From e864a9e08bab388f3f9ecc6efdf5f9b33e6c3411 Mon Sep 17 00:00:00 2001 From: busya Date: Sun, 8 Mar 2026 10:19:38 +0300 Subject: [PATCH] feat: Implement user profile preferences for start page, Git identity, and task drawer auto-open, alongside Git server default branch configuration. --- .../routes/__tests__/test_git_status_route.py | 118 +++++ .../api/routes/__tests__/test_profile_api.py | 50 ++ backend/src/api/routes/git.py | 153 +++++- backend/src/api/routes/git_schemas.py | 15 +- backend/src/api/routes/profile.py | 23 +- backend/src/core/database.py | 104 +++- backend/src/models/git.py | 1 + backend/src/models/profile.py | 13 +- backend/src/schemas/profile.py | 73 ++- backend/src/services/git_service.py | 46 ++ backend/src/services/profile_service.py | 339 ++++++++++++- backend/test_pat_api.py | 20 + backend/tests/core/test_defensive_guards.py | 17 + .../lib/components/layout/TopNavbar.svelte | 15 + frontend/src/lib/i18n/locales/en.json | 30 +- frontend/src/lib/i18n/locales/ru.json | 30 +- .../lib/stores/__tests__/taskDrawer.test.js | 30 +- frontend/src/lib/stores/taskDrawer.js | 58 ++- frontend/src/routes/+page.svelte | 51 +- frontend/src/routes/dashboards/+page.svelte | 11 +- .../src/routes/dashboards/[id]/+page.svelte | 8 +- frontend/src/routes/datasets/+page.svelte | 9 +- frontend/src/routes/profile/+page.svelte | 479 +++++++++++++++--- .../profile-preferences.integration.test.js | 57 ++- ...profile-settings-state.integration.test.js | 67 ++- frontend/src/routes/settings/git/+page.svelte | 363 +++++++++---- frontend/src/services/gitService.js | 14 + specs/024-user-dashboard-filter/tasks.md | 14 +- test_db.py | 13 + test_pat_retrieve.py | 31 ++ 30 files changed, 2041 insertions(+), 211 deletions(-) create mode 100644 backend/test_pat_api.py create mode 100644 test_db.py create mode 100644 test_pat_retrieve.py diff --git a/backend/src/api/routes/__tests__/test_git_status_route.py b/backend/src/api/routes/__tests__/test_git_status_route.py index 30bb4167..d5e94941 100644 --- a/backend/src/api/routes/__tests__/test_git_status_route.py +++ b/backend/src/api/routes/__tests__/test_git_status_route.py @@ -8,6 +8,7 @@ from fastapi import HTTPException import pytest import asyncio +from unittest.mock import MagicMock from src.api.routes import git as git_routes @@ -195,4 +196,121 @@ def test_get_repository_status_batch_deduplicates_and_truncates_ids(monkeypatch) assert "1" in response.statuses # [/DEF:test_get_repository_status_batch_deduplicates_and_truncates_ids:Function] + +# [DEF:test_commit_changes_applies_profile_identity_before_commit:Function] +# @PURPOSE: Ensure commit route configures repository identity from profile preferences before commit call. +# @PRE: Profile preference contains git_username/git_email for current user. +# @POST: git_service.configure_identity receives resolved identity and commit proceeds. +def test_commit_changes_applies_profile_identity_before_commit(monkeypatch): + class IdentityGitService: + def __init__(self): + self.configured_identity = None + self.commit_payload = None + + def configure_identity(self, dashboard_id: int, git_username: str, git_email: str): + self.configured_identity = (dashboard_id, git_username, git_email) + + def commit_changes(self, dashboard_id: int, message: str, files): + self.commit_payload = (dashboard_id, message, files) + + class PreferenceRow: + git_username = "user_1" + git_email = "user1@mail.ru" + + class PreferenceQuery: + def filter(self, *_args, **_kwargs): + return self + + def first(self): + return PreferenceRow() + + class DbStub: + def query(self, _model): + return PreferenceQuery() + + class UserStub: + id = "u-1" + + class CommitPayload: + message = "test" + files = ["dashboards/a.yaml"] + + identity_service = IdentityGitService() + monkeypatch.setattr(git_routes, "git_service", identity_service) + monkeypatch.setattr( + git_routes, + "_resolve_dashboard_id_from_ref", + lambda *_args, **_kwargs: 12, + ) + + asyncio.run( + git_routes.commit_changes( + "dashboard-12", + CommitPayload(), + config_manager=MagicMock(), + db=DbStub(), + current_user=UserStub(), + ) + ) + + assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru") + assert identity_service.commit_payload == (12, "test", ["dashboards/a.yaml"]) +# [/DEF:test_commit_changes_applies_profile_identity_before_commit:Function] + + +# [DEF:test_pull_changes_applies_profile_identity_before_pull:Function] +# @PURPOSE: Ensure pull route configures repository identity from profile preferences before pull call. +# @PRE: Profile preference contains git_username/git_email for current user. +# @POST: git_service.configure_identity receives resolved identity and pull proceeds. +def test_pull_changes_applies_profile_identity_before_pull(monkeypatch): + class IdentityGitService: + def __init__(self): + self.configured_identity = None + self.pulled_dashboard_id = None + + def configure_identity(self, dashboard_id: int, git_username: str, git_email: str): + self.configured_identity = (dashboard_id, git_username, git_email) + + def pull_changes(self, dashboard_id: int): + self.pulled_dashboard_id = dashboard_id + + class PreferenceRow: + git_username = "user_1" + git_email = "user1@mail.ru" + + class PreferenceQuery: + def filter(self, *_args, **_kwargs): + return self + + def first(self): + return PreferenceRow() + + class DbStub: + def query(self, _model): + return PreferenceQuery() + + class UserStub: + id = "u-1" + + identity_service = IdentityGitService() + monkeypatch.setattr(git_routes, "git_service", identity_service) + monkeypatch.setattr( + git_routes, + "_resolve_dashboard_id_from_ref", + lambda *_args, **_kwargs: 12, + ) + + asyncio.run( + git_routes.pull_changes( + "dashboard-12", + config_manager=MagicMock(), + db=DbStub(), + current_user=UserStub(), + ) + ) + + assert identity_service.configured_identity == (12, "user_1", "user1@mail.ru") + assert identity_service.pulled_dashboard_id == 12 +# [/DEF:test_pull_changes_applies_profile_identity_before_pull:Function] + # [/DEF:backend.src.api.routes.__tests__.test_git_status_route:Module] diff --git a/backend/src/api/routes/__tests__/test_profile_api.py b/backend/src/api/routes/__tests__/test_profile_api.py index b319a6cd..fc57af2b 100644 --- a/backend/src/api/routes/__tests__/test_profile_api.py +++ b/backend/src/api/routes/__tests__/test_profile_api.py @@ -15,8 +15,10 @@ from src.app import app from src.core.database import get_db from src.dependencies import get_config_manager, get_current_user from src.schemas.profile import ( + ProfilePermissionState, ProfilePreference, ProfilePreferenceResponse, + ProfileSecuritySummary, SupersetAccountCandidate, SupersetAccountLookupResponse, ) @@ -80,9 +82,27 @@ def _build_preference_response(user_id: str = "u-1") -> ProfilePreferenceRespons superset_username="John_Doe", superset_username_normalized="john_doe", show_only_my_dashboards=True, + git_username="ivan.ivanov", + git_email="ivan@company.local", + has_git_personal_access_token=True, + git_personal_access_token_masked="iv***al", + start_page="reports", + auto_open_task_drawer=False, + dashboards_table_density="compact", created_at=now, updated_at=now, ), + security=ProfileSecuritySummary( + read_only=True, + auth_source="adfs", + current_role="Data Engineer", + role_source="adfs", + roles=["Data Engineer"], + permissions=[ + ProfilePermissionState(key="migration:run", allowed=True), + ProfilePermissionState(key="admin:users", allowed=False), + ], + ), ) # [/DEF:_build_preference_response:Function] @@ -104,6 +124,16 @@ def test_get_profile_preferences_returns_self_payload(profile_route_deps_fixture assert payload["status"] == "success" assert payload["preference"]["user_id"] == mock_user.id assert payload["preference"]["superset_username_normalized"] == "john_doe" + assert payload["preference"]["git_username"] == "ivan.ivanov" + assert payload["preference"]["git_email"] == "ivan@company.local" + assert payload["preference"]["has_git_personal_access_token"] is True + assert payload["preference"]["git_personal_access_token_masked"] == "iv***al" + assert payload["preference"]["start_page"] == "reports" + assert payload["preference"]["auto_open_task_drawer"] is False + assert payload["preference"]["dashboards_table_density"] == "compact" + assert payload["security"]["read_only"] is True + assert payload["security"]["current_role"] == "Data Engineer" + assert payload["security"]["permissions"][0]["key"] == "migration:run" service.get_my_preference.assert_called_once_with(mock_user) # [/DEF:test_get_profile_preferences_returns_self_payload:Function] @@ -123,6 +153,12 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture): json={ "superset_username": "John_Doe", "show_only_my_dashboards": True, + "git_username": "ivan.ivanov", + "git_email": "ivan@company.local", + "git_personal_access_token": "ghp_1234567890", + "start_page": "reports-logs", + "auto_open_task_drawer": False, + "dashboards_table_density": "free", }, ) @@ -131,7 +167,21 @@ def test_patch_profile_preferences_success(profile_route_deps_fixture): assert payload["status"] == "success" assert payload["preference"]["superset_username"] == "John_Doe" assert payload["preference"]["show_only_my_dashboards"] is True + assert payload["preference"]["git_username"] == "ivan.ivanov" + assert payload["preference"]["git_email"] == "ivan@company.local" + assert payload["preference"]["start_page"] == "reports" + assert payload["preference"]["auto_open_task_drawer"] is False + assert payload["preference"]["dashboards_table_density"] == "compact" service.update_my_preference.assert_called_once() + + called_kwargs = service.update_my_preference.call_args.kwargs + assert called_kwargs["current_user"] == mock_user + assert called_kwargs["payload"].git_username == "ivan.ivanov" + assert called_kwargs["payload"].git_email == "ivan@company.local" + assert called_kwargs["payload"].git_personal_access_token == "ghp_1234567890" + assert called_kwargs["payload"].start_page == "reports-logs" + assert called_kwargs["payload"].auto_open_task_drawer is False + assert called_kwargs["payload"].dashboards_table_density == "free" # [/DEF:test_patch_profile_preferences_success:Function] diff --git a/backend/src/api/routes/git.py b/backend/src/api/routes/git.py index 90500d0f..da3e819e 100644 --- a/backend/src/api/routes/git.py +++ b/backend/src/api/routes/git.py @@ -15,11 +15,13 @@ from sqlalchemy.orm import Session from typing import List, Optional import typing import os -from src.dependencies import get_config_manager, has_permission +from src.dependencies import get_config_manager, get_current_user, has_permission from src.core.database import get_db +from src.models.auth import User from src.models.git import GitServerConfig, GitRepository, GitProvider +from src.models.profile import UserDashboardPreference from src.api.routes.git_schemas import ( - GitServerConfigSchema, GitServerConfigCreate, + GitServerConfigSchema, GitServerConfigCreate, GitServerConfigUpdate, BranchSchema, BranchCreate, BranchCheckout, CommitSchema, CommitCreate, DeploymentEnvironmentSchema, DeployRequest, RepoInitRequest, @@ -208,6 +210,84 @@ def _resolve_repo_key_from_ref( return f"dashboard-{dashboard_id}" # [/DEF:_resolve_repo_key_from_ref:Function] + +# [DEF:_sanitize_optional_identity_value:Function] +# @PURPOSE: Normalize optional identity value into trimmed string or None. +# @PRE: value may be None or blank. +# @POST: Returns sanitized value suitable for git identity configuration. +# @RETURN: Optional[str] +def _sanitize_optional_identity_value(value: Optional[str]) -> Optional[str]: + normalized = str(value or "").strip() + if not normalized: + return None + return normalized +# [/DEF:_sanitize_optional_identity_value:Function] + + +# [DEF:_resolve_current_user_git_identity:Function] +# @PURPOSE: Resolve configured Git username/email from current user's profile preferences. +# @PRE: `db` may be stubbed in tests; `current_user` may be absent for direct handler invocations. +# @POST: Returns tuple(username, email) only when both values are configured. +# @RETURN: Optional[tuple[str, str]] +def _resolve_current_user_git_identity( + db: Session, + current_user: Optional[User], +) -> Optional[tuple[str, str]]: + if db is None or not hasattr(db, "query"): + return None + + user_id = _sanitize_optional_identity_value(getattr(current_user, "id", None)) + if not user_id: + return None + + try: + preference = ( + db.query(UserDashboardPreference) + .filter(UserDashboardPreference.user_id == user_id) + .first() + ) + except Exception as resolve_error: + logger.warning( + "[_resolve_current_user_git_identity][Action] Failed to load profile preference for user %s: %s", + user_id, + resolve_error, + ) + return None + + if not preference: + return None + + git_username = _sanitize_optional_identity_value(getattr(preference, "git_username", None)) + git_email = _sanitize_optional_identity_value(getattr(preference, "git_email", None)) + if not git_username or not git_email: + return None + return git_username, git_email +# [/DEF:_resolve_current_user_git_identity:Function] + + +# [DEF:_apply_git_identity_from_profile:Function] +# @PURPOSE: Apply user-scoped Git identity to repository-local config before write/pull operations. +# @PRE: dashboard_id is resolved; db/current_user may be missing in direct test invocation context. +# @POST: git_service.configure_identity is called only when identity and method are available. +# @RETURN: None +def _apply_git_identity_from_profile( + dashboard_id: int, + db: Session, + current_user: Optional[User], +) -> None: + identity = _resolve_current_user_git_identity(db, current_user) + if not identity: + return + + configure_identity = getattr(git_service, "configure_identity", None) + if not callable(configure_identity): + return + + git_username, git_email = identity + configure_identity(dashboard_id, git_username, git_email) +# [/DEF:_apply_git_identity_from_profile:Function] + + # [DEF:get_git_configs:Function] # @PURPOSE: List all configured Git servers. # @PRE: Database session `db` is available. @@ -219,7 +299,13 @@ async def get_git_configs( _ = Depends(has_permission("admin:settings", "READ")) ): with belief_scope("get_git_configs"): - return db.query(GitServerConfig).all() + configs = db.query(GitServerConfig).all() + result = [] + for config in configs: + schema = GitServerConfigSchema.from_orm(config) + schema.pat = "********" + result.append(schema) + return result # [/DEF:get_git_configs:Function] # [DEF:create_git_config:Function] @@ -242,6 +328,40 @@ async def create_git_config( return db_config # [/DEF:create_git_config:Function] +# [DEF:update_git_config:Function] +# @PURPOSE: Update an existing Git server configuration. +# @PRE: `config_id` corresponds to an existing configuration. +# @POST: The configuration record is updated in the database. +# @PARAM: config_id (str) +# @PARAM: config_update (GitServerConfigUpdate) +# @RETURN: GitServerConfigSchema +@router.put("/config/{config_id}", response_model=GitServerConfigSchema) +async def update_git_config( + config_id: str, + config_update: GitServerConfigUpdate, + db: Session = Depends(get_db), + _ = Depends(has_permission("admin:settings", "WRITE")) +): + with belief_scope("update_git_config"): + db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first() + if not db_config: + raise HTTPException(status_code=404, detail="Configuration not found") + + update_data = config_update.dict(exclude_unset=True) + if update_data.get("pat") == "********": + update_data.pop("pat") + + for key, value in update_data.items(): + setattr(db_config, key, value) + + db.commit() + db.refresh(db_config) + + result_schema = GitServerConfigSchema.from_orm(db_config) + result_schema.pat = "********" + return result_schema +# [/DEF:update_git_config:Function] + # [DEF:delete_git_config:Function] # @PURPOSE: Remove a Git server configuration. # @PRE: `config_id` corresponds to an existing configuration. @@ -271,10 +391,22 @@ async def delete_git_config( @router.post("/config/test") async def test_git_config( config: GitServerConfigCreate, + db: Session = Depends(get_db), _ = Depends(has_permission("admin:settings", "READ")) ): with belief_scope("test_git_config"): - success = await git_service.test_connection(config.provider, config.url, config.pat) + pat_to_use = config.pat + if pat_to_use == "********": + if config.config_id: + db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config.config_id).first() + if db_config: + pat_to_use = db_config.pat + else: + db_config = db.query(GitServerConfig).filter(GitServerConfig.url == config.url).first() + if db_config: + pat_to_use = db_config.pat + + success = await git_service.test_connection(config.provider, config.url, pat_to_use) if success: return {"status": "success", "message": "Connection successful"} else: @@ -459,7 +591,7 @@ async def init_repository( try: # 2. Perform Git clone/init logger.info(f"[init_repository][Action] Initializing repo for dashboard {dashboard_id}") - git_service.init_repo(dashboard_id, init_data.remote_url, config.pat, repo_key=repo_key) + git_service.init_repo(dashboard_id, init_data.remote_url, config.pat, repo_key=repo_key, default_branch=config.default_branch) # 3. Save to DB repo_path = git_service._get_repo_path(dashboard_id, repo_key=repo_key) @@ -583,11 +715,14 @@ async def create_branch( branch_data: BranchCreate, env_id: Optional[str] = None, config_manager=Depends(get_config_manager), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("create_branch"): try: dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + _apply_git_identity_from_profile(dashboard_id, db, current_user) git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch) return {"status": "success"} except HTTPException: @@ -633,11 +768,14 @@ async def commit_changes( commit_data: CommitCreate, env_id: Optional[str] = None, config_manager=Depends(get_config_manager), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("commit_changes"): try: dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + _apply_git_identity_from_profile(dashboard_id, db, current_user) git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files) return {"status": "success"} except HTTPException: @@ -679,11 +817,14 @@ async def pull_changes( dashboard_ref: str, env_id: Optional[str] = None, config_manager=Depends(get_config_manager), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("pull_changes"): try: dashboard_id = _resolve_dashboard_id_from_ref(dashboard_ref, config_manager, env_id) + _apply_git_identity_from_profile(dashboard_id, db, current_user) git_service.pull_changes(dashboard_id) return {"status": "success"} except HTTPException: @@ -734,6 +875,7 @@ async def promote_dashboard( env_id: Optional[str] = None, config_manager=Depends(get_config_manager), db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), _ = Depends(has_permission("plugin:git", "EXECUTE")) ): with belief_scope("promote_dashboard"): @@ -762,6 +904,7 @@ async def promote_dashboard( to_branch, reason, ) + _apply_git_identity_from_profile(dashboard_id, db, current_user) result = git_service.promote_direct_merge( dashboard_id=dashboard_id, from_branch=from_branch, diff --git a/backend/src/api/routes/git_schemas.py b/backend/src/api/routes/git_schemas.py index d1c575a4..95f95a9e 100644 --- a/backend/src/api/routes/git_schemas.py +++ b/backend/src/api/routes/git_schemas.py @@ -21,14 +21,27 @@ class GitServerConfigBase(BaseModel): provider: GitProvider = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)") url: str = Field(..., description="Server base URL") pat: str = Field(..., description="Personal Access Token") + pat: str = Field(..., description="Personal Access Token") default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)") + default_branch: Optional[str] = Field("main", description="Default branch logic/name") # [/DEF:GitServerConfigBase:Class] +# [DEF:GitServerConfigUpdate:Class] +# @PURPOSE: Schema for updating an existing Git server configuration. +class GitServerConfigUpdate(BaseModel): + name: Optional[str] = Field(None, description="Display name for the Git server") + provider: Optional[GitProvider] = Field(None, description="Git provider (GITHUB, GITLAB, GITEA)") + url: Optional[str] = Field(None, description="Server base URL") + pat: Optional[str] = Field(None, description="Personal Access Token") + default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)") + default_branch: Optional[str] = Field(None, description="Default branch logic/name") +# [/DEF:GitServerConfigUpdate:Class] + # [DEF:GitServerConfigCreate:Class] # @PURPOSE: Schema for creating a new Git server configuration. class GitServerConfigCreate(GitServerConfigBase): """Schema for creating a new Git server configuration.""" - pass + config_id: Optional[str] = Field(None, description="Optional config ID, useful for testing an existing config without sending its full PAT") # [/DEF:GitServerConfigCreate:Class] # [DEF:GitServerConfigSchema:Class] diff --git a/backend/src/api/routes/profile.py b/backend/src/api/routes/profile.py index 77900513..825c45a9 100644 --- a/backend/src/api/routes/profile.py +++ b/backend/src/api/routes/profile.py @@ -23,7 +23,11 @@ from sqlalchemy.orm import Session from ...core.database import get_db from ...core.logger import logger, belief_scope -from ...dependencies import get_config_manager, get_current_user +from ...dependencies import ( + get_config_manager, + get_current_user, + get_plugin_loader, +) from ...models.auth import User from ...schemas.profile import ( ProfilePreferenceResponse, @@ -46,8 +50,12 @@ router = APIRouter(prefix="/api/profile", tags=["profile"]) # @PURPOSE: Build profile service for current request scope. # @PRE: db session and config manager are available. # @POST: Returns a ready ProfileService instance. -def _get_profile_service(db: Session, config_manager) -> ProfileService: - return ProfileService(db=db, config_manager=config_manager) +def _get_profile_service(db: Session, config_manager, plugin_loader=None) -> ProfileService: + return ProfileService( + db=db, + config_manager=config_manager, + plugin_loader=plugin_loader, + ) # [/DEF:_get_profile_service:Function] @@ -60,10 +68,11 @@ async def get_preferences( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), config_manager=Depends(get_config_manager), + plugin_loader=Depends(get_plugin_loader), ): with belief_scope("profile.get_preferences", f"user_id={current_user.id}"): logger.reason("[REASON] Resolving current user preference") - service = _get_profile_service(db, config_manager) + service = _get_profile_service(db, config_manager, plugin_loader) return service.get_my_preference(current_user) # [/DEF:get_preferences:Function] @@ -78,9 +87,10 @@ async def update_preferences( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), config_manager=Depends(get_config_manager), + plugin_loader=Depends(get_plugin_loader), ): with belief_scope("profile.update_preferences", f"user_id={current_user.id}"): - service = _get_profile_service(db, config_manager) + service = _get_profile_service(db, config_manager, plugin_loader) try: logger.reason("[REASON] Attempting preference save") return service.update_my_preference(current_user=current_user, payload=payload) @@ -108,12 +118,13 @@ async def lookup_superset_accounts( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), config_manager=Depends(get_config_manager), + plugin_loader=Depends(get_plugin_loader), ): with belief_scope( "profile.lookup_superset_accounts", f"user_id={current_user.id}, environment_id={environment_id}", ): - service = _get_profile_service(db, config_manager) + service = _get_profile_service(db, config_manager, plugin_loader) lookup_request = SupersetAccountLookupRequest( environment_id=environment_id, search=search, diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 2b7e54da..735c1c82 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -11,7 +11,7 @@ # @INVARIANT: A single engine instance is used for the entire application. # [SECTION: IMPORTS] -from sqlalchemy import create_engine +from sqlalchemy import create_engine, inspect, text from sqlalchemy.orm import sessionmaker from ..models.mapping import Base # Import models to ensure they're registered with Base @@ -21,7 +21,7 @@ from ..models import config as _config_models # noqa: F401 from ..models import llm as _llm_models # noqa: F401 from ..models import assistant as _assistant_models # noqa: F401 from ..models import profile as _profile_models # noqa: F401 -from .logger import belief_scope +from .logger import belief_scope, logger from .auth.config import auth_config import os from pathlib import Path @@ -95,6 +95,104 @@ TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_e AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine) # [/DEF:AuthSessionLocal:Class] +# [DEF:_ensure_user_dashboard_preferences_columns:Function] +# @PURPOSE: Applies additive schema upgrades for user_dashboard_preferences table. +# @PRE: bind_engine points to application database where profile table is stored. +# @POST: Missing columns are added without data loss. +def _ensure_user_dashboard_preferences_columns(bind_engine): + with belief_scope("_ensure_user_dashboard_preferences_columns"): + table_name = "user_dashboard_preferences" + inspector = inspect(bind_engine) + if table_name not in inspector.get_table_names(): + return + + existing_columns = { + str(column.get("name") or "").strip() + for column in inspector.get_columns(table_name) + } + + alter_statements = [] + if "git_username" not in existing_columns: + alter_statements.append( + "ALTER TABLE user_dashboard_preferences ADD COLUMN git_username VARCHAR" + ) + if "git_email" not in existing_columns: + alter_statements.append( + "ALTER TABLE user_dashboard_preferences ADD COLUMN git_email VARCHAR" + ) + if "git_personal_access_token_encrypted" not in existing_columns: + alter_statements.append( + "ALTER TABLE user_dashboard_preferences " + "ADD COLUMN git_personal_access_token_encrypted VARCHAR" + ) + if "start_page" not in existing_columns: + alter_statements.append( + "ALTER TABLE user_dashboard_preferences " + "ADD COLUMN start_page VARCHAR NOT NULL DEFAULT 'dashboards'" + ) + if "auto_open_task_drawer" not in existing_columns: + alter_statements.append( + "ALTER TABLE user_dashboard_preferences " + "ADD COLUMN auto_open_task_drawer BOOLEAN NOT NULL DEFAULT TRUE" + ) + if "dashboards_table_density" not in existing_columns: + alter_statements.append( + "ALTER TABLE user_dashboard_preferences " + "ADD COLUMN dashboards_table_density VARCHAR NOT NULL DEFAULT 'comfortable'" + ) + + if not alter_statements: + return + + try: + with bind_engine.begin() as connection: + for statement in alter_statements: + connection.execute(text(statement)) + except Exception as migration_error: + logger.warning( + "[database][EXPLORE] Profile preference additive migration failed: %s", + migration_error, + ) +# [/DEF:_ensure_user_dashboard_preferences_columns:Function] + + +# [DEF:_ensure_git_server_configs_columns:Function] +# @PURPOSE: Applies additive schema upgrades for git_server_configs table. +# @PRE: bind_engine points to application database. +# @POST: Missing columns are added without data loss. +def _ensure_git_server_configs_columns(bind_engine): + with belief_scope("_ensure_git_server_configs_columns"): + table_name = "git_server_configs" + inspector = inspect(bind_engine) + if table_name not in inspector.get_table_names(): + return + + existing_columns = { + str(column.get("name") or "").strip() + for column in inspector.get_columns(table_name) + } + + alter_statements = [] + if "default_branch" not in existing_columns: + alter_statements.append( + "ALTER TABLE git_server_configs ADD COLUMN default_branch VARCHAR NOT NULL DEFAULT 'main'" + ) + + if not alter_statements: + return + + try: + with bind_engine.begin() as connection: + for statement in alter_statements: + connection.execute(text(statement)) + except Exception as migration_error: + logger.warning( + "[database][EXPLORE] GitServerConfig preference additive migration failed: %s", + migration_error, + ) +# [/DEF:_ensure_git_server_configs_columns:Function] + + # [DEF:init_db:Function] # @PURPOSE: Initializes the database by creating all tables. # @PRE: engine, tasks_engine and auth_engine are initialized. @@ -105,6 +203,8 @@ def init_db(): Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=tasks_engine) Base.metadata.create_all(bind=auth_engine) + _ensure_user_dashboard_preferences_columns(engine) + _ensure_git_server_configs_columns(engine) # [/DEF:init_db:Function] # [DEF:get_db:Function] diff --git a/backend/src/models/git.py b/backend/src/models/git.py index 222f5dc6..938b282f 100644 --- a/backend/src/models/git.py +++ b/backend/src/models/git.py @@ -38,6 +38,7 @@ class GitServerConfig(Base): url = Column(String(255), nullable=False) pat = Column(String(255), nullable=False) # PERSONAL ACCESS TOKEN default_repository = Column(String(255), nullable=True) + default_branch = Column(String(255), default="main") status = Column(Enum(GitStatus), default=GitStatus.UNKNOWN) last_validated = Column(DateTime, default=datetime.utcnow) # [/DEF:GitServerConfig:Class] diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index a1ad6cd9..c562ed61 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -1,13 +1,14 @@ # [DEF:backend.src.models.profile:Module] # # @TIER: STANDARD -# @SEMANTICS: profile, preferences, persistence, user, dashboard-filter, sqlalchemy -# @PURPOSE: Defines persistent per-user dashboard filter preferences. +# @SEMANTICS: profile, preferences, persistence, user, dashboard-filter, git, ui-preferences, sqlalchemy +# @PURPOSE: Defines persistent per-user profile settings for dashboard filter, Git identity/token, and UX preferences. # @LAYER: Domain # @RELATION: DEPENDS_ON -> backend.src.models.auth # @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base # # @INVARIANT: Exactly one preference row exists per user_id. +# @INVARIANT: Sensitive Git token is stored encrypted and never returned in plaintext. # [SECTION: IMPORTS] import uuid @@ -32,6 +33,14 @@ class UserDashboardPreference(Base): show_only_my_dashboards = Column(Boolean, nullable=False, default=False) + git_username = Column(String, nullable=True) + git_email = Column(String, nullable=True) + git_personal_access_token_encrypted = Column(String, nullable=True) + + start_page = Column(String, nullable=False, default="dashboards") + auto_open_task_drawer = Column(Boolean, nullable=False, default=True) + dashboards_table_density = Column(String, nullable=False, default="comfortable") + created_at = Column(DateTime, nullable=False, default=datetime.utcnow) updated_at = Column( DateTime, diff --git a/backend/src/schemas/profile.py b/backend/src/schemas/profile.py index 02f1bc3f..ac7ede5a 100644 --- a/backend/src/schemas/profile.py +++ b/backend/src/schemas/profile.py @@ -1,12 +1,12 @@ # [DEF:backend.src.schemas.profile:Module] # # @TIER: STANDARD -# @SEMANTICS: profile, schemas, pydantic, preferences, superset, lookup -# @PURPOSE: Defines API schemas for profile preference persistence and Superset account lookup flows. +# @SEMANTICS: profile, schemas, pydantic, preferences, superset, lookup, security, git, ux +# @PURPOSE: Defines API schemas for profile preference persistence, security read-only snapshot, and Superset account lookup. # @LAYER: API # @RELATION: DEPENDS_ON -> pydantic # -# @INVARIANT: Schema shapes stay stable for profile UI states and dashboards filter metadata. +# @INVARIANT: Schema shapes stay stable for profile UI states and backend preference contracts. # [SECTION: IMPORTS] from datetime import datetime @@ -15,6 +15,28 @@ from pydantic import BaseModel, Field # [/SECTION] +# [DEF:ProfilePermissionState:Class] +# @TIER: STANDARD +# @PURPOSE: Represents one permission badge state for profile read-only security view. +class ProfilePermissionState(BaseModel): + key: str + allowed: bool +# [/DEF:ProfilePermissionState:Class] + + +# [DEF:ProfileSecuritySummary:Class] +# @TIER: STANDARD +# @PURPOSE: Read-only security and access snapshot for current user. +class ProfileSecuritySummary(BaseModel): + read_only: bool = True + auth_source: Optional[str] = None + current_role: Optional[str] = None + role_source: Optional[str] = None + roles: List[str] = Field(default_factory=list) + permissions: List[ProfilePermissionState] = Field(default_factory=list) +# [/DEF:ProfileSecuritySummary:Class] + + # [DEF:ProfilePreference:Class] # @TIER: STANDARD # @PURPOSE: Represents persisted profile preference for a single authenticated user. @@ -23,6 +45,16 @@ class ProfilePreference(BaseModel): superset_username: Optional[str] = None superset_username_normalized: Optional[str] = None show_only_my_dashboards: bool = False + + git_username: Optional[str] = None + git_email: Optional[str] = None + has_git_personal_access_token: bool = False + git_personal_access_token_masked: Optional[str] = None + + start_page: Literal["dashboards", "datasets", "reports"] = "dashboards" + auto_open_task_drawer: bool = True + dashboards_table_density: Literal["compact", "comfortable"] = "comfortable" + created_at: datetime updated_at: datetime @@ -33,16 +65,44 @@ class ProfilePreference(BaseModel): # [DEF:ProfilePreferenceUpdateRequest:Class] # @TIER: STANDARD -# @PURPOSE: Request payload for updating current user's dashboard filter preference. +# @PURPOSE: Request payload for updating current user's profile settings. class ProfilePreferenceUpdateRequest(BaseModel): superset_username: Optional[str] = Field( default=None, description="Apache Superset username bound to current user profile.", ) - show_only_my_dashboards: bool = Field( - default=False, + show_only_my_dashboards: Optional[bool] = Field( + default=None, description='When true, "/dashboards" can auto-apply profile filter in main context.', ) + git_username: Optional[str] = Field( + default=None, + description="Git author username used for commit signature.", + ) + git_email: Optional[str] = Field( + default=None, + description="Git author email used for commit signature.", + ) + git_personal_access_token: Optional[str] = Field( + default=None, + description="Personal Access Token value. Empty string clears existing token.", + ) + start_page: Optional[ + Literal["dashboards", "datasets", "reports", "reports-logs"] + ] = Field( + default=None, + description="Preferred start page after login.", + ) + auto_open_task_drawer: Optional[bool] = Field( + default=None, + description="Auto-open task drawer when long-running tasks start.", + ) + dashboards_table_density: Optional[ + Literal["compact", "comfortable", "free"] + ] = Field( + default=None, + description="Preferred table density for dashboard listings.", + ) # [/DEF:ProfilePreferenceUpdateRequest:Class] @@ -54,6 +114,7 @@ class ProfilePreferenceResponse(BaseModel): message: Optional[str] = None validation_errors: List[str] = Field(default_factory=list) preference: ProfilePreference + security: ProfileSecuritySummary = Field(default_factory=ProfileSecuritySummary) # [/DEF:ProfilePreferenceResponse:Class] diff --git a/backend/src/services/git_service.py b/backend/src/services/git_service.py index 3a95c2d6..1157a296 100644 --- a/backend/src/services/git_service.py +++ b/backend/src/services/git_service.py @@ -411,6 +411,37 @@ class GitService: raise HTTPException(status_code=500, detail="Failed to open local Git repository") # [/DEF:get_repo:Function] + # [DEF:configure_identity:Function] + # @PURPOSE: Configure repository-local Git committer identity for user-scoped operations. + # @PRE: dashboard_id repository exists; git_username/git_email may be empty. + # @POST: Repository config has user.name and user.email when both identity values are provided. + # @RETURN: None + def configure_identity( + self, + dashboard_id: int, + git_username: Optional[str], + git_email: Optional[str], + ) -> None: + with belief_scope("GitService.configure_identity"): + normalized_username = str(git_username or "").strip() + normalized_email = str(git_email or "").strip() + if not normalized_username or not normalized_email: + return + + repo = self.get_repo(dashboard_id) + try: + with repo.config_writer(config_level="repository") as config_writer: + config_writer.set_value("user", "name", normalized_username) + config_writer.set_value("user", "email", normalized_email) + logger.info( + "[configure_identity][Action] Applied repository-local git identity for dashboard %s", + dashboard_id, + ) + except Exception as e: + logger.error(f"[configure_identity][Coherence:Failed] Failed to configure git identity: {e}") + raise HTTPException(status_code=500, detail=f"Failed to configure git identity: {str(e)}") + # [/DEF:configure_identity:Function] + # [DEF:list_branches:Function] # @PURPOSE: List all branches for a dashboard's repository. # @PRE: Repository for dashboard_id exists. @@ -601,6 +632,21 @@ class GitService: def pull_changes(self, dashboard_id: int): with belief_scope("GitService.pull_changes"): repo = self.get_repo(dashboard_id) + + # Check for unfinished merge (MERGE_HEAD exists) + merge_head_path = os.path.join(repo.git_dir, "MERGE_HEAD") + if os.path.exists(merge_head_path): + logger.warning(f"[pull_changes][Action] Unfinished merge detected for dashboard {dashboard_id}") + raise HTTPException( + status_code=409, + detail=( + "В репозитории есть незавершённое слияние. " + "Пожалуйста, завершите или отмените слияние вручную: " + "1) Разрешите конфликты и выполните коммит, или " + "2) Выполните 'git merge --abort' для отмены слияния." + ), + ) + try: origin = repo.remote(name='origin') current_branch = repo.active_branch.name diff --git a/backend/src/services/profile_service.py b/backend/src/services/profile_service.py index a2d7445c..9af238d4 100644 --- a/backend/src/services/profile_service.py +++ b/backend/src/services/profile_service.py @@ -23,7 +23,7 @@ # [SECTION: IMPORTS] from datetime import datetime -from typing import Any, Iterable, List, Optional, Sequence +from typing import Any, Iterable, List, Optional, Sequence, Set, Tuple from sqlalchemy.orm import Session from ..core.auth.repository import AuthRepository @@ -32,16 +32,23 @@ from ..core.superset_client import SupersetClient from ..core.superset_profile_lookup import SupersetAccountLookupAdapter from ..models.auth import User from ..models.profile import UserDashboardPreference +from .llm_provider import EncryptionManager +from .rbac_permission_catalog import discover_declared_permissions from ..schemas.profile import ( + ProfilePermissionState, ProfilePreference, ProfilePreferenceResponse, ProfilePreferenceUpdateRequest, + ProfileSecuritySummary, SupersetAccountLookupRequest, SupersetAccountLookupResponse, SupersetAccountCandidate, ) # [/SECTION] +SUPPORTED_START_PAGES = {"dashboards", "datasets", "reports"} +SUPPORTED_DENSITIES = {"compact", "comfortable"} + # [DEF:ProfileValidationError:Class] # @TIER: STANDARD @@ -77,10 +84,12 @@ class ProfileService: # @PURPOSE: Initialize service with DB session and config manager. # @PRE: db session is active and config_manager supports get_environments(). # @POST: Service is ready for preference persistence and lookup operations. - def __init__(self, db: Session, config_manager: Any): + def __init__(self, db: Session, config_manager: Any, plugin_loader: Any = None): self.db = db self.config_manager = config_manager + self.plugin_loader = plugin_loader self.auth_repository = AuthRepository(db) + self.encryption = EncryptionManager() # [/DEF:__init__:Function] # [DEF:get_my_preference:Function] @@ -91,16 +100,20 @@ class ProfileService: with belief_scope("ProfileService.get_my_preference", f"user_id={current_user.id}"): logger.reflect("[REFLECT] Loading current user's dashboard preference") preference = self._get_preference_row(current_user.id) + security_summary = self._build_security_summary(current_user) + if preference is None: return ProfilePreferenceResponse( status="success", message="Preference not configured yet", preference=self._build_default_preference(current_user.id), + security=security_summary, ) return ProfilePreferenceResponse( status="success", message="Preference loaded", - preference=ProfilePreference.model_validate(preference, from_attributes=True), + preference=self._to_preference_payload(preference, str(current_user.id)), + security=security_summary, ) # [/DEF:get_my_preference:Function] @@ -121,27 +134,90 @@ class ProfileService: logger.explore("[EXPLORE] Cross-user mutation attempt blocked") raise ProfileAuthorizationError("Cross-user preference mutation is forbidden") - validation_errors = self._validate_update_payload(payload) + preference = self._get_or_create_preference_row(current_user.id) + provided_fields = set(getattr(payload, "model_fields_set", set())) + + effective_superset_username = self._sanitize_username(preference.superset_username) + if "superset_username" in provided_fields: + effective_superset_username = self._sanitize_username(payload.superset_username) + + effective_show_only = bool(preference.show_only_my_dashboards) + if "show_only_my_dashboards" in provided_fields: + effective_show_only = bool(payload.show_only_my_dashboards) + + effective_git_username = self._sanitize_text(preference.git_username) + if "git_username" in provided_fields: + effective_git_username = self._sanitize_text(payload.git_username) + + effective_git_email = self._sanitize_text(preference.git_email) + if "git_email" in provided_fields: + effective_git_email = self._sanitize_text(payload.git_email) + + effective_start_page = self._normalize_start_page(preference.start_page) + if "start_page" in provided_fields: + effective_start_page = self._normalize_start_page(payload.start_page) + + effective_auto_open_task_drawer = ( + bool(preference.auto_open_task_drawer) + if preference.auto_open_task_drawer is not None + else True + ) + if "auto_open_task_drawer" in provided_fields: + effective_auto_open_task_drawer = bool(payload.auto_open_task_drawer) + + effective_dashboards_table_density = self._normalize_density( + preference.dashboards_table_density + ) + if "dashboards_table_density" in provided_fields: + effective_dashboards_table_density = self._normalize_density( + payload.dashboards_table_density + ) + + validation_errors = self._validate_update_payload( + superset_username=effective_superset_username, + show_only_my_dashboards=effective_show_only, + git_email=effective_git_email, + start_page=effective_start_page, + dashboards_table_density=effective_dashboards_table_density, + ) if validation_errors: logger.reflect("[REFLECT] Validation failed; mutation is denied") raise ProfileValidationError(validation_errors) - normalized_username = self._normalize_username(payload.superset_username) - raw_username = self._sanitize_username(payload.superset_username) + preference.superset_username = effective_superset_username + preference.superset_username_normalized = self._normalize_username( + effective_superset_username + ) + preference.show_only_my_dashboards = effective_show_only - preference = self._get_or_create_preference_row(current_user.id) - preference.superset_username = raw_username - preference.superset_username_normalized = normalized_username - preference.show_only_my_dashboards = bool(payload.show_only_my_dashboards) + preference.git_username = effective_git_username + preference.git_email = effective_git_email + + if "git_personal_access_token" in provided_fields: + sanitized_token = self._sanitize_secret(payload.git_personal_access_token) + if sanitized_token is None: + preference.git_personal_access_token_encrypted = None + else: + preference.git_personal_access_token_encrypted = self.encryption.encrypt( + sanitized_token + ) + + preference.start_page = effective_start_page + preference.auto_open_task_drawer = effective_auto_open_task_drawer + preference.dashboards_table_density = effective_dashboards_table_density preference.updated_at = datetime.utcnow() - self.auth_repository.save_user_dashboard_preference(preference) + persisted_preference = self.auth_repository.save_user_dashboard_preference(preference) logger.reason("[REASON] Preference persisted successfully") return ProfilePreferenceResponse( status="success", message="Preference saved", - preference=ProfilePreference.model_validate(preference, from_attributes=True), + preference=self._to_preference_payload( + persisted_preference, + str(current_user.id), + ), + security=self._build_security_summary(current_user), ) # [/DEF:update_my_preference:Function] @@ -245,6 +321,206 @@ class ProfileService: return False # [/DEF:matches_dashboard_actor:Function] + # [DEF:_build_security_summary:Function] + # @PURPOSE: Build read-only security snapshot with role and permission badges. + # @PRE: current_user is authenticated. + # @POST: Returns deterministic security projection for profile UI. + def _build_security_summary(self, current_user: User) -> ProfileSecuritySummary: + role_names_set: Set[str] = set() + roles = getattr(current_user, "roles", []) or [] + for role in roles: + normalized_role_name = self._sanitize_text(getattr(role, "name", None)) + if normalized_role_name: + role_names_set.add(normalized_role_name) + role_names = sorted(role_names_set) + + is_admin = any(str(role_name).lower() == "admin" for role_name in role_names) + user_permission_pairs = self._collect_user_permission_pairs(current_user) + + declared_permission_pairs: Set[Tuple[str, str]] = set() + try: + discovered_permissions = discover_declared_permissions( + plugin_loader=self.plugin_loader + ) + for resource, action in discovered_permissions: + normalized_resource = self._sanitize_text(resource) + normalized_action = str(action or "").strip().upper() + if normalized_resource and normalized_action: + declared_permission_pairs.add((normalized_resource, normalized_action)) + except Exception as discovery_error: + logger.warning( + "[ProfileService][EXPLORE] Failed to build declared permission catalog: %s", + discovery_error, + ) + + if not declared_permission_pairs: + declared_permission_pairs = set(user_permission_pairs) + + sorted_permission_pairs = sorted( + declared_permission_pairs, + key=lambda pair: (pair[0], pair[1]), + ) + permission_states = [ + ProfilePermissionState( + key=self._format_permission_key(resource, action), + allowed=bool(is_admin or (resource, action) in user_permission_pairs), + ) + for resource, action in sorted_permission_pairs + ] + + auth_source = self._sanitize_text(getattr(current_user, "auth_source", None)) + current_role = "Admin" if is_admin else (role_names[0] if role_names else None) + + return ProfileSecuritySummary( + read_only=True, + auth_source=auth_source, + current_role=current_role, + role_source=auth_source, + roles=role_names, + permissions=permission_states, + ) + # [/DEF:_build_security_summary:Function] + + # [DEF:_collect_user_permission_pairs:Function] + # @PURPOSE: Collect effective permission tuples from current user's roles. + # @PRE: current_user can include role/permission graph. + # @POST: Returns unique normalized (resource, ACTION) tuples. + def _collect_user_permission_pairs(self, current_user: User) -> Set[Tuple[str, str]]: + collected: Set[Tuple[str, str]] = set() + roles = getattr(current_user, "roles", []) or [] + for role in roles: + permissions = getattr(role, "permissions", []) or [] + for permission in permissions: + resource = self._sanitize_text(getattr(permission, "resource", None)) + action = str(getattr(permission, "action", "") or "").strip().upper() + if resource and action: + collected.add((resource, action)) + return collected + # [/DEF:_collect_user_permission_pairs:Function] + + # [DEF:_format_permission_key:Function] + # @PURPOSE: Convert normalized permission pair to compact UI key. + # @PRE: resource and action are normalized. + # @POST: Returns user-facing badge key. + def _format_permission_key(self, resource: str, action: str) -> str: + normalized_resource = self._sanitize_text(resource) or "" + normalized_action = str(action or "").strip().upper() + if normalized_action == "READ": + return normalized_resource + return f"{normalized_resource}:{normalized_action.lower()}" + # [/DEF:_format_permission_key:Function] + + # [DEF:_to_preference_payload:Function] + # @PURPOSE: Map ORM preference row to API DTO with token metadata. + # @PRE: preference row can contain nullable optional fields. + # @POST: Returns normalized ProfilePreference object. + def _to_preference_payload( + self, + preference: UserDashboardPreference, + user_id: str, + ) -> ProfilePreference: + encrypted_token = self._sanitize_text( + preference.git_personal_access_token_encrypted + ) + token_masked = None + if encrypted_token: + try: + decrypted_token = self.encryption.decrypt(encrypted_token) + token_masked = self._mask_secret_value(decrypted_token) + except Exception: + token_masked = "***" + + created_at = getattr(preference, "created_at", None) or datetime.utcnow() + updated_at = getattr(preference, "updated_at", None) or created_at + + return ProfilePreference( + user_id=str(user_id), + superset_username=self._sanitize_username(preference.superset_username), + superset_username_normalized=self._normalize_username( + preference.superset_username_normalized + ), + show_only_my_dashboards=bool(preference.show_only_my_dashboards), + git_username=self._sanitize_text(preference.git_username), + git_email=self._sanitize_text(preference.git_email), + has_git_personal_access_token=bool(encrypted_token), + git_personal_access_token_masked=token_masked, + start_page=self._normalize_start_page(preference.start_page), + auto_open_task_drawer=( + bool(preference.auto_open_task_drawer) + if preference.auto_open_task_drawer is not None + else True + ), + dashboards_table_density=self._normalize_density( + preference.dashboards_table_density + ), + created_at=created_at, + updated_at=updated_at, + ) + # [/DEF:_to_preference_payload:Function] + + # [DEF:_mask_secret_value:Function] + # @PURPOSE: Build a safe display value for sensitive secrets. + # @PRE: secret may be None or plaintext. + # @POST: Returns masked representation or None. + def _mask_secret_value(self, secret: Optional[str]) -> Optional[str]: + sanitized_secret = self._sanitize_secret(secret) + if sanitized_secret is None: + return None + if len(sanitized_secret) <= 4: + return "***" + return f"{sanitized_secret[:2]}***{sanitized_secret[-2:]}" + # [/DEF:_mask_secret_value:Function] + + # [DEF:_sanitize_text:Function] + # @PURPOSE: Normalize optional text into trimmed form or None. + # @PRE: value may be empty or None. + # @POST: Returns trimmed value or None. + def _sanitize_text(self, value: Optional[str]) -> Optional[str]: + normalized = str(value or "").strip() + if not normalized: + return None + return normalized + # [/DEF:_sanitize_text:Function] + + # [DEF:_sanitize_secret:Function] + # @PURPOSE: Normalize secret input into trimmed form or None. + # @PRE: value may be None or blank. + # @POST: Returns trimmed secret or None. + def _sanitize_secret(self, value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = str(value).strip() + if not normalized: + return None + return normalized + # [/DEF:_sanitize_secret:Function] + + # [DEF:_normalize_start_page:Function] + # @PURPOSE: Normalize supported start page aliases to canonical values. + # @PRE: value may be None or alias. + # @POST: Returns one of SUPPORTED_START_PAGES. + def _normalize_start_page(self, value: Optional[str]) -> str: + normalized = str(value or "").strip().lower() + if normalized == "reports-logs": + return "reports" + if normalized in SUPPORTED_START_PAGES: + return normalized + return "dashboards" + # [/DEF:_normalize_start_page:Function] + + # [DEF:_normalize_density:Function] + # @PURPOSE: Normalize supported density aliases to canonical values. + # @PRE: value may be None or alias. + # @POST: Returns one of SUPPORTED_DENSITIES. + def _normalize_density(self, value: Optional[str]) -> str: + normalized = str(value or "").strip().lower() + if normalized == "free": + return "comfortable" + if normalized in SUPPORTED_DENSITIES: + return normalized + return "comfortable" + # [/DEF:_normalize_density:Function] + # [DEF:_resolve_environment:Function] # @PURPOSE: Resolve environment model from configured environments by id. # @PRE: environment_id is provided. @@ -287,6 +563,13 @@ class ProfileService: superset_username=None, superset_username_normalized=None, show_only_my_dashboards=False, + git_username=None, + git_email=None, + has_git_personal_access_token=False, + git_personal_access_token_masked=None, + start_page="dashboards", + auto_open_task_drawer=True, + dashboards_table_density="comfortable", created_at=now, updated_at=now, ) @@ -298,17 +581,38 @@ class ProfileService: # @POST: Returns validation errors list; empty list means valid. def _validate_update_payload( self, - payload: ProfilePreferenceUpdateRequest, + superset_username: Optional[str], + show_only_my_dashboards: bool, + git_email: Optional[str], + start_page: str, + dashboards_table_density: str, ) -> List[str]: errors: List[str] = [] - sanitized_username = self._sanitize_username(payload.superset_username) + sanitized_username = self._sanitize_username(superset_username) if sanitized_username and any(ch.isspace() for ch in sanitized_username): errors.append( "Username should not contain spaces. Please enter a valid Apache Superset username." ) - if payload.show_only_my_dashboards and not sanitized_username: + if show_only_my_dashboards and not sanitized_username: errors.append("Superset username is required when default filter is enabled.") + + sanitized_git_email = self._sanitize_text(git_email) + if sanitized_git_email: + if ( + " " in sanitized_git_email + or "@" not in sanitized_git_email + or sanitized_git_email.startswith("@") + or sanitized_git_email.endswith("@") + ): + errors.append("Git email should be a valid email address.") + + if start_page not in SUPPORTED_START_PAGES: + errors.append("Start page value is not supported.") + + if dashboards_table_density not in SUPPORTED_DENSITIES: + errors.append("Dashboards table density value is not supported.") + return errors # [/DEF:_validate_update_payload:Function] @@ -317,10 +621,7 @@ class ProfileService: # @PRE: value can be empty or None. # @POST: Returns trimmed username or None. def _sanitize_username(self, value: Optional[str]) -> Optional[str]: - normalized = str(value or "").strip() - if not normalized: - return None - return normalized + return self._sanitize_text(value) # [/DEF:_sanitize_username:Function] # [DEF:_normalize_username:Function] diff --git a/backend/test_pat_api.py b/backend/test_pat_api.py new file mode 100644 index 00000000..7a3a0645 --- /dev/null +++ b/backend/test_pat_api.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel, Field +from typing import Optional + +class GitProvider(str): + pass + +class GitServerConfigBase(BaseModel): + name: str = Field(..., description="Display name for the Git server") + provider: str = Field(..., description="Git provider (GITHUB, GITLAB, GITEA)") + url: str = Field(..., description="Server base URL") + pat: str = Field(..., description="Personal Access Token") + pat: str = Field(..., description="Personal Access Token") + default_repository: Optional[str] = Field(None, description="Default repository path (org/repo)") + default_branch: Optional[str] = Field("main", description="Default branch logic/name") + +class GitServerConfigSchema(GitServerConfigBase): + id: str + status: str + +print(GitServerConfigSchema.model_fields.keys()) diff --git a/backend/tests/core/test_defensive_guards.py b/backend/tests/core/test_defensive_guards.py index 3cd6d1bd..d831a05c 100644 --- a/backend/tests/core/test_defensive_guards.py +++ b/backend/tests/core/test_defensive_guards.py @@ -113,3 +113,20 @@ def test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults assert {"main", "dev", "preprod"}.issubset(local_branch_names) assert "dev:dev" in repo.origin.pushed assert "preprod:preprod" in repo.origin.pushed + + +def test_git_service_configure_identity_updates_repo_local_config(): + """Verify configure_identity writes repository-local user.name/user.email.""" + service = GitService(base_path="test_repos_identity") + + config_writer_context = MagicMock() + config_writer = config_writer_context.__enter__.return_value + fake_repo = MagicMock() + fake_repo.config_writer.return_value = config_writer_context + + with patch.object(service, "get_repo", return_value=fake_repo): + service.configure_identity(42, "user_1", "user1@mail.ru") + + fake_repo.config_writer.assert_called_once_with(config_level="repository") + config_writer.set_value.assert_any_call("user", "name", "user_1") + config_writer.set_value.assert_any_call("user", "email", "user1@mail.ru") diff --git a/frontend/src/lib/components/layout/TopNavbar.svelte b/frontend/src/lib/components/layout/TopNavbar.svelte index dd6de2cd..56fc4f67 100644 --- a/frontend/src/lib/components/layout/TopNavbar.svelte +++ b/frontend/src/lib/components/layout/TopNavbar.svelte @@ -40,6 +40,7 @@ taskDrawerStore, openDrawerForTask, openDrawer, + setTaskDrawerAutoOpenPreference, } from "$lib/stores/taskDrawer.js"; import { sidebarStore, toggleMobileSidebar } from "$lib/stores/sidebar.js"; import { t } from "$lib/i18n"; @@ -112,6 +113,19 @@ toggleAssistantChat(); } + async function hydrateTaskDrawerPreference() { + try { + const response = await api.getProfilePreferences(); + const autoOpenTaskDrawer = response?.preference?.auto_open_task_drawer; + setTaskDrawerAutoOpenPreference(autoOpenTaskDrawer !== false); + } catch (error) { + console.warn( + "[TopNavbar][REFLECT] Failed to hydrate task drawer preference, fallback to local preference cache", + error, + ); + } + } + function handleSearchFocus() { isSearchFocused = true; showSearchDropdown = groupedSearchResults.length > 0; @@ -320,6 +334,7 @@ onMount(() => { void initializeEnvironmentContext(); + void hydrateTaskDrawerPreference(); if (typeof document !== "undefined") { document.addEventListener("click", handleDocumentClick); } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 8003774c..e8083f75 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -401,8 +401,15 @@ }, "profile": { "title": "Profile", - "description": "Manage your dashboard filter preferences.", + "description": "Manage your profile preferences, Git integration, and access view.", "dashboard_preferences": "Dashboard Preferences", + "security_access": "Security & Access", + "read_only": "Read-only", + "security_read_only_note": "This section is read-only. Role changes are managed in Admin → Users.", + "current_role": "Current Role", + "role_source": "Role Source", + "permissions": "Permissions", + "permission_none": "No permissions available", "superset_environment": "Superset Environment", "superset_environment_placeholder": "Select environment", "superset_account": "Your Apache Superset Account", @@ -415,6 +422,27 @@ "save_error": "Failed to save preferences. Please try again.", "invalid_username": "Username should not contain spaces. Please enter a valid Apache Superset username.", "username_required": "Superset username is required when default filter is enabled.", + "invalid_git_email": "Git email should be a valid email address.", + "git_integration": "Git Integration (PAT)", + "git_username": "Git Username", + "git_username_placeholder": "Enter git username", + "git_email": "Git Email", + "git_email_placeholder": "Enter git email", + "git_token": "GitLab / GitHub Token", + "git_token_clear": "Clear token", + "git_token_placeholder": "Enter new personal access token", + "git_token_hint": "Token is never returned in plain text. Leave empty to keep current token.", + "git_token_masked_label": "Current token", + "git_token_not_set": "Token is not set", + "user_preferences": "User Preferences", + "start_page": "Start Page", + "start_page_dashboards": "Dashboards", + "start_page_datasets": "Datasets", + "start_page_reports": "Reports / Logs", + "table_density": "Table Density", + "table_density_compact": "Compact", + "table_density_comfortable": "Comfortable", + "auto_open_task_drawer": "Automatically open task drawer for long-running tasks", "filter_badge_active": "My Dashboards Only", "filter_badge_override": "Showing all dashboards temporarily", "filter_empty_state": "No dashboards found for your account. Try adjusting your filter settings.", diff --git a/frontend/src/lib/i18n/locales/ru.json b/frontend/src/lib/i18n/locales/ru.json index a1743185..19c1cb91 100644 --- a/frontend/src/lib/i18n/locales/ru.json +++ b/frontend/src/lib/i18n/locales/ru.json @@ -399,8 +399,15 @@ }, "profile": { "title": "Профиль", - "description": "Управляйте настройками фильтра дашбордов.", + "description": "Управляйте настройками профиля, Git-интеграцией и просмотром доступа.", "dashboard_preferences": "Настройки дашбордов", + "security_access": "Безопасность и доступ", + "read_only": "Только чтение", + "security_read_only_note": "Этот раздел только для чтения. Изменение ролей выполняется в Админ → Users.", + "current_role": "Текущая роль", + "role_source": "Источник роли", + "permissions": "Права доступа", + "permission_none": "Права доступа отсутствуют", "superset_environment": "Окружение Superset", "superset_environment_placeholder": "Выберите окружение", "superset_account": "Ваш аккаунт Apache Superset", @@ -413,6 +420,27 @@ "save_error": "Не удалось сохранить настройки. Попробуйте снова.", "invalid_username": "Имя пользователя не должно содержать пробелы. Введите корректное имя пользователя Apache Superset.", "username_required": "Имя пользователя Superset обязательно, когда фильтр по умолчанию включен.", + "invalid_git_email": "Git email должен быть корректным адресом электронной почты.", + "git_integration": "Интеграция с Git (PAT)", + "git_username": "Имя пользователя Git", + "git_username_placeholder": "Введите имя пользователя Git", + "git_email": "Git Email", + "git_email_placeholder": "Введите Git Email", + "git_token": "Токен GitLab / GitHub", + "git_token_clear": "Очистить токен", + "git_token_placeholder": "Введите новый персональный токен доступа", + "git_token_hint": "Токен никогда не возвращается в открытом виде. Оставьте поле пустым, чтобы сохранить текущий токен.", + "git_token_masked_label": "Текущий токен", + "git_token_not_set": "Токен не задан", + "user_preferences": "Пользовательские настройки", + "start_page": "Стартовая страница", + "start_page_dashboards": "Дашборды", + "start_page_datasets": "Датасеты", + "start_page_reports": "Отчеты / Логи", + "table_density": "Плотность таблиц", + "table_density_compact": "Компактная", + "table_density_comfortable": "Комфортная", + "auto_open_task_drawer": "Автоматически открывать Task Drawer для долгих задач", "filter_badge_active": "Только мои дашборды", "filter_badge_override": "Временно показаны все дашборды", "filter_empty_state": "Для вашего аккаунта дашборды не найдены. Попробуйте изменить настройки фильтра.", diff --git a/frontend/src/lib/stores/__tests__/taskDrawer.test.js b/frontend/src/lib/stores/__tests__/taskDrawer.test.js index 8f3a9a39..5cdf3465 100644 --- a/frontend/src/lib/stores/__tests__/taskDrawer.test.js +++ b/frontend/src/lib/stores/__tests__/taskDrawer.test.js @@ -1,9 +1,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { get } from 'svelte/store'; -import { taskDrawerStore, openDrawerForTask, closeDrawer, updateResourceTask } from '../taskDrawer.js'; +import { + taskDrawerStore, + openDrawerForTask, + openDrawerForTaskIfPreferred, + closeDrawer, + updateResourceTask, + setTaskDrawerAutoOpenPreference +} from '../taskDrawer.js'; describe('taskDrawerStore', () => { beforeEach(() => { + setTaskDrawerAutoOpenPreference(true); taskDrawerStore.set({ isOpen: false, activeTaskId: null, @@ -26,6 +34,26 @@ describe('taskDrawerStore', () => { expect(state.activeTaskId).toBe(null); }); + it('should open drawer via preference-aware helper when auto-open is enabled', () => { + setTaskDrawerAutoOpenPreference(true); + const opened = openDrawerForTaskIfPreferred('task-123'); + const state = get(taskDrawerStore); + + expect(opened).toBe(true); + expect(state.isOpen).toBe(true); + expect(state.activeTaskId).toBe('task-123'); + }); + + it('should skip opening drawer via preference-aware helper when auto-open is disabled', () => { + setTaskDrawerAutoOpenPreference(false); + const opened = openDrawerForTaskIfPreferred('task-123'); + const state = get(taskDrawerStore); + + expect(opened).toBe(false); + expect(state.isOpen).toBe(false); + expect(state.activeTaskId).toBe(null); + }); + it('should update resource task mapping for running task', () => { updateResourceTask('dash-1', 'task-1', 'RUNNING'); const state = get(taskDrawerStore); diff --git a/frontend/src/lib/stores/taskDrawer.js b/frontend/src/lib/stores/taskDrawer.js index d50890ef..a6c727b7 100644 --- a/frontend/src/lib/stores/taskDrawer.js +++ b/frontend/src/lib/stores/taskDrawer.js @@ -21,7 +21,21 @@ // @TEST_FIXTURE: valid_store_state -> {"isOpen": true, "activeTaskId": "test_1", "resourceTaskMap": {"res1": {"taskId": "test_1", "status": "RUNNING"}}} // @TEST_INVARIANT: state_management -> verifies: [valid_store_state] -import { writable, derived } from 'svelte/store'; +import { writable } from 'svelte/store'; + +const TASK_DRAWER_AUTO_OPEN_STORAGE_KEY = "ss_tools.profile.auto_open_task_drawer"; + +function readAutoOpenTaskDrawerPreference() { + if (typeof window === "undefined") { + return true; + } + const rawValue = window.localStorage.getItem(TASK_DRAWER_AUTO_OPEN_STORAGE_KEY); + if (rawValue === "false") return false; + if (rawValue === "true") return true; + return true; +} + +let autoOpenTaskDrawerPreference = readAutoOpenTaskDrawerPreference(); const initialState = { isOpen: false, @@ -37,12 +51,54 @@ export const taskDrawerStore = writable(initialState); * @UX_STATE: Open -> Drawer visible, logs streaming */ export function openDrawerForTask(taskId) { + if (!taskId) { + console.log("[taskDrawer.openDrawerForTask][Action] Skip open: taskId is empty"); + return false; + } console.log(`[taskDrawer.openDrawerForTask][Action] Opening drawer for task ${taskId}`); taskDrawerStore.update(state => ({ ...state, isOpen: true, activeTaskId: taskId })); + return true; +} + +/** + * Update user preference for automatic drawer opening. + * @param {boolean} enabled - Whether automatic opening is enabled. + */ +export function setTaskDrawerAutoOpenPreference(enabled) { + autoOpenTaskDrawerPreference = enabled !== false; + if (typeof window !== "undefined") { + window.localStorage.setItem( + TASK_DRAWER_AUTO_OPEN_STORAGE_KEY, + autoOpenTaskDrawerPreference ? "true" : "false", + ); + } +} + +/** + * Read current user preference for automatic drawer opening. + * @returns {boolean} true if automatic drawer opening is enabled. + */ +export function getTaskDrawerAutoOpenPreference() { + return autoOpenTaskDrawerPreference; +} + +/** + * Open drawer for a task only when user preference allows auto-open. + * @param {string} taskId - The task ID to show in drawer. + * @returns {boolean} true if drawer was opened. + */ +export function openDrawerForTaskIfPreferred(taskId) { + if (autoOpenTaskDrawerPreference !== true) { + console.log( + `[taskDrawer.openDrawerForTaskIfPreferred][Action] Skip auto-open for task ${taskId}`, + ); + return false; + } + return openDrawerForTask(taskId); } /** diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index a16dc42c..3d890017 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -2,19 +2,20 @@ diff --git a/frontend/src/routes/dashboards/+page.svelte b/frontend/src/routes/dashboards/+page.svelte index 92197298..ff1b5deb 100644 --- a/frontend/src/routes/dashboards/+page.svelte +++ b/frontend/src/routes/dashboards/+page.svelte @@ -35,7 +35,10 @@ import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { t } from "$lib/i18n"; - import { openDrawerForTask } from "$lib/stores/taskDrawer.js"; + import { + openDrawerForTask, + openDrawerForTaskIfPreferred, + } from "$lib/stores/taskDrawer.js"; import { api } from "$lib/api.js"; import { debounce } from "$lib/utils/debounce.js"; import { addToast } from "$lib/toasts.js"; @@ -837,7 +840,7 @@ // Open task drawer if task was created if (response.task_id || response.id) { const taskId = response.task_id || response.id; - openDrawerForTask(taskId); + openDrawerForTaskIfPreferred(taskId); } } catch (err) { console.error("[DashboardHub][COHERENCE:FAILED] Validation failed:", err); @@ -1006,7 +1009,7 @@ typeof response.task_id === "object" ? response.task_id.id || response.task_id.task_id : response.task_id; - openDrawerForTask(taskId); + openDrawerForTaskIfPreferred(taskId); } } catch (err) { console.error( @@ -1046,7 +1049,7 @@ typeof response.task_id === "object" ? response.task_id.id || response.task_id.task_id : response.task_id; - openDrawerForTask(taskId); + openDrawerForTaskIfPreferred(taskId); } } catch (err) { console.error("[DashboardHub][COHERENCE:FAILED] Bulk backup error:", err); diff --git a/frontend/src/routes/dashboards/[id]/+page.svelte b/frontend/src/routes/dashboards/[id]/+page.svelte index e6cfa7fa..1e274788 100644 --- a/frontend/src/routes/dashboards/[id]/+page.svelte +++ b/frontend/src/routes/dashboards/[id]/+page.svelte @@ -28,7 +28,9 @@ import { t } from "$lib/i18n"; import { api } from "$lib/api.js"; import { gitService } from "../../../services/gitService"; - import { openDrawerForTask } from "$lib/stores/taskDrawer.js"; + import { + openDrawerForTaskIfPreferred, + } from "$lib/stores/taskDrawer.js"; import { addToast } from "$lib/toasts.js"; import Icon from "$lib/ui/Icon.svelte"; import BranchSelector from "../../../components/git/BranchSelector.svelte"; @@ -169,7 +171,7 @@ }); const taskId = response?.task_id; if (taskId) { - openDrawerForTask(taskId); + openDrawerForTaskIfPreferred(taskId); addToast( $t.dashboard?.backup_started || "Backup task started", "success", @@ -208,7 +210,7 @@ }); const taskId = response?.task_id || response?.id; if (taskId) { - openDrawerForTask(taskId); + openDrawerForTaskIfPreferred(taskId); addToast( $t.dashboard?.validation_started || "LLM validation started", "success", diff --git a/frontend/src/routes/datasets/+page.svelte b/frontend/src/routes/datasets/+page.svelte index 22f5d423..ade754cb 100644 --- a/frontend/src/routes/datasets/+page.svelte +++ b/frontend/src/routes/datasets/+page.svelte @@ -37,7 +37,10 @@ import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { t } from "$lib/i18n"; - import { openDrawerForTask } from "$lib/stores/taskDrawer.js"; + import { + openDrawerForTask, + openDrawerForTaskIfPreferred, + } from "$lib/stores/taskDrawer.js"; import { api } from "$lib/api.js"; import { debounce } from "$lib/utils/debounce.js"; import { @@ -302,7 +305,7 @@ updateSelectionState(); if (response.task_id) { - openDrawerForTask(response.task_id); + openDrawerForTaskIfPreferred(response.task_id); } } catch (err) { console.error("[DatasetHub][Coherence:Failed]", err); @@ -336,7 +339,7 @@ updateSelectionState(); if (response.task_id) { - openDrawerForTask(response.task_id); + openDrawerForTaskIfPreferred(response.task_id); } } catch (err) { console.error("[DatasetHub][Coherence:Failed]", err); diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index 19c6a129..3ab4a97f 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -2,11 +2,12 @@ -
+

{$t.profile?.title || "Profile"}

{$t.profile?.description || - "Manage your dashboard filter preferences."} + "Manage your profile preferences, Git integration, and access view."}

-
-

- {$t.profile?.dashboard_preferences || "Dashboard Preferences"} -

- - {#if isPageLoading} -
-
-
-
+ {#if isPageLoading} +
+
+
+
+
+ {:else} +
+
+

+ {$t.profile?.security_access || "Security & Access"} +

+ + {$t.profile?.read_only || "Read-only"} +
- {:else} -
+ +

+ {$t.profile?.security_read_only_note || + "This section is read-only. Role changes are managed in Admin → Users."} +

+ +
+
+

+ {$t.profile?.current_role || "Current Role"} +

+

+ {securitySummary.current_role || + $t.common?.not_available || + "N/A"} +

+
+ +
+

+ {$t.profile?.role_source || "Role Source"} +

+

+ {securitySummary.role_source || + securitySummary.auth_source || + $t.common?.not_available || + "N/A"} +

+
+
+ +
+

+ {$t.profile?.permissions || "Permissions"} +

+ {#if securitySummary.permissions.length > 0} +
+ {#each securitySummary.permissions as permission (permission.key)} + + {permission.allowed ? "✔" : "✖"} + {permission.key} + + {/each} +
+ {:else} +

+ {$t.profile?.permission_none || "No permissions available"} +

+ {/if} +
+
+ +
+

+ {$t.profile?.git_integration || "Git Integration (PAT)"} +

+ +
+ + + +
+ +
+
+ + {$t.profile?.git_token || "GitLab / GitHub Token"} + + +
+ + + +

+ {$t.profile?.git_token_hint || + "Token is never returned in plain text. Leave empty to keep current token."} +

+ + {#if hasGitPersonalAccessToken} +

+ {$t.profile?.git_token_masked_label || "Current token"}: + + {gitPersonalAccessTokenMasked || "***"} + +

+ {:else} +

+ {$t.profile?.git_token_not_set || "Token is not set"} +

+ {/if} +
+
+ +
+

+ {$t.profile?.user_preferences || "User Preferences"} +

+ +
+ + + +
+ + + +
- {#if visibleValidationErrors.length > 0} -
- {#each visibleValidationErrors as errorText} -

{errorText}

- {/each} -
- {/if} -
-
- - -
- {/if} -
+ {#if visibleValidationErrors.length > 0} +
+ {#each visibleValidationErrors as errorText} +

{errorText}

+ {/each} +
+ {/if} +
+ +
+ + +
+ {/if}
\ No newline at end of file diff --git a/frontend/src/routes/profile/__tests__/profile-preferences.integration.test.js b/frontend/src/routes/profile/__tests__/profile-preferences.integration.test.js index 92202b1f..a1d758e7 100644 --- a/frontend/src/routes/profile/__tests__/profile-preferences.integration.test.js +++ b/frontend/src/routes/profile/__tests__/profile-preferences.integration.test.js @@ -53,7 +53,7 @@ vi.mock("$lib/i18n", () => ({ common: { cancel: "Cancel" }, profile: { title: "Profile", - description: "Manage your dashboard filter preferences.", + description: "Manage your profile preferences, Git integration, and access view.", dashboard_preferences: "Dashboard Preferences", superset_environment: "Superset Environment", superset_environment_placeholder: "Select environment", @@ -88,6 +88,21 @@ describe("profile-preferences.integration", () => { user_id: "u-1", superset_username: null, show_only_my_dashboards: false, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }); @@ -97,6 +112,21 @@ describe("profile-preferences.integration", () => { user_id: "u-1", superset_username: "john_doe", show_only_my_dashboards: true, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }); }); @@ -145,6 +175,11 @@ describe("profile-preferences.integration", () => { expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith({ superset_username: "john_doe", show_only_my_dashboards: true, + git_username: null, + git_email: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", }); expect(mockedAddToast).toHaveBeenCalledWith("Preferences saved", "success"); }); @@ -160,6 +195,21 @@ describe("profile-preferences.integration", () => { user_id: "u-1", superset_username: "manual_user", show_only_my_dashboards: true, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }); @@ -189,6 +239,11 @@ describe("profile-preferences.integration", () => { expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith({ superset_username: "manual_user", show_only_my_dashboards: true, + git_username: null, + git_email: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", }); }); }); diff --git a/frontend/src/routes/profile/__tests__/profile-settings-state.integration.test.js b/frontend/src/routes/profile/__tests__/profile-settings-state.integration.test.js index 33ebcd6c..2d6118c5 100644 --- a/frontend/src/routes/profile/__tests__/profile-settings-state.integration.test.js +++ b/frontend/src/routes/profile/__tests__/profile-settings-state.integration.test.js @@ -51,7 +51,7 @@ vi.mock("$lib/i18n", () => ({ common: { cancel: "Cancel" }, profile: { title: "Profile", - description: "Manage your dashboard filter preferences.", + description: "Manage your profile preferences, Git integration, and access view.", dashboard_preferences: "Dashboard Preferences", superset_environment: "Superset Environment", superset_environment_placeholder: "Select environment", @@ -96,6 +96,21 @@ describe("profile-settings-state.integration", () => { user_id: "u-1", superset_username: "john_doe", show_only_my_dashboards: true, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }); @@ -139,6 +154,21 @@ describe("profile-settings-state.integration", () => { user_id: "u-1", superset_username: "john_doe", show_only_my_dashboards: true, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }) .mockResolvedValueOnce({ @@ -147,6 +177,21 @@ describe("profile-settings-state.integration", () => { user_id: "u-1", superset_username: "new_user", show_only_my_dashboards: true, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }); @@ -156,6 +201,21 @@ describe("profile-settings-state.integration", () => { user_id: "u-1", superset_username: "new_user", show_only_my_dashboards: true, + git_username: null, + git_email: null, + has_git_personal_access_token: false, + git_personal_access_token_masked: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", + }, + security: { + read_only: true, + auth_source: "adfs", + current_role: "Data Engineer", + role_source: "adfs", + roles: ["Data Engineer"], + permissions: [], }, }); @@ -178,6 +238,11 @@ describe("profile-settings-state.integration", () => { expect(mockedApi.updateProfilePreferences).toHaveBeenCalledWith({ superset_username: "new_user", show_only_my_dashboards: true, + git_username: null, + git_email: null, + start_page: "dashboards", + auto_open_task_drawer: true, + dashboards_table_density: "comfortable", }); }); diff --git a/frontend/src/routes/settings/git/+page.svelte b/frontend/src/routes/settings/git/+page.svelte index 51c520f4..c6693414 100644 --- a/frontend/src/routes/settings/git/+page.svelte +++ b/frontend/src/routes/settings/git/+page.svelte @@ -11,34 +11,37 @@