9 Commits

108 changed files with 75756 additions and 81683 deletions

7
.gitignore vendored
View File

@@ -66,7 +66,6 @@ backend/mappings.db
backend/tasks.db
# Git Integration repositories
backend/git_repos/
backend/backend/git_repos
backend/logs
backend/auth.db
semantics/reports

View File

@@ -29,6 +29,9 @@ Auto-generated from all feature plans. Last updated: 2025-12-19
- LocalStorage (for language preference) (013-unify-frontend-css)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend) (014-file-storage-ui)
- Local Filesystem (for artifacts), Config (for storage path) (014-file-storage-ui)
- Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend) (015-frontend-nav-redesign)
- N/A (UI reorganization and API integration) (015-frontend-nav-redesign)
- SQLite (`auth.db`) for Users, Roles, Permissions, and Mappings. (016-multi-user-auth)
- Python 3.9+ (Backend), Node.js 18+ (Frontend Build) (001-plugin-arch-svelte-ui)
@@ -49,9 +52,9 @@ cd src; pytest; ruff check .
Python 3.9+ (Backend), Node.js 18+ (Frontend Build): Follow standard conventions
## Recent Changes
- 016-multi-user-auth: Added Python 3.9+ (Backend), Node.js 18+ (Frontend)
- 015-frontend-nav-redesign: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend)
- 014-file-storage-ui: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI (Backend), SvelteKit (Frontend)
- 013-unify-frontend-css: Added Node.js 18+ (Frontend Build), Svelte 5.x + SvelteKit, Tailwind CSS, `date-fns` (existing)
- 011-git-integration-dashboard: Added Python 3.9+ (Backend), Node.js 18+ (Frontend) + FastAPI, SvelteKit, GitPython (or CLI git), Pydantic, SQLAlchemy, Superset API
<!-- MANUAL ADDITIONS START -->

View File

@@ -0,0 +1,4 @@
---
description: USE SEMANTIC
---
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке

View File

@@ -1,11 +1,10 @@
<!--
SYNC IMPACT REPORT
Version: 1.7.1 (Simplified Workflow)
Version: 1.9.0 (Security & RBAC Mandate)
Changes:
- Simplified Generation Workflow to a single phase: Code Generation from `tasks.md`.
- Removed multi-phase Architecture/Implementation split to streamline development.
- Added Principle IX: Security & Access Control (Mandating granular permissions for plugins).
Templates Status:
- .specify/templates/plan-template.md: ✅ Aligned (Dynamic check).
- .specify/templates/plan-template.md: ✅ Aligned.
- .specify/templates/spec-template.md: ✅ Aligned.
- .specify/templates/tasks-template.md: ✅ Aligned.
-->
@@ -37,6 +36,16 @@ To maintain semantic coherence, code must adhere to the complexity limits (Modul
### VII. Everything is a Plugin
All functional extensions, tools, or major features must be implemented as modular Plugins inheriting from `PluginBase`. Logic should not reside in standalone services or scripts unless strictly necessary for core infrastructure. This ensures a unified execution model via the `TaskManager`, consistent logging, and modularity.
### VIII. Unified Frontend Experience
To ensure a consistent and accessible user experience, all frontend implementations must strictly adhere to the unified design and localization standards.
- **Component Reusability**: All UI elements MUST utilize the standardized Svelte component library (`src/lib/ui`) and centralized design tokens. Ad-hoc styling and hardcoded values are prohibited.
- **Internationalization (i18n)**: All user-facing text MUST be extracted to the translation system (`src/lib/i18n`). Hardcoded strings in the UI are prohibited.
### IX. Security & Access Control
To support the Role-Based Access Control (RBAC) system, all functional components must define explicit permissions.
- **Granular Permissions**: Every Plugin MUST define a unique permission string (e.g., `plugin:name:execute`) required for its operation.
- **Registration**: These permissions MUST be registered in the system database during initialization or plugin loading to ensure they are available for role assignment in the Admin UI.
## File Structure Standards
Refer to **Section III (File Structure Standard)** in `semantic_protocol.md` for the authoritative definitions of:
- Python Module Headers (`.py`)
@@ -64,4 +73,4 @@ This Constitution establishes the "Semantic Code Generation Protocol" as the sup
- **Amendments**: Changes to core principles require a Constitution amendment. Changes to technical syntax require a Protocol update.
- **Compliance**: Failure to adhere to the Protocol constitutes a build failure.
**Version**: 1.7.1 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-13
**Version**: 1.9.0 | **Ratified**: 2025-12-19 | **Last Amended**: 2026-01-27

BIN
backend/backend/auth.db Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -25,9 +25,13 @@ keyring==25.7.0
more-itertools==10.8.0
pycparser==2.23
pydantic==2.12.5
pydantic-settings
pydantic_core==2.41.5
python-multipart==0.0.21
PyYAML==6.0.3
passlib[bcrypt]
python-jose[cryptography]
PyJWT
RapidFuzz==3.14.3
referencing==0.37.0
requests==2.32.5
@@ -44,4 +48,6 @@ websockets==15.0.1
pandas
psycopg2-binary
openpyxl
GitPython==3.1.44
GitPython==3.1.44
itsdangerous
email-validator

View File

@@ -1,59 +1,118 @@
# [DEF:AuthModule:Module]
# @SEMANTICS: auth, authentication, adfs, oauth, middleware
# @PURPOSE: Implements ADFS authentication using Authlib for FastAPI. It provides a dependency to protect endpoints.
# @LAYER: UI (API)
# @RELATION: Used by API routers to protect endpoints that require authentication.
# [DEF:backend.src.api.auth:Module]
#
# @SEMANTICS: api, auth, routes, login, logout
# @PURPOSE: Authentication API endpoints.
# @LAYER: API
# @RELATION: USES -> backend.src.services.auth_service.AuthService
# @RELATION: USES -> backend.src.core.database.get_auth_db
#
# @INVARIANT: All auth endpoints must return consistent error codes.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2AuthorizationCodeBearer
from authlib.integrations.starlette_client import OAuth
from starlette.config import Config
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..core.database import get_auth_db
from ..services.auth_service import AuthService
from ..schemas.auth import Token, User as UserSchema
from ..dependencies import get_current_user
from ..core.auth.oauth import oauth, is_adfs_configured
from ..core.auth.logger import log_security_event
from ..core.logger import belief_scope
import starlette.requests
# [/SECTION]
# Placeholder for ADFS configuration. In a real app, this would come from a secure source.
# Create an in-memory .env file
from io import StringIO
config_data = StringIO("""
ADFS_CLIENT_ID=your-client-id
ADFS_CLIENT_SECRET=your-client-secret
ADFS_SERVER_METADATA_URL=https://your-adfs-server/.well-known/openid-configuration
""")
config = Config(config_data)
oauth = OAuth(config)
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for authentication routes.
router = APIRouter(prefix="/api/auth", tags=["auth"])
# [/DEF:router:Variable]
oauth.register(
name='adfs',
server_metadata_url=config('ADFS_SERVER_METADATA_URL'),
client_kwargs={'scope': 'openid profile email'}
)
# [DEF:login_for_access_token:Function]
# @PURPOSE: Authenticates a user and returns a JWT access token.
# @PRE: form_data contains username and password.
# @POST: Returns a Token object on success.
# @THROW: HTTPException 401 if authentication fails.
# @PARAM: form_data (OAuth2PasswordRequestForm) - Login credentials.
# @PARAM: db (Session) - Auth database session.
# @RETURN: Token - The generated JWT token.
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_auth_db)
):
with belief_scope("api.auth.login"):
auth_service = AuthService(db)
user = auth_service.authenticate_user(form_data.username, form_data.password)
if not user:
log_security_event("LOGIN_FAILED", form_data.username, {"reason": "Invalid credentials"})
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
log_security_event("LOGIN_SUCCESS", user.username, {"source": "LOCAL"})
return auth_service.create_session(user)
# [/DEF:login_for_access_token:Function]
oauth2_scheme = OAuth2AuthorizationCodeBearer(
authorizationUrl="https://your-adfs-server/adfs/oauth2/authorize",
tokenUrl="https://your-adfs-server/adfs/oauth2/token",
)
# [DEF:read_users_me:Function]
# @PURPOSE: Retrieves the profile of the currently authenticated user.
# @PRE: Valid JWT token provided.
# @POST: Returns the current user's data.
# @PARAM: current_user (UserSchema) - The user extracted from the token.
# @RETURN: UserSchema - The current user profile.
@router.get("/me", response_model=UserSchema)
async def read_users_me(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.me"):
return current_user
# [/DEF:read_users_me:Function]
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency to get the current user from the ADFS token.
# @PARAM: token (str) - The OAuth2 bearer token.
# @PRE: token should be provided via Authorization header.
# @POST: Returns user details if authenticated, else raises 401.
# @RETURN: Dict[str, str] - User information.
async def get_current_user(token: str = Depends(oauth2_scheme)):
"""
Dependency to get the current user from the ADFS token.
This is a placeholder and needs to be fully implemented.
"""
# In a real implementation, you would:
# 1. Validate the token with ADFS.
# 2. Fetch user information.
# 3. Create a user object.
# For now, we'll just check if a token exists.
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
# A real implementation would return a user object.
return {"placeholder_user": "user@example.com"}
# [/DEF:get_current_user:Function]
# [/DEF:AuthModule:Module]
# [DEF:logout:Function]
# @PURPOSE: Logs out the current user (placeholder for session revocation).
# @PRE: Valid JWT token provided.
# @POST: Returns success message.
@router.post("/logout")
async def logout(current_user: UserSchema = Depends(get_current_user)):
with belief_scope("api.auth.logout"):
log_security_event("LOGOUT", current_user.username)
# In a stateless JWT setup, client-side token deletion is primary.
# Server-side revocation (blacklisting) can be added here if needed.
return {"message": "Successfully logged out"}
# [/DEF:logout:Function]
# [DEF:login_adfs:Function]
# @PURPOSE: Initiates the ADFS OIDC login flow.
# @POST: Redirects the user to ADFS.
@router.get("/login/adfs")
async def login_adfs(request: starlette.requests.Request):
with belief_scope("api.auth.login_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
)
redirect_uri = request.url_for('auth_callback_adfs')
return await oauth.adfs.authorize_redirect(request, str(redirect_uri))
# [/DEF:login_adfs:Function]
# [DEF:auth_callback_adfs:Function]
# @PURPOSE: Handles the callback from ADFS after successful authentication.
# @POST: Provisions user JIT and returns session token.
@router.get("/callback/adfs", name="auth_callback_adfs")
async def auth_callback_adfs(request: starlette.requests.Request, db: Session = Depends(get_auth_db)):
with belief_scope("api.auth.callback_adfs"):
if not is_adfs_configured():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ADFS is not configured. Please set ADFS_CLIENT_ID, ADFS_CLIENT_SECRET, and ADFS_METADATA_URL environment variables."
)
token = await oauth.adfs.authorize_access_token(request)
user_info = token.get('userinfo')
if not user_info:
raise HTTPException(status_code=400, detail="Failed to retrieve user info from ADFS")
auth_service = AuthService(db)
user = auth_service.provision_adfs_user(user_info)
return auth_service.create_session(user)
# [/DEF:auth_callback_adfs:Function]
# [/DEF:backend.src.api.auth:Module]

View File

@@ -1 +1 @@
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage
from . import plugins, tasks, settings, connections, environments, mappings, migration, git, storage, admin

View File

@@ -0,0 +1,310 @@
# [DEF:backend.src.api.routes.admin:Module]
#
# @TIER: STANDARD
# @SEMANTICS: api, admin, users, roles, permissions
# @PURPOSE: Admin API endpoints for user and role management.
# @LAYER: API
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.dependencies.has_permission
#
# @INVARIANT: All endpoints in this module require 'Admin' role or 'admin' scope.
# [SECTION: IMPORTS]
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ...core.database import get_auth_db
from ...core.auth.repository import AuthRepository
from ...core.auth.security import get_password_hash
from ...schemas.auth import (
User as UserSchema, UserCreate, UserUpdate,
RoleSchema, RoleCreate, RoleUpdate, PermissionSchema,
ADGroupMappingSchema, ADGroupMappingCreate
)
from ...models.auth import User, Role, Permission, ADGroupMapping
from ...dependencies import has_permission, get_current_user
from ...core.logger import logger, belief_scope
# [/SECTION]
# [DEF:router:Variable]
# @PURPOSE: APIRouter instance for admin routes.
router = APIRouter(prefix="/api/admin", tags=["admin"])
# [/DEF:router:Variable]
# [DEF:list_users:Function]
# @PURPOSE: Lists all registered users.
# @PRE: Current user has 'Admin' role.
# @POST: Returns a list of UserSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[UserSchema] - List of users.
@router.get("/users", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "READ"))
):
with belief_scope("api.admin.list_users"):
users = db.query(User).all()
return users
# [/DEF:list_users:Function]
# [DEF:create_user:Function]
# @PURPOSE: Creates a new local user.
# @PRE: Current user has 'Admin' role.
# @POST: New user is created in the database.
# @PARAM: user_in (UserCreate) - New user data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: UserSchema - The created user.
@router.post("/users", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
async def create_user(
user_in: UserCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.create_user"):
repo = AuthRepository(db)
if repo.get_user_by_username(user_in.username):
raise HTTPException(status_code=400, detail="Username already exists")
new_user = User(
username=user_in.username,
email=user_in.email,
password_hash=get_password_hash(user_in.password),
auth_source="LOCAL",
is_active=user_in.is_active
)
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
new_user.roles.append(role)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
# [/DEF:create_user:Function]
# [DEF:update_user:Function]
# @PURPOSE: Updates an existing user.
@router.put("/users/{user_id}", response_model=UserSchema)
async def update_user(
user_id: str,
user_in: UserUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.update_user"):
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_in.email is not None:
user.email = user_in.email
if user_in.is_active is not None:
user.is_active = user_in.is_active
if user_in.password is not None:
user.password_hash = get_password_hash(user_in.password)
if user_in.roles is not None:
user.roles = []
for role_name in user_in.roles:
role = repo.get_role_by_name(role_name)
if role:
user.roles.append(role)
db.commit()
db.refresh(user)
return user
# [/DEF:update_user:Function]
# [DEF:delete_user:Function]
# @PURPOSE: Deletes a user.
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:users", "WRITE"))
):
with belief_scope("api.admin.delete_user"):
logger.info(f"[DEBUG] Attempting to delete user context={{'user_id': '{user_id}'}}")
repo = AuthRepository(db)
user = repo.get_user_by_id(user_id)
if not user:
logger.warning(f"[DEBUG] User not found for deletion context={{'user_id': '{user_id}'}}")
raise HTTPException(status_code=404, detail="User not found")
logger.info(f"[DEBUG] Found user to delete context={{'username': '{user.username}'}}")
db.delete(user)
db.commit()
logger.info(f"[DEBUG] Successfully deleted user context={{'user_id': '{user_id}'}}")
return None
# [/DEF:delete_user:Function]
# [DEF:list_roles:Function]
# @PURPOSE: Lists all available roles.
# @RETURN: List[RoleSchema] - List of roles.
# @RELATION: CALLS -> backend.src.models.auth.Role
@router.get("/roles", response_model=List[RoleSchema])
async def list_roles(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_roles"):
return db.query(Role).all()
# [/DEF:list_roles:Function]
# [DEF:create_role:Function]
# @PURPOSE: Creates a new system role with associated permissions.
# @PRE: Role name must be unique.
# @POST: New Role record is created in auth.db.
# @PARAM: role_in (RoleCreate) - New role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The created role.
# @SIDE_EFFECT: Commits new role and associations to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_permission_by_id
@router.post("/roles", response_model=RoleSchema, status_code=status.HTTP_201_CREATED)
async def create_role(
role_in: RoleCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.create_role"):
if db.query(Role).filter(Role.name == role_in.name).first():
raise HTTPException(status_code=400, detail="Role already exists")
new_role = Role(name=role_in.name, description=role_in.description)
repo = AuthRepository(db)
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
new_role.permissions.append(perm)
db.add(new_role)
db.commit()
db.refresh(new_role)
return new_role
# [/DEF:create_role:Function]
# [DEF:update_role:Function]
# @PURPOSE: Updates an existing role's metadata and permissions.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is updated in auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: role_in (RoleUpdate) - Updated role data.
# @PARAM: db (Session) - Auth database session.
# @RETURN: RoleSchema - The updated role.
# @SIDE_EFFECT: Commits updates to auth.db.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.put("/roles/{role_id}", response_model=RoleSchema)
async def update_role(
role_id: str,
role_in: RoleUpdate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.update_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
if role_in.name is not None:
role.name = role_in.name
if role_in.description is not None:
role.description = role_in.description
if role_in.permissions is not None:
role.permissions = []
for perm_id_or_str in role_in.permissions:
perm = repo.get_permission_by_id(perm_id_or_str)
if not perm and ":" in perm_id_or_str:
res, act = perm_id_or_str.split(":", 1)
perm = repo.get_permission_by_resource_action(res, act)
if perm:
role.permissions.append(perm)
db.commit()
db.refresh(role)
return role
# [/DEF:update_role:Function]
# [DEF:delete_role:Function]
# @PURPOSE: Removes a role from the system.
# @PRE: role_id must be a valid existing role UUID.
# @POST: Role record is removed from auth.db.
# @PARAM: role_id (str) - Target role identifier.
# @PARAM: db (Session) - Auth database session.
# @RETURN: None
# @SIDE_EFFECT: Deletes record from auth.db and commits.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.get_role_by_id
@router.delete("/roles/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: str,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "WRITE"))
):
with belief_scope("api.admin.delete_role"):
repo = AuthRepository(db)
role = repo.get_role_by_id(role_id)
if not role:
raise HTTPException(status_code=404, detail="Role not found")
db.delete(role)
db.commit()
return None
# [/DEF:delete_role:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available system permissions for assignment.
# @POST: Returns a list of all PermissionSchema objects.
# @PARAM: db (Session) - Auth database session.
# @RETURN: List[PermissionSchema] - List of permissions.
# @RELATION: CALLS -> backend.src.core.auth.repository.AuthRepository.list_permissions
@router.get("/permissions", response_model=List[PermissionSchema])
async def list_permissions(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:roles", "READ"))
):
with belief_scope("api.admin.list_permissions"):
repo = AuthRepository(db)
return repo.list_permissions()
# [/DEF:list_permissions:Function]
# [DEF:list_ad_mappings:Function]
# @PURPOSE: Lists all AD Group to Role mappings.
@router.get("/ad-mappings", response_model=List[ADGroupMappingSchema])
async def list_ad_mappings(
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("api.admin.list_ad_mappings"):
return db.query(ADGroupMapping).all()
# [/DEF:list_ad_mappings:Function]
# [DEF:create_ad_mapping:Function]
# @PURPOSE: Creates a new AD Group mapping.
@router.post("/ad-mappings", response_model=ADGroupMappingSchema)
async def create_ad_mapping(
mapping_in: ADGroupMappingCreate,
db: Session = Depends(get_auth_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("api.admin.create_ad_mapping"):
new_mapping = ADGroupMapping(
ad_group=mapping_in.ad_group,
role_id=mapping_in.role_id
)
db.add(new_mapping)
db.commit()
db.refresh(new_mapping)
return new_mapping
# [/DEF:create_ad_mapping:Function]
# [/DEF:backend.src.api.routes.admin:Module]

View File

@@ -11,7 +11,7 @@
# [SECTION: IMPORTS]
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict, Optional
from ...dependencies import get_config_manager, get_scheduler_service
from ...dependencies import get_config_manager, get_scheduler_service, has_permission
from ...core.superset_client import SupersetClient
from pydantic import BaseModel, Field
from ...core.config_models import Environment as EnvModel
@@ -23,7 +23,7 @@ router = APIRouter()
# [DEF:ScheduleSchema:DataClass]
class ScheduleSchema(BaseModel):
enabled: bool = False
cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){5,7})$')
cron_expression: str = Field(..., pattern=r'^(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|((((\d+,)*\d+|(\d+(\/|-)\d+)|\d+|\*) ?){4,6})$')
# [/DEF:ScheduleSchema:DataClass]
# [DEF:EnvironmentResponse:DataClass]
@@ -47,7 +47,10 @@ class DatabaseResponse(BaseModel):
# @POST: Returns a list of EnvironmentResponse objects.
# @RETURN: List[EnvironmentResponse]
@router.get("", response_model=List[EnvironmentResponse])
async def get_environments(config_manager=Depends(get_config_manager)):
async def get_environments(
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("environments", "READ"))
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
# Ensure envs is a list
@@ -77,7 +80,8 @@ async def update_environment_schedule(
id: str,
schedule: ScheduleSchema,
config_manager=Depends(get_config_manager),
scheduler_service=Depends(get_scheduler_service)
scheduler_service=Depends(get_scheduler_service),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_environment_schedule", f"id={id}"):
envs = config_manager.get_environments()
@@ -104,7 +108,11 @@ async def update_environment_schedule(
# @PARAM: id (str) - The environment ID.
# @RETURN: List[Dict] - List of databases.
@router.get("/{id}/databases")
async def get_environment_databases(id: str, config_manager=Depends(get_config_manager)):
async def get_environment_databases(
id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environment_databases", f"id={id}"):
envs = config_manager.get_environments()
env = next((e for e in envs if e.id == id), None)

View File

@@ -13,7 +13,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
import typing
from src.dependencies import get_config_manager
from src.dependencies import get_config_manager, has_permission
from src.core.database import get_db
from src.models.git import GitServerConfig, GitStatus, DeploymentEnvironment, GitRepository
from src.api.routes.git_schemas import (
@@ -34,7 +34,10 @@ git_service = GitService()
# @POST: Returns a list of all GitServerConfig objects from the database.
# @RETURN: List[GitServerConfigSchema]
@router.get("/config", response_model=List[GitServerConfigSchema])
async def get_git_configs(db: Session = Depends(get_db)):
async def get_git_configs(
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_git_configs"):
return db.query(GitServerConfig).all()
# [/DEF:get_git_configs:Function]
@@ -46,7 +49,11 @@ async def get_git_configs(db: Session = Depends(get_db)):
# @PARAM: config (GitServerConfigCreate)
# @RETURN: GitServerConfigSchema
@router.post("/config", response_model=GitServerConfigSchema)
async def create_git_config(config: GitServerConfigCreate, db: Session = Depends(get_db)):
async def create_git_config(
config: GitServerConfigCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("create_git_config"):
db_config = GitServerConfig(**config.dict())
db.add(db_config)
@@ -61,7 +68,11 @@ async def create_git_config(config: GitServerConfigCreate, db: Session = Depends
# @POST: The configuration record is removed from the database.
# @PARAM: config_id (str)
@router.delete("/config/{config_id}")
async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
async def delete_git_config(
config_id: str,
db: Session = Depends(get_db),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("delete_git_config"):
db_config = db.query(GitServerConfig).filter(GitServerConfig.id == config_id).first()
if not db_config:
@@ -78,7 +89,10 @@ async def delete_git_config(config_id: str, db: Session = Depends(get_db)):
# @POST: Returns success if the connection is validated via GitService.
# @PARAM: config (GitServerConfigCreate)
@router.post("/config/test")
async def test_git_config(config: GitServerConfigCreate):
async def test_git_config(
config: GitServerConfigCreate,
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("test_git_config"):
success = await git_service.test_connection(config.provider, config.url, config.pat)
if success:
@@ -94,7 +108,12 @@ async def test_git_config(config: GitServerConfigCreate):
# @PARAM: dashboard_id (int)
# @PARAM: init_data (RepoInitRequest)
@router.post("/repositories/{dashboard_id}/init")
async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Session = Depends(get_db)):
async def init_repository(
dashboard_id: int,
init_data: RepoInitRequest,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("init_repository"):
# 1. Get config
config = db.query(GitServerConfig).filter(GitServerConfig.id == init_data.config_id).first()
@@ -138,7 +157,10 @@ async def init_repository(dashboard_id: int, init_data: RepoInitRequest, db: Ses
# @PARAM: dashboard_id (int)
# @RETURN: List[BranchSchema]
@router.get("/repositories/{dashboard_id}/branches", response_model=List[BranchSchema])
async def get_branches(dashboard_id: int):
async def get_branches(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_branches"):
try:
return git_service.list_branches(dashboard_id)
@@ -153,7 +175,11 @@ async def get_branches(dashboard_id: int):
# @PARAM: dashboard_id (int)
# @PARAM: branch_data (BranchCreate)
@router.post("/repositories/{dashboard_id}/branches")
async def create_branch(dashboard_id: int, branch_data: BranchCreate):
async def create_branch(
dashboard_id: int,
branch_data: BranchCreate,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("create_branch"):
try:
git_service.create_branch(dashboard_id, branch_data.name, branch_data.from_branch)
@@ -169,7 +195,11 @@ async def create_branch(dashboard_id: int, branch_data: BranchCreate):
# @PARAM: dashboard_id (int)
# @PARAM: checkout_data (BranchCheckout)
@router.post("/repositories/{dashboard_id}/checkout")
async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
async def checkout_branch(
dashboard_id: int,
checkout_data: BranchCheckout,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("checkout_branch"):
try:
git_service.checkout_branch(dashboard_id, checkout_data.name)
@@ -185,7 +215,11 @@ async def checkout_branch(dashboard_id: int, checkout_data: BranchCheckout):
# @PARAM: dashboard_id (int)
# @PARAM: commit_data (CommitCreate)
@router.post("/repositories/{dashboard_id}/commit")
async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
async def commit_changes(
dashboard_id: int,
commit_data: CommitCreate,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("commit_changes"):
try:
git_service.commit_changes(dashboard_id, commit_data.message, commit_data.files)
@@ -200,7 +234,10 @@ async def commit_changes(dashboard_id: int, commit_data: CommitCreate):
# @POST: Local commits are pushed to the remote repository.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/push")
async def push_changes(dashboard_id: int):
async def push_changes(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("push_changes"):
try:
git_service.push_changes(dashboard_id)
@@ -215,7 +252,10 @@ async def push_changes(dashboard_id: int):
# @POST: Remote changes are fetched and merged into the local branch.
# @PARAM: dashboard_id (int)
@router.post("/repositories/{dashboard_id}/pull")
async def pull_changes(dashboard_id: int):
async def pull_changes(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("pull_changes"):
try:
git_service.pull_changes(dashboard_id)
@@ -231,7 +271,11 @@ async def pull_changes(dashboard_id: int):
# @PARAM: dashboard_id (int)
# @PARAM: source_env_id (Optional[str])
@router.post("/repositories/{dashboard_id}/sync")
async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str] = None):
async def sync_dashboard(
dashboard_id: int,
source_env_id: typing.Optional[str] = None,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("sync_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
@@ -251,7 +295,10 @@ async def sync_dashboard(dashboard_id: int, source_env_id: typing.Optional[str]
# @POST: Returns a list of DeploymentEnvironmentSchema objects.
# @RETURN: List[DeploymentEnvironmentSchema]
@router.get("/environments", response_model=List[DeploymentEnvironmentSchema])
async def get_environments(config_manager=Depends(get_config_manager)):
async def get_environments(
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("environments", "READ"))
):
with belief_scope("get_environments"):
envs = config_manager.get_environments()
return [
@@ -271,7 +318,11 @@ async def get_environments(config_manager=Depends(get_config_manager)):
# @PARAM: dashboard_id (int)
# @PARAM: deploy_data (DeployRequest)
@router.post("/repositories/{dashboard_id}/deploy")
async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
async def deploy_dashboard(
dashboard_id: int,
deploy_data: DeployRequest,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("deploy_dashboard"):
try:
from src.plugins.git_plugin import GitPlugin
@@ -293,7 +344,11 @@ async def deploy_dashboard(dashboard_id: int, deploy_data: DeployRequest):
# @PARAM: limit (int)
# @RETURN: List[CommitSchema]
@router.get("/repositories/{dashboard_id}/history", response_model=List[CommitSchema])
async def get_history(dashboard_id: int, limit: int = 50):
async def get_history(
dashboard_id: int,
limit: int = 50,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_history"):
try:
return git_service.get_commit_history(dashboard_id, limit)
@@ -308,7 +363,10 @@ async def get_history(dashboard_id: int, limit: int = 50):
# @PARAM: dashboard_id (int)
# @RETURN: dict
@router.get("/repositories/{dashboard_id}/status")
async def get_repository_status(dashboard_id: int):
async def get_repository_status(
dashboard_id: int,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_repository_status"):
try:
return git_service.get_status(dashboard_id)
@@ -325,7 +383,12 @@ async def get_repository_status(dashboard_id: int):
# @PARAM: staged (bool)
# @RETURN: str
@router.get("/repositories/{dashboard_id}/diff")
async def get_repository_diff(dashboard_id: int, file_path: Optional[str] = None, staged: bool = False):
async def get_repository_diff(
dashboard_id: int,
file_path: Optional[str] = None,
staged: bool = False,
_ = Depends(has_permission("plugin:git", "EXECUTE"))
):
with belief_scope("get_repository_diff"):
try:
diff_text = git_service.get_diff(dashboard_id, file_path, staged)

View File

@@ -1,5 +1,6 @@
# [DEF:backend.src.api.routes.git_schemas:Module]
#
# @TIER: STANDARD
# @SEMANTICS: git, schemas, pydantic, api, contracts
# @PURPOSE: Defines Pydantic models for the Git integration API layer.
# @LAYER: API
@@ -14,6 +15,7 @@ from uuid import UUID
from src.models.git import GitProvider, GitStatus, SyncStatus
# [DEF:GitServerConfigBase:Class]
# @TIER: TRIVIAL
# @PURPOSE: Base schema for Git server configuration attributes.
class GitServerConfigBase(BaseModel):
name: str = Field(..., description="Display name for the Git server")

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import List, Optional
from ...core.logger import belief_scope
from ...dependencies import get_config_manager
from ...dependencies import get_config_manager, has_permission
from ...core.database import get_db
from ...models.mapping import DatabaseMapping
from pydantic import BaseModel
@@ -60,7 +60,8 @@ class SuggestRequest(BaseModel):
async def get_mappings(
source_env_id: Optional[str] = None,
target_env_id: Optional[str] = None,
db: Session = Depends(get_db)
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("get_mappings"):
query = db.query(DatabaseMapping)
@@ -76,7 +77,11 @@ async def get_mappings(
# @PRE: mapping is valid MappingCreate, db session is injected.
# @POST: DatabaseMapping created or updated in database.
@router.post("", response_model=MappingResponse)
async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
async def create_mapping(
mapping: MappingCreate,
db: Session = Depends(get_db),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("create_mapping"):
# Check if mapping already exists
existing = db.query(DatabaseMapping).filter(
@@ -106,10 +111,11 @@ async def create_mapping(mapping: MappingCreate, db: Session = Depends(get_db)):
@router.post("/suggest")
async def suggest_mappings_api(
request: SuggestRequest,
config_manager=Depends(get_config_manager)
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:mapper", "EXECUTE"))
):
with belief_scope("suggest_mappings_api"):
from backend.src.services.mapping_service import MappingService
from ...services.mapping_service import MappingService
service = MappingService(config_manager)
try:
return await service.get_suggestions(request.source_env_id, request.target_env_id)

View File

@@ -7,7 +7,7 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import List, Dict
from ...dependencies import get_config_manager, get_task_manager
from ...dependencies import get_config_manager, get_task_manager, has_permission
from ...models.dashboard import DashboardMetadata, DashboardSelection
from ...core.superset_client import SupersetClient
from ...core.logger import belief_scope
@@ -21,7 +21,11 @@ router = APIRouter(prefix="/api", tags=["migration"])
# @PARAM: env_id (str) - The ID of the environment to fetch from.
# @RETURN: List[DashboardMetadata]
@router.get("/environments/{env_id}/dashboards", response_model=List[DashboardMetadata])
async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)):
async def get_dashboards(
env_id: str,
config_manager=Depends(get_config_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("get_dashboards", f"env_id={env_id}"):
environments = config_manager.get_environments()
env = next((e for e in environments if e.id == env_id), None)
@@ -40,7 +44,12 @@ async def get_dashboards(env_id: str, config_manager=Depends(get_config_manager)
# @PARAM: selection (DashboardSelection) - The dashboards to migrate.
# @RETURN: Dict - {"task_id": str, "message": str}
@router.post("/migration/execute")
async def execute_migration(selection: DashboardSelection, config_manager=Depends(get_config_manager), task_manager=Depends(get_task_manager)):
async def execute_migration(
selection: DashboardSelection,
config_manager=Depends(get_config_manager),
task_manager=Depends(get_task_manager),
_ = Depends(has_permission("plugin:migration", "EXECUTE"))
):
with belief_scope("execute_migration"):
# Validate environments exist
environments = config_manager.get_environments()

View File

@@ -7,7 +7,7 @@ from typing import List
from fastapi import APIRouter, Depends
from ...core.plugin_base import PluginConfig
from ...dependencies import get_plugin_loader
from ...dependencies import get_plugin_loader, has_permission
from ...core.logger import belief_scope
router = APIRouter()
@@ -19,7 +19,8 @@ router = APIRouter()
# @RETURN: List[PluginConfig] - List of registered plugins.
@router.get("", response_model=List[PluginConfig])
async def list_plugins(
plugin_loader = Depends(get_plugin_loader)
plugin_loader = Depends(get_plugin_loader),
_ = Depends(has_permission("plugins", "READ"))
):
with belief_scope("list_plugins"):
"""

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException
from typing import List
from ...core.config_models import AppConfig, Environment, GlobalSettings
from ...models.storage import StorageConfig
from ...dependencies import get_config_manager
from ...dependencies import get_config_manager, has_permission
from ...core.config_manager import ConfigManager
from ...core.logger import logger, belief_scope
from ...core.superset_client import SupersetClient
@@ -29,7 +29,10 @@ router = APIRouter()
# @POST: Returns masked AppConfig.
# @RETURN: AppConfig - The current configuration.
@router.get("", response_model=AppConfig)
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_settings"):
logger.info("[get_settings][Entry] Fetching all settings")
config = config_manager.get_config().copy(deep=True)
@@ -49,7 +52,8 @@ async def get_settings(config_manager: ConfigManager = Depends(get_config_manage
@router.patch("/global", response_model=GlobalSettings)
async def update_global_settings(
settings: GlobalSettings,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_global_settings"):
logger.info("[update_global_settings][Entry] Updating global settings")
@@ -62,7 +66,10 @@ async def update_global_settings(
# @PURPOSE: Retrieves storage-specific settings.
# @RETURN: StorageConfig - The storage configuration.
@router.get("/storage", response_model=StorageConfig)
async def get_storage_settings(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_storage_settings(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_storage_settings"):
return config_manager.get_config().settings.storage
# [/DEF:get_storage_settings:Function]
@@ -73,7 +80,11 @@ async def get_storage_settings(config_manager: ConfigManager = Depends(get_confi
# @POST: Storage settings are updated and saved.
# @RETURN: StorageConfig - The updated storage settings.
@router.put("/storage", response_model=StorageConfig)
async def update_storage_settings(storage: StorageConfig, config_manager: ConfigManager = Depends(get_config_manager)):
async def update_storage_settings(
storage: StorageConfig,
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("update_storage_settings"):
is_valid, message = config_manager.validate_path(storage.root_path)
if not is_valid:
@@ -91,7 +102,10 @@ async def update_storage_settings(storage: StorageConfig, config_manager: Config
# @POST: Returns list of environments.
# @RETURN: List[Environment] - List of environments.
@router.get("/environments", response_model=List[Environment])
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
async def get_environments(
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "READ"))
):
with belief_scope("get_environments"):
logger.info("[get_environments][Entry] Fetching environments")
return config_manager.get_environments()
@@ -106,7 +120,8 @@ async def get_environments(config_manager: ConfigManager = Depends(get_config_ma
@router.post("/environments", response_model=Environment)
async def add_environment(
env: Environment,
config_manager: ConfigManager = Depends(get_config_manager)
config_manager: ConfigManager = Depends(get_config_manager),
_ = Depends(has_permission("admin:settings", "WRITE"))
):
with belief_scope("add_environment"):
logger.info(f"[add_environment][Entry] Adding environment {env.id}")

View File

@@ -8,11 +8,12 @@
# @INVARIANT: All paths must be validated against path traversal.
# [SECTION: IMPORTS]
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse
from typing import List, Optional
from ...models.storage import StoredFile, FileCategory
from ...dependencies import get_plugin_loader
from ...dependencies import get_plugin_loader, has_permission
from ...plugins.storage.plugin import StoragePlugin
from ...core.logger import belief_scope
# [/SECTION]
@@ -34,7 +35,8 @@ router = APIRouter(tags=["storage"])
async def list_files(
category: Optional[FileCategory] = None,
path: Optional[str] = None,
plugin_loader=Depends(get_plugin_loader)
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "READ"))
):
with belief_scope("list_files"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
@@ -63,7 +65,8 @@ async def upload_file(
category: FileCategory = Form(...),
path: Optional[str] = Form(None),
file: UploadFile = File(...),
plugin_loader=Depends(get_plugin_loader)
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "WRITE"))
):
with belief_scope("upload_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
@@ -89,7 +92,12 @@ async def upload_file(
#
# @RELATION: CALLS -> StoragePlugin.delete_file
@router.delete("/files/{category}/{path:path}", status_code=204)
async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)):
async def delete_file(
category: FileCategory,
path: str,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "WRITE"))
):
with belief_scope("delete_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:
@@ -114,7 +122,12 @@ async def delete_file(category: FileCategory, path: str, plugin_loader=Depends(g
#
# @RELATION: CALLS -> StoragePlugin.get_file_path
@router.get("/download/{category}/{path:path}")
async def download_file(category: FileCategory, path: str, plugin_loader=Depends(get_plugin_loader)):
async def download_file(
category: FileCategory,
path: str,
plugin_loader=Depends(get_plugin_loader),
_ = Depends(has_permission("plugin:storage", "READ"))
):
with belief_scope("download_file"):
storage_plugin: StoragePlugin = plugin_loader.get_plugin("storage-manager")
if not storage_plugin:

View File

@@ -9,7 +9,7 @@ from pydantic import BaseModel
from ...core.logger import belief_scope
from ...core.task_manager import TaskManager, Task, TaskStatus, LogEntry
from ...dependencies import get_task_manager
from ...dependencies import get_task_manager, has_permission
router = APIRouter()
@@ -33,7 +33,8 @@ class ResumeTaskRequest(BaseModel):
# @RETURN: Task - The created task instance.
async def create_task(
request: CreateTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(lambda req: has_permission(f"plugin:{req.plugin_id}", "EXECUTE"))
):
"""
Create and start a new task for a given plugin.
@@ -63,7 +64,8 @@ async def list_tasks(
limit: int = 10,
offset: int = 0,
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve a list of tasks with pagination and optional status filter.
@@ -82,7 +84,8 @@ async def list_tasks(
# @RETURN: Task - The task details.
async def get_task(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve the details of a specific task.
@@ -104,7 +107,8 @@ async def get_task(
# @RETURN: List[LogEntry] - List of log entries.
async def get_task_logs(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "READ"))
):
"""
Retrieve logs for a specific task.
@@ -128,7 +132,8 @@ async def get_task_logs(
async def resolve_task(
task_id: str,
request: ResolveTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Resolve a task that is awaiting mapping.
@@ -153,7 +158,8 @@ async def resolve_task(
async def resume_task(
task_id: str,
request: ResumeTaskRequest,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Resume a task that is awaiting input (e.g., passwords).
@@ -175,7 +181,8 @@ async def resume_task(
# @POST: Tasks are removed from memory/persistence.
async def clear_tasks(
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager)
task_manager: TaskManager = Depends(get_task_manager),
_ = Depends(has_permission("tasks", "WRITE"))
):
"""
Clear tasks matching the status filter. If no filter, clears all non-running tasks.

View File

@@ -10,6 +10,7 @@ from pathlib import Path
project_root = Path(__file__).resolve().parent.parent.parent
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, Request, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
@@ -18,7 +19,8 @@ import os
from .dependencies import get_task_manager, get_scheduler_service
from .core.logger import logger, belief_scope
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage
from .api.routes import plugins, tasks, settings, environments, mappings, migration, connections, git, storage, admin
from .api import auth
from .core.database import init_db
# [DEF:App:Global]
@@ -55,6 +57,10 @@ async def shutdown_event():
scheduler.stop()
# [/DEF:shutdown_event:Function]
# Configure Session Middleware (required by Authlib for OAuth2 flow)
from .core.auth.config import auth_config
app.add_middleware(SessionMiddleware, secret_key=auth_config.SECRET_KEY)
# Configure CORS
app.add_middleware(
CORSMiddleware,
@@ -81,6 +87,8 @@ async def log_requests(request: Request, call_next):
# [/DEF:log_requests:Function]
# Include API routes
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(plugins.router, prefix="/api/plugins", tags=["Plugins"])
app.include_router(tasks.router, prefix="/api/tasks", tags=["Tasks"])
app.include_router(settings.router, prefix="/api/settings", tags=["Settings"])

View File

@@ -0,0 +1,45 @@
# [DEF:backend.src.core.auth.config:Module]
#
# @SEMANTICS: auth, config, settings, jwt, adfs
# @PURPOSE: Centralized configuration for authentication and authorization.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> pydantic
#
# @INVARIANT: All sensitive configuration must have defaults or be loaded from environment.
# [SECTION: IMPORTS]
from pydantic import Field
from pydantic_settings import BaseSettings
import os
# [/SECTION]
# [DEF:AuthConfig:Class]
# @PURPOSE: Holds authentication-related settings.
# @PRE: Environment variables may be provided via .env file.
# @POST: Returns a configuration object with validated settings.
class AuthConfig(BaseSettings):
# JWT Settings
SECRET_KEY: str = Field(default="super-secret-key-change-in-production", env="AUTH_SECRET_KEY")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Database Settings
AUTH_DATABASE_URL: str = Field(default="sqlite:///./backend/auth.db", env="AUTH_DATABASE_URL")
# ADFS Settings
ADFS_CLIENT_ID: str = Field(default="", env="ADFS_CLIENT_ID")
ADFS_CLIENT_SECRET: str = Field(default="", env="ADFS_CLIENT_SECRET")
ADFS_METADATA_URL: str = Field(default="", env="ADFS_METADATA_URL")
class Config:
env_file = ".env"
extra = "ignore"
# [/DEF:AuthConfig:Class]
# [DEF:auth_config:Variable]
# @PURPOSE: Singleton instance of AuthConfig.
auth_config = AuthConfig()
# [/DEF:auth_config:Variable]
# [/DEF:backend.src.core.auth.config:Module]

View File

@@ -0,0 +1,54 @@
# [DEF:backend.src.core.auth.jwt:Module]
#
# @SEMANTICS: jwt, token, session, auth
# @PURPOSE: JWT token generation and validation logic.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> jose
# @RELATION: USES -> backend.src.core.auth.config.auth_config
#
# @INVARIANT: Tokens must include expiration time and user identifier.
# [SECTION: IMPORTS]
from datetime import datetime, timedelta
from typing import Optional, List
from jose import JWTError, jwt
from .config import auth_config
from ..logger import belief_scope
# [/SECTION]
# [DEF:create_access_token:Function]
# @PURPOSE: Generates a new JWT access token.
# @PRE: data dict contains 'sub' (user_id) and optional 'scopes' (roles).
# @POST: Returns a signed JWT string.
#
# @PARAM: data (dict) - Payload data for the token.
# @PARAM: expires_delta (Optional[timedelta]) - Custom expiration time.
# @RETURN: str - The encoded JWT.
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
with belief_scope("create_access_token"):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=auth_config.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, auth_config.SECRET_KEY, algorithm=auth_config.ALGORITHM)
return encoded_jwt
# [/DEF:create_access_token:Function]
# [DEF:decode_token:Function]
# @PURPOSE: Decodes and validates a JWT token.
# @PRE: token is a signed JWT string.
# @POST: Returns the decoded payload if valid.
#
# @PARAM: token (str) - The JWT to decode.
# @RETURN: dict - The decoded payload.
# @THROW: jose.JWTError - If token is invalid or expired.
def decode_token(token: str) -> dict:
with belief_scope("decode_token"):
payload = jwt.decode(token, auth_config.SECRET_KEY, algorithms=[auth_config.ALGORITHM])
return payload
# [/DEF:decode_token:Function]
# [/DEF:backend.src.core.auth.jwt:Module]

View File

@@ -0,0 +1,31 @@
# [DEF:backend.src.core.auth.logger:Module]
#
# @SEMANTICS: auth, logger, audit, security
# @PURPOSE: Audit logging for security-related events.
# @LAYER: Core
# @RELATION: USES -> backend.src.core.logger.belief_scope
#
# @INVARIANT: Must not log sensitive data like passwords or full tokens.
# [SECTION: IMPORTS]
from ..logger import logger, belief_scope
from datetime import datetime
# [/SECTION]
# [DEF:log_security_event:Function]
# @PURPOSE: Logs a security-related event for audit trails.
# @PRE: event_type and username are strings.
# @POST: Security event is written to the application log.
# @PARAM: event_type (str) - Type of event (e.g., LOGIN_SUCCESS, PERMISSION_DENIED).
# @PARAM: username (str) - The user involved in the event.
# @PARAM: details (dict) - Additional non-sensitive metadata.
def log_security_event(event_type: str, username: str, details: dict = None):
with belief_scope("log_security_event", f"{event_type}:{username}"):
timestamp = datetime.utcnow().isoformat()
msg = f"[AUDIT][{timestamp}][{event_type}] User: {username}"
if details:
msg += f" Details: {details}"
logger.info(msg)
# [/DEF:log_security_event:Function]
# [/DEF:backend.src.core.auth.logger:Module]

View File

@@ -0,0 +1,51 @@
# [DEF:backend.src.core.auth.oauth:Module]
#
# @SEMANTICS: auth, oauth, oidc, adfs
# @PURPOSE: ADFS OIDC configuration and client using Authlib.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> authlib
# @RELATION: USES -> backend.src.core.auth.config.auth_config
#
# @INVARIANT: Must use secure OIDC flows.
# [SECTION: IMPORTS]
from authlib.integrations.starlette_client import OAuth
from .config import auth_config
# [/SECTION]
# [DEF:oauth:Variable]
# @PURPOSE: Global Authlib OAuth registry.
oauth = OAuth()
# [/DEF:oauth:Variable]
# [DEF:register_adfs:Function]
# @PURPOSE: Registers the ADFS OIDC client.
# @PRE: ADFS configuration is provided in auth_config.
# @POST: ADFS client is registered in oauth registry.
def register_adfs():
if auth_config.ADFS_CLIENT_ID:
oauth.register(
name='adfs',
client_id=auth_config.ADFS_CLIENT_ID,
client_secret=auth_config.ADFS_CLIENT_SECRET,
server_metadata_url=auth_config.ADFS_METADATA_URL,
client_kwargs={
'scope': 'openid email profile groups'
}
)
# [/DEF:register_adfs:Function]
# [DEF:is_adfs_configured:Function]
# @PURPOSE: Checks if ADFS is properly configured.
# @PRE: None.
# @POST: Returns True if ADFS client is registered, False otherwise.
# @RETURN: bool - Configuration status.
def is_adfs_configured() -> bool:
"""Check if ADFS OAuth client is registered."""
return 'adfs' in oauth._registry
# [/DEF:is_adfs_configured:Function]
# Initial registration
register_adfs()
# [/DEF:backend.src.core.auth.oauth:Module]

View File

@@ -0,0 +1,123 @@
# [DEF:backend.src.core.auth.repository:Module]
#
# @SEMANTICS: auth, repository, database, user, role
# @PURPOSE: Data access layer for authentication-related entities.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.auth
#
# @INVARIANT: All database operations must be performed within a session.
# [SECTION: IMPORTS]
from typing import Optional, List
from sqlalchemy.orm import Session
from ...models.auth import User, Role, Permission, ADGroupMapping
from ..logger import belief_scope
# [/SECTION]
# [DEF:AuthRepository:Class]
# @PURPOSE: Encapsulates database operations for authentication.
class AuthRepository:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the repository with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.db = db
# [/DEF:__init__:Function]
# [DEF:get_user_by_username:Function]
# @PURPOSE: Retrieves a user by their username.
# @PRE: username is a string.
# @POST: Returns User object if found, else None.
# @PARAM: username (str) - The username to search for.
# @RETURN: Optional[User] - The found user or None.
def get_user_by_username(self, username: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_username"):
return self.db.query(User).filter(User.username == username).first()
# [/DEF:get_user_by_username:Function]
# [DEF:get_user_by_id:Function]
# @PURPOSE: Retrieves a user by their unique ID.
# @PRE: user_id is a valid UUID string.
# @POST: Returns User object if found, else None.
# @PARAM: user_id (str) - The user's unique identifier.
# @RETURN: Optional[User] - The found user or None.
def get_user_by_id(self, user_id: str) -> Optional[User]:
with belief_scope("AuthRepository.get_user_by_id"):
return self.db.query(User).filter(User.id == user_id).first()
# [/DEF:get_user_by_id:Function]
# [DEF:get_role_by_name:Function]
# @PURPOSE: Retrieves a role by its name.
# @PRE: name is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: name (str) - The role name to search for.
# @RETURN: Optional[Role] - The found role or None.
def get_role_by_name(self, name: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_name"):
return self.db.query(Role).filter(Role.name == name).first()
# [/DEF:get_role_by_name:Function]
# [DEF:update_last_login:Function]
# @PURPOSE: Updates the last_login timestamp for a user.
# @PRE: user object is a valid User instance.
# @POST: User's last_login is updated in the database.
# @SIDE_EFFECT: Commits the transaction.
# @PARAM: user (User) - The user to update.
def update_last_login(self, user: User):
with belief_scope("AuthRepository.update_last_login"):
from datetime import datetime
user.last_login = datetime.utcnow()
self.db.add(user)
self.db.commit()
# [/DEF:update_last_login:Function]
# [DEF:get_role_by_id:Function]
# @PURPOSE: Retrieves a role by its unique ID.
# @PRE: role_id is a string.
# @POST: Returns Role object if found, else None.
# @PARAM: role_id (str) - The role's unique identifier.
# @RETURN: Optional[Role] - The found role or None.
def get_role_by_id(self, role_id: str) -> Optional[Role]:
with belief_scope("AuthRepository.get_role_by_id"):
return self.db.query(Role).filter(Role.id == role_id).first()
# [/DEF:get_role_by_id:Function]
# [DEF:get_permission_by_id:Function]
# @PURPOSE: Retrieves a permission by its unique ID.
# @PRE: perm_id is a string.
# @POST: Returns Permission object if found, else None.
# @PARAM: perm_id (str) - The permission's unique identifier.
# @RETURN: Optional[Permission] - The found permission or None.
def get_permission_by_id(self, perm_id: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_id"):
return self.db.query(Permission).filter(Permission.id == perm_id).first()
# [/DEF:get_permission_by_id:Function]
# [DEF:get_permission_by_resource_action:Function]
# @PURPOSE: Retrieves a permission by resource and action.
# @PRE: resource and action are strings.
# @POST: Returns Permission object if found, else None.
# @PARAM: resource (str) - The resource name.
# @PARAM: action (str) - The action name.
# @RETURN: Optional[Permission] - The found permission or None.
def get_permission_by_resource_action(self, resource: str, action: str) -> Optional[Permission]:
with belief_scope("AuthRepository.get_permission_by_resource_action"):
return self.db.query(Permission).filter(
Permission.resource == resource,
Permission.action == action
).first()
# [/DEF:get_permission_by_resource_action:Function]
# [DEF:list_permissions:Function]
# @PURPOSE: Lists all available permissions.
# @POST: Returns a list of all Permission objects.
# @RETURN: List[Permission] - List of permissions.
def list_permissions(self) -> List[Permission]:
with belief_scope("AuthRepository.list_permissions"):
return self.db.query(Permission).all()
# [/DEF:list_permissions:Function]
# [/DEF:AuthRepository:Class]
# [/DEF:backend.src.core.auth.repository:Module]

View File

@@ -0,0 +1,42 @@
# [DEF:backend.src.core.auth.security:Module]
#
# @SEMANTICS: security, password, hashing, bcrypt
# @PURPOSE: Utility for password hashing and verification using Passlib.
# @LAYER: Core
# @RELATION: DEPENDS_ON -> passlib
#
# @INVARIANT: Uses bcrypt for hashing with standard work factor.
# [SECTION: IMPORTS]
from passlib.context import CryptContext
# [/SECTION]
# [DEF:pwd_context:Variable]
# @PURPOSE: Passlib CryptContext for password management.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# [/DEF:pwd_context:Variable]
# [DEF:verify_password:Function]
# @PURPOSE: Verifies a plain password against a hashed password.
# @PRE: plain_password is a string, hashed_password is a bcrypt hash.
# @POST: Returns True if password matches, False otherwise.
#
# @PARAM: plain_password (str) - The unhashed password.
# @PARAM: hashed_password (str) - The stored hash.
# @RETURN: bool - Verification result.
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
# [/DEF:verify_password:Function]
# [DEF:get_password_hash:Function]
# @PURPOSE: Generates a bcrypt hash for a plain password.
# @PRE: password is a string.
# @POST: Returns a secure bcrypt hash string.
#
# @PARAM: password (str) - The password to hash.
# @RETURN: str - The generated hash.
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
# [/DEF:get_password_hash:Function]
# [/DEF:backend.src.core.auth.security:Module]

View File

@@ -5,6 +5,7 @@
# @LAYER: Core
# @RELATION: DEPENDS_ON -> sqlalchemy
# @RELATION: USES -> backend.src.models.mapping
# @RELATION: USES -> backend.src.core.auth.config
#
# @INVARIANT: A single engine instance is used for the entire application.
@@ -16,44 +17,70 @@ from ..models.mapping import Base
from ..models.task import TaskRecord
from ..models.connection import ConnectionConfig
from ..models.git import GitServerConfig, GitRepository, DeploymentEnvironment
from ..models.auth import User, Role, Permission, ADGroupMapping
from .logger import belief_scope
from .auth.config import auth_config
import os
# [/SECTION]
# [DEF:DATABASE_URL:Constant]
# @PURPOSE: URL for the main mappings database.
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./mappings.db")
# [/DEF:DATABASE_URL:Constant]
# [DEF:TASKS_DATABASE_URL:Constant]
# @PURPOSE: URL for the tasks execution database.
TASKS_DATABASE_URL = os.getenv("TASKS_DATABASE_URL", "sqlite:///./tasks.db")
# [/DEF:TASKS_DATABASE_URL:Constant]
# [DEF:AUTH_DATABASE_URL:Constant]
# @PURPOSE: URL for the authentication database.
AUTH_DATABASE_URL = auth_config.AUTH_DATABASE_URL
# [/DEF:AUTH_DATABASE_URL:Constant]
# [DEF:engine:Variable]
# @PURPOSE: SQLAlchemy engine for mappings database.
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:engine:Variable]
# [DEF:tasks_engine:Variable]
# @PURPOSE: SQLAlchemy engine for tasks database.
tasks_engine = create_engine(TASKS_DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:tasks_engine:Variable]
# [DEF:auth_engine:Variable]
# @PURPOSE: SQLAlchemy engine for authentication database.
auth_engine = create_engine(AUTH_DATABASE_URL, connect_args={"check_same_thread": False})
# [/DEF:auth_engine:Variable]
# [DEF:SessionLocal:Class]
# @PURPOSE: A session factory for the main mappings database.
# @PRE: engine is initialized.
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# [/DEF:SessionLocal:Class]
# [DEF:TasksSessionLocal:Class]
# @PURPOSE: A session factory for the tasks execution database.
# @PRE: tasks_engine is initialized.
TasksSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=tasks_engine)
# [/DEF:TasksSessionLocal:Class]
# [DEF:AuthSessionLocal:Class]
# @PURPOSE: A session factory for the authentication database.
# @PRE: auth_engine is initialized.
AuthSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=auth_engine)
# [/DEF:AuthSessionLocal:Class]
# [DEF:init_db:Function]
# @PURPOSE: Initializes the database by creating all tables.
# @PRE: engine and tasks_engine are initialized.
# @POST: Database tables created.
# @PRE: engine, tasks_engine and auth_engine are initialized.
# @POST: Database tables created in all databases.
# @SIDE_EFFECT: Creates physical database files if they don't exist.
def init_db():
with belief_scope("init_db"):
Base.metadata.create_all(bind=engine)
Base.metadata.create_all(bind=tasks_engine)
Base.metadata.create_all(bind=auth_engine)
# [/DEF:init_db:Function]
# [DEF:get_db:Function]
@@ -84,4 +111,18 @@ def get_tasks_db():
db.close()
# [/DEF:get_tasks_db:Function]
# [DEF:get_auth_db:Function]
# @PURPOSE: Dependency for getting an authentication database session.
# @PRE: AuthSessionLocal is initialized.
# @POST: Session is closed after use.
# @RETURN: Generator[Session, None, None]
def get_auth_db():
with belief_scope("get_auth_db"):
db = AuthSessionLocal()
try:
yield db
finally:
db.close()
# [/DEF:get_auth_db:Function]
# [/DEF:backend.src.core.database:Module]

View File

@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from typing import Dict, Any
from typing import Dict, Any, Optional
from .logger import belief_scope
from pydantic import BaseModel, Field
@@ -68,6 +68,33 @@ class PluginBase(ABC):
pass
# [/DEF:version:Function]
@property
# [DEF:required_permission:Function]
# @PURPOSE: Returns the required permission string to execute this plugin.
# @PRE: Plugin instance exists.
# @POST: Returns string permission.
# @RETURN: str - Required permission (e.g., "plugin:backup:execute").
def required_permission(self) -> str:
"""The permission string required to execute this plugin."""
with belief_scope("required_permission"):
return f"plugin:{self.id}:execute"
# [/DEF:required_permission:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the plugin's UI, if applicable.
# @PRE: Plugin instance exists.
# @POST: Returns string route or None.
# @RETURN: Optional[str] - Frontend route.
def ui_route(self) -> Optional[str]:
"""
The frontend route for the plugin's UI.
Returns None if the plugin does not have a dedicated UI page.
"""
with belief_scope("ui_route"):
return None
# [/DEF:ui_route:Function]
@abstractmethod
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the plugin's input parameters.
@@ -111,5 +138,6 @@ class PluginConfig(BaseModel):
name: str = Field(..., description="Human-readable name for the plugin")
description: str = Field(..., description="Brief description of what the plugin does")
version: str = Field(..., description="Version of the plugin")
ui_route: Optional[str] = Field(None, description="Frontend route for the plugin UI")
input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema")
# [/DEF:PluginConfig:Class]

View File

@@ -141,6 +141,7 @@ class PluginLoader:
name=plugin_instance.name,
description=plugin_instance.description,
version=plugin_instance.version,
ui_route=plugin_instance.ui_route,
schema=schema,
)
# The following line is commented out because it requires a schema to be passed to validate against.

View File

@@ -1,16 +1,23 @@
# [DEF:Dependencies:Module]
# @SEMANTICS: dependency, injection, singleton, factory
# @SEMANTICS: dependency, injection, singleton, factory, auth, jwt
# @PURPOSE: Manages the creation and provision of shared application dependencies, such as the PluginLoader and TaskManager, to avoid circular imports.
# @LAYER: Core
# @RELATION: Used by the main app and API routers to get access to shared instances.
from pathlib import Path
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from .core.plugin_loader import PluginLoader
from .core.task_manager import TaskManager
from .core.config_manager import ConfigManager
from .core.scheduler import SchedulerService
from .core.database import init_db
from .core.database import init_db, get_auth_db
from .core.logger import logger, belief_scope
from .core.auth.jwt import decode_token
from .core.auth.repository import AuthRepository
from .models.auth import User
# Initialize singletons
# Use absolute path relative to this file to ensure plugins are found regardless of CWD
@@ -77,4 +84,70 @@ def get_scheduler_service() -> SchedulerService:
return scheduler_service
# [/DEF:get_scheduler_service:Function]
# [DEF:oauth2_scheme:Variable]
# @PURPOSE: OAuth2 password bearer scheme for token extraction.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
# [/DEF:oauth2_scheme:Variable]
# [DEF:get_current_user:Function]
# @PURPOSE: Dependency for retrieving the currently authenticated user from a JWT.
# @PRE: JWT token provided in Authorization header.
# @POST: Returns the User object if token is valid.
# @THROW: HTTPException 401 if token is invalid or user not found.
# @PARAM: token (str) - Extracted JWT token.
# @PARAM: db (Session) - Auth database session.
# @RETURN: User - The authenticated user.
def get_current_user(token: str = Depends(oauth2_scheme), db = Depends(get_auth_db)):
with belief_scope("get_current_user"):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
repo = AuthRepository(db)
user = repo.get_user_by_username(username)
if user is None:
raise credentials_exception
return user
# [/DEF:get_current_user:Function]
# [DEF:has_permission:Function]
# @PURPOSE: Dependency for checking if the current user has a specific permission.
# @PRE: User is authenticated.
# @POST: Returns True if user has permission.
# @THROW: HTTPException 403 if permission is denied.
# @PARAM: resource (str) - The resource identifier.
# @PARAM: action (str) - The action identifier (READ, EXECUTE, WRITE).
# @RETURN: User - The authenticated user if permission granted.
def has_permission(resource: str, action: str):
def permission_checker(current_user: User = Depends(get_current_user)):
with belief_scope("has_permission", f"{resource}:{action}"):
# Union of all permissions across all roles
for role in current_user.roles:
for perm in role.permissions:
if perm.resource == resource and perm.action == action:
return current_user
# Special case for Admin role (full access)
if any(role.name == "Admin" for role in current_user.roles):
return current_user
from .core.auth.logger import log_security_event
log_security_event("PERMISSION_DENIED", current_user.username, {"resource": resource, "action": action})
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission denied for {resource}:{action}"
)
return permission_checker
# [/DEF:has_permission:Function]
# [/DEF:Dependencies:Module]

105
backend/src/models/auth.py Normal file
View File

@@ -0,0 +1,105 @@
# [DEF:backend.src.models.auth:Module]
#
# @TIER: STANDARD
# @SEMANTICS: auth, models, user, role, permission, sqlalchemy
# @PURPOSE: SQLAlchemy models for multi-user authentication and authorization.
# @LAYER: Domain
# @RELATION: INHERITS_FROM -> backend.src.models.mapping.Base
#
# @INVARIANT: Usernames and emails must be unique.
# [SECTION: IMPORTS]
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Table, Enum
from sqlalchemy.orm import relationship
from .mapping import Base
# [/SECTION]
# [DEF:generate_uuid:Function]
# @PURPOSE: Generates a unique UUID string.
# @POST: Returns a string representation of a new UUID.
def generate_uuid():
return str(uuid.uuid4())
# [/DEF:generate_uuid:Function]
# [DEF:user_roles:Table]
# @PURPOSE: Association table for many-to-many relationship between Users and Roles.
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", String, ForeignKey("users.id"), primary_key=True),
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
)
# [/DEF:user_roles:Table]
# [DEF:role_permissions:Table]
# @PURPOSE: Association table for many-to-many relationship between Roles and Permissions.
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", String, ForeignKey("roles.id"), primary_key=True),
Column("permission_id", String, ForeignKey("permissions.id"), primary_key=True),
)
# [/DEF:role_permissions:Table]
# [DEF:User:Class]
# @PURPOSE: Represents an identity that can authenticate to the system.
# @RELATION: HAS_MANY -> Role (via user_roles)
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=generate_uuid)
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True, nullable=True)
password_hash = Column(String, nullable=True)
auth_source = Column(String, default="LOCAL") # LOCAL or ADFS
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True)
roles = relationship("Role", secondary=user_roles, back_populates="users")
# [/DEF:User:Class]
# [DEF:Role:Class]
# @PURPOSE: Represents a collection of permissions.
# @RELATION: HAS_MANY -> User (via user_roles)
# @RELATION: HAS_MANY -> Permission (via role_permissions)
class Role(Base):
__tablename__ = "roles"
id = Column(String, primary_key=True, default=generate_uuid)
name = Column(String, unique=True, index=True, nullable=False)
description = Column(String, nullable=True)
users = relationship("User", secondary=user_roles, back_populates="roles")
permissions = relationship("Permission", secondary=role_permissions, back_populates="roles")
# [/DEF:Role:Class]
# [DEF:Permission:Class]
# @PURPOSE: Represents a specific capability within the system.
# @RELATION: HAS_MANY -> Role (via role_permissions)
class Permission(Base):
__tablename__ = "permissions"
id = Column(String, primary_key=True, default=generate_uuid)
resource = Column(String, nullable=False) # e.g. "plugin:backup"
action = Column(String, nullable=False) # e.g. "READ", "EXECUTE", "WRITE"
roles = relationship("Role", secondary=role_permissions, back_populates="permissions")
# [/DEF:Permission:Class]
# [DEF:ADGroupMapping:Class]
# @PURPOSE: Maps an Active Directory group to a local System Role.
# @RELATION: DEPENDS_ON -> Role
class ADGroupMapping(Base):
__tablename__ = "ad_group_mappings"
id = Column(String, primary_key=True, default=generate_uuid)
ad_group = Column(String, unique=True, index=True, nullable=False)
role_id = Column(String, ForeignKey("roles.id"), nullable=False)
role = relationship("Role")
# [/DEF:ADGroupMapping:Class]
# [/DEF:backend.src.models.auth:Module]

View File

@@ -1,4 +1,5 @@
# [DEF:backend.src.models.dashboard:Module]
# @TIER: STANDARD
# @SEMANTICS: dashboard, model, metadata, migration
# @PURPOSE: Defines data models for dashboard metadata and selection.
# @LAYER: Model
@@ -8,6 +9,7 @@ from pydantic import BaseModel
from typing import List
# [DEF:DashboardMetadata:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents a dashboard available for migration.
class DashboardMetadata(BaseModel):
id: int
@@ -17,6 +19,7 @@ class DashboardMetadata(BaseModel):
# [/DEF:DashboardMetadata:Class]
# [DEF:DashboardSelection:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents the user's selection of dashboards to migrate.
class DashboardSelection(BaseModel):
selected_ids: List[int]

View File

@@ -1,5 +1,6 @@
# [DEF:backend.src.models.mapping:Module]
#
# @TIER: STANDARD
# @SEMANTICS: database, mapping, environment, migration, sqlalchemy, sqlite
# @PURPOSE: Defines the database schema for environment metadata and database mappings using SQLAlchemy.
# @LAYER: Domain
@@ -19,6 +20,7 @@ import enum
Base = declarative_base()
# [DEF:MigrationStatus:Class]
# @TIER: TRIVIAL
# @PURPOSE: Enumeration of possible migration job statuses.
class MigrationStatus(enum.Enum):
PENDING = "PENDING"
@@ -29,6 +31,7 @@ class MigrationStatus(enum.Enum):
# [/DEF:MigrationStatus:Class]
# [DEF:Environment:Class]
# @TIER: STANDARD
# @PURPOSE: Represents a Superset instance environment.
class Environment(Base):
__tablename__ = "environments"
@@ -40,6 +43,7 @@ class Environment(Base):
# [/DEF:Environment:Class]
# [DEF:DatabaseMapping:Class]
# @TIER: STANDARD
# @PURPOSE: Represents a mapping between source and target databases.
class DatabaseMapping(Base):
__tablename__ = "database_mappings"

View File

@@ -75,6 +75,15 @@ class BackupPlugin(PluginBase):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the backup plugin.
# @RETURN: str - "/tools/backups"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/backups"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for backup plugin parameters.
# @PRE: Plugin instance exists.

View File

@@ -63,6 +63,15 @@ class DebugPlugin(PluginBase):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the debug plugin.
# @RETURN: str - "/tools/debug"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/debug"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the debug plugin parameters.
# @PRE: Plugin instance exists.

View File

@@ -99,6 +99,15 @@ class GitPlugin(PluginBase):
return "0.1.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the git plugin.
# @RETURN: str - "/git"
def ui_route(self) -> str:
with belief_scope("GitPlugin.ui_route"):
return "/git"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Возвращает JSON-схему параметров для выполнения задач плагина.
# @PRE: GitPlugin is initialized.

View File

@@ -66,6 +66,15 @@ class MapperPlugin(PluginBase):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the mapper plugin.
# @RETURN: str - "/tools/mapper"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/mapper"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the mapper plugin parameters.
# @PRE: Plugin instance exists.

View File

@@ -71,6 +71,15 @@ class MigrationPlugin(PluginBase):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the migration plugin.
# @RETURN: str - "/migration"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/migration"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for migration plugin parameters.
# @PRE: Config manager is available.
@@ -294,9 +303,9 @@ class MigrationPlugin(PluginBase):
try:
exported_content, _ = from_c.export_dashboard(dash_id)
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip", logger=logger) as tmp_zip_path:
with create_temp_file(content=exported_content, dry_run=True, suffix=".zip") as tmp_zip_path:
# Always transform to strip databases to avoid password errors
with create_temp_file(suffix=".zip", dry_run=True, logger=logger) as tmp_new_zip:
with create_temp_file(suffix=".zip", dry_run=True) as tmp_new_zip:
success = engine.transform_zip(str(tmp_zip_path), str(tmp_new_zip), db_mapping, strip_databases=False)
if not success and replace_db_config:

View File

@@ -64,6 +64,15 @@ class SearchPlugin(PluginBase):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the search plugin.
# @RETURN: str - "/tools/search"
def ui_route(self) -> str:
with belief_scope("ui_route"):
return "/tools/search"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for the search plugin parameters.
# @PRE: Plugin instance exists.

View File

@@ -82,6 +82,15 @@ class StoragePlugin(PluginBase):
return "1.0.0"
# [/DEF:version:Function]
@property
# [DEF:ui_route:Function]
# @PURPOSE: Returns the frontend route for the storage plugin.
# @RETURN: str - "/tools/storage"
def ui_route(self) -> str:
with belief_scope("StoragePlugin:ui_route"):
return "/tools/storage"
# [/DEF:ui_route:Function]
# [DEF:get_schema:Function]
# @PURPOSE: Returns the JSON schema for storage plugin parameters.
# @PRE: None.

128
backend/src/schemas/auth.py Normal file
View File

@@ -0,0 +1,128 @@
# [DEF:backend.src.schemas.auth:Module]
#
# @TIER: STANDARD
# @SEMANTICS: auth, schemas, pydantic, user, token
# @PURPOSE: Pydantic schemas for authentication requests and responses.
# @LAYER: API
# @RELATION: DEPENDS_ON -> pydantic
#
# @INVARIANT: Sensitive fields like password must not be included in response schemas.
# [SECTION: IMPORTS]
from typing import List, Optional
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
# [/SECTION]
# [DEF:Token:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents a JWT access token response.
class Token(BaseModel):
access_token: str
token_type: str
# [/DEF:Token:Class]
# [DEF:TokenData:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents the data encoded in a JWT token.
class TokenData(BaseModel):
username: Optional[str] = None
scopes: List[str] = []
# [/DEF:TokenData:Class]
# [DEF:PermissionSchema:Class]
# @TIER: TRIVIAL
# @PURPOSE: Represents a permission in API responses.
class PermissionSchema(BaseModel):
id: Optional[str] = None
resource: str
action: str
class Config:
from_attributes = True
# [/DEF:PermissionSchema:Class]
# [DEF:RoleSchema:Class]
# @PURPOSE: Represents a role in API responses.
class RoleSchema(BaseModel):
id: str
name: str
description: Optional[str] = None
permissions: List[PermissionSchema] = []
class Config:
from_attributes = True
# [/DEF:RoleSchema:Class]
# [DEF:RoleCreate:Class]
# @PURPOSE: Schema for creating a new role.
class RoleCreate(BaseModel):
name: str
description: Optional[str] = None
permissions: List[str] = [] # List of permission IDs or "resource:action" strings
# [/DEF:RoleCreate:Class]
# [DEF:RoleUpdate:Class]
# @PURPOSE: Schema for updating an existing role.
class RoleUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
permissions: Optional[List[str]] = None
# [/DEF:RoleUpdate:Class]
# [DEF:ADGroupMappingSchema:Class]
# @PURPOSE: Represents an AD Group to Role mapping in API responses.
class ADGroupMappingSchema(BaseModel):
id: str
ad_group: str
role_id: str
class Config:
from_attributes = True
# [/DEF:ADGroupMappingSchema:Class]
# [DEF:ADGroupMappingCreate:Class]
# @PURPOSE: Schema for creating an AD Group mapping.
class ADGroupMappingCreate(BaseModel):
ad_group: str
role_id: str
# [/DEF:ADGroupMappingCreate:Class]
# [DEF:UserBase:Class]
# @PURPOSE: Base schema for user data.
class UserBase(BaseModel):
username: str
email: Optional[EmailStr] = None
is_active: bool = True
# [/DEF:UserBase:Class]
# [DEF:UserCreate:Class]
# @PURPOSE: Schema for creating a new user.
class UserCreate(UserBase):
password: str
roles: List[str] = []
# [/DEF:UserCreate:Class]
# [DEF:UserUpdate:Class]
# @PURPOSE: Schema for updating an existing user.
class UserUpdate(BaseModel):
email: Optional[EmailStr] = None
password: Optional[str] = None
is_active: Optional[bool] = None
roles: Optional[List[str]] = None
# [/DEF:UserUpdate:Class]
# [DEF:User:Class]
# @PURPOSE: Schema for user data in API responses.
class User(UserBase):
id: str
auth_source: str
created_at: datetime
last_login: Optional[datetime] = None
roles: List[RoleSchema] = []
class Config:
from_attributes = True
# [/DEF:User:Class]
# [/DEF:backend.src.schemas.auth:Module]

View File

@@ -0,0 +1,82 @@
# [DEF:backend.src.scripts.create_admin:Module]
#
# @SEMANTICS: admin, setup, user, auth, cli
# @PURPOSE: CLI tool for creating the initial admin user.
# @LAYER: Scripts
# @RELATION: USES -> backend.src.core.auth.security
# @RELATION: USES -> backend.src.core.database
# @RELATION: USES -> backend.src.models.auth
#
# @INVARIANT: Admin user must have the "Admin" role.
# [SECTION: IMPORTS]
import sys
import argparse
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import AuthSessionLocal, init_db
from src.core.auth.security import get_password_hash
from src.models.auth import User, Role, Permission
from src.core.logger import logger, belief_scope
# [/SECTION]
# [DEF:create_admin:Function]
# @PURPOSE: Creates an admin user and necessary roles/permissions.
# @PRE: username and password provided via CLI.
# @POST: Admin user exists in auth.db.
#
# @PARAM: username (str) - Admin username.
# @PARAM: password (str) - Admin password.
def create_admin(username, password):
with belief_scope("create_admin"):
db = AuthSessionLocal()
try:
# 1. Ensure Admin role exists
admin_role = db.query(Role).filter(Role.name == "Admin").first()
if not admin_role:
logger.info("Creating Admin role...")
admin_role = Role(name="Admin", description="System Administrator")
db.add(admin_role)
db.commit()
db.refresh(admin_role)
# 2. Check if user already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
logger.warning(f"User {username} already exists.")
return
# 3. Create Admin user
logger.info(f"Creating admin user: {username}")
new_user = User(
username=username,
password_hash=get_password_hash(password),
auth_source="LOCAL",
is_active=True
)
new_user.roles.append(admin_role)
db.add(new_user)
db.commit()
logger.info(f"Admin user {username} created successfully.")
except Exception as e:
logger.error(f"Failed to create admin user: {e}")
db.rollback()
finally:
db.close()
# [/DEF:create_admin:Function]
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Create initial admin user")
parser.add_argument("--username", required=True, help="Admin username")
parser.add_argument("--password", required=True, help="Admin password")
args = parser.parse_args()
# Ensure DB is initialized before creating admin
init_db()
create_admin(args.username, args.password)
# [/DEF:backend.src.scripts.create_admin:Module]

View File

@@ -0,0 +1,44 @@
# [DEF:backend.src.scripts.init_auth_db:Module]
#
# @SEMANTICS: setup, database, auth, migration
# @PURPOSE: Initializes the auth database and creates the necessary tables.
# @LAYER: Scripts
# @RELATION: CALLS -> backend.src.core.database.init_db
#
# @INVARIANT: Safe to run multiple times (idempotent).
# [SECTION: IMPORTS]
import sys
import os
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import init_db, auth_engine
from src.core.logger import logger, belief_scope
from src.scripts.seed_permissions import seed_permissions
# [/SECTION]
# [DEF:run_init:Function]
# @PURPOSE: Main entry point for the initialization script.
# @POST: auth.db is initialized with the correct schema and seeded permissions.
def run_init():
with belief_scope("init_auth_db"):
logger.info("Initializing authentication database...")
try:
init_db()
logger.info("Authentication database initialized successfully.")
# Seed permissions
seed_permissions()
except Exception as e:
logger.error(f"Failed to initialize authentication database: {e}")
sys.exit(1)
# [/DEF:run_init:Function]
if __name__ == "__main__":
run_init()
# [/DEF:backend.src.scripts.init_auth_db:Module]

View File

@@ -0,0 +1,116 @@
# [DEF:backend.src.scripts.seed_permissions:Module]
#
# @SEMANTICS: setup, database, auth, permissions, seeding
# @PURPOSE: Populates the auth database with initial system permissions.
# @LAYER: Scripts
# @RELATION: USES -> backend.src.core.database.get_auth_db
# @RELATION: USES -> backend.src.models.auth.Permission
#
# @INVARIANT: Safe to run multiple times (idempotent).
# [SECTION: IMPORTS]
import sys
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent.parent))
from src.core.database import AuthSessionLocal
from src.models.auth import Permission, Role
from src.core.auth.repository import AuthRepository
from src.core.logger import logger, belief_scope
# [/SECTION]
# [DEF:INITIAL_PERMISSIONS:Constant]
INITIAL_PERMISSIONS = [
# Admin Permissions
{"resource": "admin:users", "action": "READ"},
{"resource": "admin:users", "action": "WRITE"},
{"resource": "admin:roles", "action": "READ"},
{"resource": "admin:roles", "action": "WRITE"},
{"resource": "admin:settings", "action": "READ"},
{"resource": "admin:settings", "action": "WRITE"},
{"resource": "environments", "action": "READ"},
{"resource": "plugins", "action": "READ"},
{"resource": "tasks", "action": "READ"},
{"resource": "tasks", "action": "WRITE"},
# Plugin Permissions
{"resource": "plugin:backup", "action": "EXECUTE"},
{"resource": "plugin:migration", "action": "EXECUTE"},
{"resource": "plugin:mapper", "action": "EXECUTE"},
{"resource": "plugin:search", "action": "EXECUTE"},
{"resource": "plugin:git", "action": "EXECUTE"},
{"resource": "plugin:storage", "action": "EXECUTE"},
{"resource": "plugin:storage", "action": "READ"},
{"resource": "plugin:storage", "action": "WRITE"},
{"resource": "plugin:debug", "action": "EXECUTE"},
]
# [/DEF:INITIAL_PERMISSIONS:Constant]
# [DEF:seed_permissions:Function]
# @PURPOSE: Inserts missing permissions into the database.
# @POST: All INITIAL_PERMISSIONS exist in the DB.
def seed_permissions():
with belief_scope("seed_permissions"):
db = AuthSessionLocal()
try:
logger.info("Seeding permissions...")
count = 0
for perm_data in INITIAL_PERMISSIONS:
exists = db.query(Permission).filter(
Permission.resource == perm_data["resource"],
Permission.action == perm_data["action"]
).first()
if not exists:
new_perm = Permission(
resource=perm_data["resource"],
action=perm_data["action"]
)
db.add(new_perm)
count += 1
db.commit()
logger.info(f"Seeding completed. Added {count} new permissions.")
# Assign permissions to User role
repo = AuthRepository(db)
user_role = repo.get_role_by_name("User")
if not user_role:
user_role = Role(name="User", description="Standard user with plugin access")
db.add(user_role)
db.flush()
user_permissions = [
("plugin:mapper", "EXECUTE"),
("plugin:migration", "EXECUTE"),
("plugin:backup", "EXECUTE"),
("plugin:git", "EXECUTE"),
("plugin:storage", "READ"),
("plugin:storage", "WRITE"),
("environments", "READ"),
("plugins", "READ"),
("tasks", "READ"),
("tasks", "WRITE"),
]
for res, act in user_permissions:
perm = repo.get_permission_by_resource_action(res, act)
if perm and perm not in user_role.permissions:
user_role.permissions.append(perm)
db.commit()
logger.info("User role permissions updated.")
except Exception as e:
logger.error(f"Failed to seed permissions: {e}")
db.rollback()
finally:
db.close()
# [/DEF:seed_permissions:Function]
if __name__ == "__main__":
seed_permissions()
# [/DEF:backend.src.scripts.seed_permissions:Module]

View File

@@ -0,0 +1,115 @@
# [DEF:backend.src.services.auth_service:Module]
#
# @SEMANTICS: auth, service, business-logic, login, jwt
# @PURPOSE: Orchestrates authentication business logic.
# @LAYER: Service
# @RELATION: USES -> backend.src.core.auth.repository.AuthRepository
# @RELATION: USES -> backend.src.core.auth.security
# @RELATION: USES -> backend.src.core.auth.jwt
#
# @INVARIANT: Authentication must verify both credentials and account status.
# [SECTION: IMPORTS]
from typing import Optional, Dict, Any, List
from sqlalchemy.orm import Session
from ..models.auth import User, Role
from ..core.auth.repository import AuthRepository
from ..core.auth.security import verify_password, get_password_hash
from ..core.auth.jwt import create_access_token
from ..core.logger import belief_scope
# [/SECTION]
# [DEF:AuthService:Class]
# @PURPOSE: Provides high-level authentication services.
class AuthService:
# [DEF:__init__:Function]
# @PURPOSE: Initializes the service with a database session.
# @PARAM: db (Session) - SQLAlchemy session.
def __init__(self, db: Session):
self.repo = AuthRepository(db)
# [/DEF:__init__:Function]
# [DEF:authenticate_user:Function]
# @PURPOSE: Authenticates a user with username and password.
# @PRE: username and password are provided.
# @POST: Returns User object if authentication succeeds, else None.
# @SIDE_EFFECT: Updates last_login timestamp on success.
# @PARAM: username (str) - The username.
# @PARAM: password (str) - The plain password.
# @RETURN: Optional[User] - The authenticated user or None.
def authenticate_user(self, username: str, password: str):
with belief_scope("AuthService.authenticate_user"):
user = self.repo.get_user_by_username(username)
if not user:
return None
if not user.is_active:
return None
if not user.password_hash or not verify_password(password, user.password_hash):
return None
self.repo.update_last_login(user)
return user
# [/DEF:authenticate_user:Function]
# [DEF:create_session:Function]
# @PURPOSE: Creates a JWT session for an authenticated user.
# @PRE: user is a valid User object.
# @POST: Returns a dictionary with access_token and token_type.
# @PARAM: user (User) - The authenticated user.
# @RETURN: Dict[str, str] - Session data.
def create_session(self, user) -> Dict[str, str]:
with belief_scope("AuthService.create_session"):
# Collect role names for scopes
scopes = [role.name for role in user.roles]
token_data = {
"sub": user.username,
"scopes": scopes
}
access_token = create_access_token(data=token_data)
return {
"access_token": access_token,
"token_type": "bearer"
}
# [/DEF:create_session:Function]
# [DEF:provision_adfs_user:Function]
# @PURPOSE: Just-In-Time (JIT) provisioning for ADFS users based on group mappings.
# @PRE: user_info contains 'upn' (username), 'email', and 'groups'.
# @POST: User is created/updated and assigned roles based on groups.
# @PARAM: user_info (Dict[str, Any]) - Claims from ADFS token.
# @RETURN: User - The provisioned user.
def provision_adfs_user(self, user_info: Dict[str, Any]) -> User:
with belief_scope("AuthService.provision_adfs_user"):
username = user_info.get("upn") or user_info.get("email")
email = user_info.get("email")
ad_groups = user_info.get("groups", [])
user = self.repo.get_user_by_username(username)
if not user:
user = User(
username=username,
email=email,
auth_source="ADFS",
is_active=True
)
self.repo.db.add(user)
# Update roles based on group mappings
from ..models.auth import ADGroupMapping
mapped_roles = self.repo.db.query(Role).join(ADGroupMapping).filter(
ADGroupMapping.ad_group.in_(ad_groups)
).all()
user.roles = mapped_roles
self.repo.db.commit()
self.repo.db.refresh(user)
return user
# [/DEF:provision_adfs_user:Function]
# [/DEF:AuthService:Class]
# [/DEF:backend.src.services.auth_service:Module]

View File

@@ -10,9 +10,9 @@
# [SECTION: IMPORTS]
from typing import List, Dict
from backend.src.core.logger import belief_scope
from backend.src.core.superset_client import SupersetClient
from backend.src.core.utils.matching import suggest_mappings
from ..core.logger import belief_scope
from ..core.superset_client import SupersetClient
from ..core.utils.matching import suggest_mappings
# [/SECTION]
# [DEF:MappingService:Class]

Binary file not shown.

162
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,162 @@
import sys
import os
from pathlib import Path
# Add src to path
sys.path.append(str(Path(__file__).parent.parent / "src"))
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from src.core.database import Base, get_auth_db
from src.models.auth import User, Role, Permission, ADGroupMapping
from src.services.auth_service import AuthService
from src.core.auth.repository import AuthRepository
from src.core.auth.security import verify_password, get_password_hash
# Create in-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Create all tables
Base.metadata.create_all(bind=engine)
@pytest.fixture
def db_session():
"""Create a new database session with a transaction, rollback after test"""
connection = engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def auth_service(db_session):
return AuthService(db_session)
@pytest.fixture
def auth_repo(db_session):
return AuthRepository(db_session)
def test_create_user(auth_repo):
"""Test user creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("testuser")
assert retrieved_user is not None
assert retrieved_user.username == "testuser"
assert retrieved_user.email == "test@example.com"
assert verify_password("testpassword123", retrieved_user.password_hash)
def test_authenticate_user(auth_service, auth_repo):
"""Test user authentication with valid and invalid credentials"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
# Test valid credentials
authenticated_user = auth_service.authenticate_user("testuser", "testpassword123")
assert authenticated_user is not None
assert authenticated_user.username == "testuser"
# Test invalid password
invalid_user = auth_service.authenticate_user("testuser", "wrongpassword")
assert invalid_user is None
# Test invalid username
invalid_user = auth_service.authenticate_user("nonexistent", "testpassword123")
assert invalid_user is None
def test_create_session(auth_service, auth_repo):
"""Test session token creation"""
user = User(
username="testuser",
email="test@example.com",
password_hash=get_password_hash("testpassword123"),
auth_source="LOCAL"
)
auth_repo.db.add(user)
auth_repo.db.commit()
session = auth_service.create_session(user)
assert "access_token" in session
assert "token_type" in session
assert session["token_type"] == "bearer"
assert len(session["access_token"]) > 0
def test_role_permission_association(auth_repo):
"""Test role and permission association"""
role = Role(name="Admin", description="System administrator")
perm1 = Permission(resource="admin:users", action="READ")
perm2 = Permission(resource="admin:users", action="WRITE")
role.permissions.extend([perm1, perm2])
auth_repo.db.add(role)
auth_repo.db.commit()
retrieved_role = auth_repo.get_role_by_name("Admin")
assert retrieved_role is not None
assert len(retrieved_role.permissions) == 2
permissions = [f"{p.resource}:{p.action}" for p in retrieved_role.permissions]
assert "admin:users:READ" in permissions
assert "admin:users:WRITE" in permissions
def test_user_role_association(auth_repo):
"""Test user and role association"""
role = Role(name="Admin", description="System administrator")
user = User(
username="adminuser",
email="admin@example.com",
password_hash=get_password_hash("adminpass123"),
auth_source="LOCAL"
)
user.roles.append(role)
auth_repo.db.add(role)
auth_repo.db.add(user)
auth_repo.db.commit()
retrieved_user = auth_repo.get_user_by_username("adminuser")
assert retrieved_user is not None
assert len(retrieved_user.roles) == 1
assert retrieved_user.roles[0].name == "Admin"
def test_ad_group_mapping(auth_repo):
"""Test AD group mapping"""
role = Role(name="ADFS_Admin", description="ADFS administrators")
auth_repo.db.add(role)
auth_repo.db.commit()
mapping = ADGroupMapping(ad_group="DOMAIN\\ADFS_Admins", role_id=role.id)
auth_repo.db.add(mapping)
auth_repo.db.commit()
retrieved_mapping = auth_repo.db.query(ADGroupMapping).filter_by(ad_group="DOMAIN\\ADFS_Admins").first()
assert retrieved_mapping is not None
assert retrieved_mapping.role_id == role.id

View File

@@ -57,4 +57,4 @@
/* Component specific styles */
</style>
<!-- [/DEF:EnvSelector:Component] -->
<!-- [/DEF:EnvSelector:Component] -->

View File

@@ -9,6 +9,13 @@
import { page } from '$app/stores';
import { t } from '$lib/i18n';
import { LanguageSwitcher } from '$lib/ui';
import { auth } from '../lib/auth/store';
import { goto } from '$app/navigation';
function handleLogout() {
auth.logout();
goto('/login');
}
</script>
<header class="bg-white shadow-md p-4 flex justify-between items-center">
@@ -25,35 +32,12 @@
>
{$t.nav.dashboard}
</a>
<a
href="/migration"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/migration') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
{$t.nav.migration}
</a>
<a
href="/git"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/git') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
{$t.nav.git}
</a>
<a
href="/tasks"
class="text-gray-600 hover:text-blue-600 font-medium {$page.url.pathname.startsWith('/tasks') ? 'text-blue-600 border-b-2 border-blue-600' : ''}"
>
{$t.nav.tasks}
</a>
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/tools') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.tools}
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/tools/search" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_search}</a>
<a href="/tools/mapper" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_mapper}</a>
<a href="/tools/debug" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_debug}</a>
<a href="/tools/storage" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.tools_storage}</a>
</div>
</div>
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/settings') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.settings}
@@ -62,10 +46,34 @@
<a href="/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_general}</a>
<a href="/settings/connections" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_connections}</a>
<a href="/settings/git" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_git}</a>
<a href="/settings/environments" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.settings_environments}</a>
</div>
</div>
{#if $auth.isAuthenticated && $auth.user?.roles?.some(r => r.name === 'Admin')}
<div class="relative inline-block group">
<button class="text-gray-600 hover:text-blue-600 font-medium pb-1 {$page.url.pathname.startsWith('/admin') ? 'text-blue-600 border-b-2 border-blue-600' : ''}">
{$t.nav.admin}
</button>
<div class="absolute hidden group-hover:block bg-white shadow-lg rounded-md mt-1 py-2 w-48 z-10 border border-gray-100 before:absolute before:-top-2 before:left-0 before:right-0 before:h-2 before:content-[''] right-0">
<a href="/admin/users" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_users}</a>
<a href="/admin/roles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_roles}</a>
<a href="/admin/settings" class="block px-4 py-2 text-sm text-gray-700 hover:bg-blue-50 hover:text-blue-600">{$t.nav.admin_settings}</a>
</div>
</div>
{/if}
<LanguageSwitcher />
{#if $auth.isAuthenticated}
<div class="flex items-center space-x-2 border-l pl-4 ml-4">
<span class="text-sm text-gray-600">{$auth.user?.username}</span>
<button
on:click={handleLogout}
class="text-sm text-red-600 hover:text-red-800 font-medium"
>
Logout
</button>
</div>
{/if}
</nav>
</header>
<!-- [/DEF:Navbar:Component] -->

View File

@@ -21,7 +21,9 @@
// @POST: tasks array is updated and selectedTask status synchronized.
async function fetchTasks() {
try {
const res = await fetch('/api/tasks?limit=10');
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch('/api/tasks?limit=10', { headers });
if (!res.ok) throw new Error('Failed to fetch tasks');
tasks = await res.json();
@@ -58,7 +60,9 @@
const params = new URLSearchParams();
if (status) params.append('status', status);
const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE' });
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE', headers });
if (!res.ok) throw new Error('Failed to clear tasks');
await fetchTasks();
@@ -75,7 +79,9 @@
async function selectTask(task) {
try {
// Fetch the full task details (including logs) before setting it as selected
const res = await fetch(`/api/tasks/${task.id}`);
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const res = await fetch(`/api/tasks/${task.id}`, { headers });
if (res.ok) {
const fullTask = await res.json();
selectedTask.set(fullTask);

View File

@@ -0,0 +1,61 @@
<!-- [DEF:ProtectedRoute:Component] -->
<!--
@SEMANTICS: auth, guard, route, protection
@PURPOSE: Wraps content to ensure only authenticated users can access it.
@LAYER: Component
@RELATION: USES -> authStore
@RELATION: CALLS -> goto
@INVARIANT: Redirects to /login if user is not authenticated.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '../../lib/auth/store';
import { goto } from '$app/navigation';
// [SECTION: TEMPLATE]
// Only render slot if authenticated
// [/SECTION: TEMPLATE]
onMount(async () => {
// Check if we have a token but no user profile yet
if ($auth.token && !$auth.user) {
auth.setLoading(true);
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${$auth.token}`
}
});
if (response.ok) {
const user = await response.json();
auth.setUser(user);
} else {
// Token invalid or expired
auth.logout();
goto('/login');
}
} catch (e) {
console.error('Failed to verify session:', e);
auth.logout();
goto('/login');
} finally {
auth.setLoading(false);
}
} else if (!$auth.token) {
goto('/login');
}
});
</script>
{#if $auth.loading}
<div class="flex items-center justify-center min-h-screen">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
{:else if $auth.isAuthenticated}
<slot />
{/if}
<!-- [/DEF:ProtectedRoute:Component] -->

View File

@@ -0,0 +1,84 @@
<!-- [DEF:BackupList:Component] -->
<!--
@SEMANTICS: backup, list, table
@PURPOSE: Displays a list of existing backups.
@LAYER: Component
@RELATION: USED_BY -> frontend/src/components/backups/BackupManager.svelte
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { t } from '../../lib/i18n';
import { Button } from '../../lib/ui';
import type { Backup } from '../../types/backup';
import { goto } from '$app/navigation';
// [/SECTION]
// [SECTION: PROPS]
/**
* @type {Backup[]}
* @description Array of backup objects to display.
*/
export let backups: Backup[] = [];
// [/SECTION]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.storage.table.name}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.tasks.target_env}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.storage.table.created_at}
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{$t.storage.table.actions}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each backups as backup}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{backup.name}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{backup.environment}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(backup.created_at).toLocaleString()}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Button
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-900"
on:click={() => goto(`/tools/storage?path=backups/${backup.name}`)}
>
{$t.storage.table.go_to_storage}
</Button>
</td>
</tr>
{:else}
<tr>
<td colspan="4" class="px-6 py-10 text-center text-gray-500">
{$t.storage.no_files}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- [/SECTION] -->
<style>
</style>
<!-- [/DEF:BackupList:Component] -->

View File

@@ -0,0 +1,241 @@
<!-- [DEF:BackupManager:Component] -->
<!--
@SEMANTICS: backup, manager, orchestrator
@PURPOSE: Main container for backup management, handling creation and listing.
@LAYER: Feature
@RELATION: USES -> BackupList
@RELATION: USES -> api
@INVARIANT: Only one backup task can be triggered at a time from the UI.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '../../lib/i18n';
import { api, requestApi } from '../../lib/api';
import { addToast } from '../../lib/toasts';
import { Button, Card, Select, Input } from '../../lib/ui';
import BackupList from './BackupList.svelte';
import type { Backup } from '../../types/backup';
// [/SECTION]
// [SECTION: STATE]
let backups: Backup[] = [];
let environments: any[] = [];
let selectedEnvId = '';
let loading = true;
let creating = false;
let savingSchedule = false;
// Schedule state for selected environment
let scheduleEnabled = false;
let cronExpression = '0 0 * * *';
$: selectedEnv = environments.find(e => e.id === selectedEnvId);
$: if (selectedEnv) {
scheduleEnabled = selectedEnv.backup_schedule?.enabled ?? false;
cronExpression = selectedEnv.backup_schedule?.cron_expression ?? '0 0 * * *';
}
// [/SECTION]
// [DEF:loadData:Function]
/**
* @purpose Loads backups and environments from the backend.
*
* @pre API must be reachable.
* @post environments and backups stores are populated.
*
* @returns {Promise<void>}
* @side_effect Updates local state variables.
*/
// @RELATION: CALLS -> api.getEnvironmentsList
// @RELATION: CALLS -> api.requestApi
async function loadData() {
console.log("[BackupManager][Entry] Loading data.");
loading = true;
try {
const [envsData, storageData] = await Promise.all([
api.getEnvironmentsList(),
requestApi('/storage/files?category=backups')
]);
environments = envsData;
// Map storage files to Backup type
backups = (storageData || []).map((file: any) => ({
id: file.name,
name: file.name,
environment: file.path.split('/')[0] || 'Unknown',
created_at: file.created_at,
size_bytes: file.size,
status: 'success'
}));
console.log("[BackupManager][Action] Data loaded successfully.");
} catch (error) {
console.error("[BackupManager][Coherence:Failed] Load failed", error);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:handleCreateBackup:Function]
/**
* @purpose Triggers a new backup task for the selected environment.
*
* @pre selectedEnvId must be a valid environment ID.
* @post A new task is created on the backend.
*
* @returns {Promise<void>}
* @side_effect Dispatches a toast notification.
*/
// @RELATION: CALLS -> api.createTask
// [DEF:handleUpdateSchedule:Function]
/**
* @purpose Updates the backup schedule for the selected environment.
* @pre selectedEnvId must be set.
* @post Environment config is updated on the backend.
*/
async function handleUpdateSchedule() {
if (!selectedEnvId) return;
console.log(`[BackupManager][Action] Updating schedule for env: ${selectedEnvId}`);
savingSchedule = true;
try {
await api.updateEnvironmentSchedule(selectedEnvId, {
enabled: scheduleEnabled,
cron_expression: cronExpression
});
addToast($t.common.success, 'success');
// Update local state
environments = environments.map(e =>
e.id === selectedEnvId
? { ...e, backup_schedule: { enabled: scheduleEnabled, cron_expression: cronExpression } }
: e
);
} catch (error) {
console.error("[BackupManager][Coherence:Failed] Schedule update failed", error);
} finally {
savingSchedule = false;
}
}
// [/DEF:handleUpdateSchedule:Function]
async function handleCreateBackup() {
if (!selectedEnvId) {
addToast($t.tasks.select_env, 'error');
return;
}
console.log(`[BackupManager][Action] Triggering backup for env: ${selectedEnvId}`);
creating = true;
try {
await api.createTask('superset-backup', { environment_id: selectedEnvId });
addToast($t.common.success, 'success');
console.log("[BackupManager][Coherence:OK] Backup task triggered.");
} catch (error) {
console.error("[BackupManager][Coherence:Failed] Create failed", error);
} finally {
creating = false;
}
}
// [/DEF:handleCreateBackup:Function]
onMount(loadData);
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-6">
<Card title={$t.tasks.manual_backup}>
<div class="space-y-4">
<div class="flex items-end gap-4">
<div class="flex-1">
<Select
label={$t.tasks.target_env}
bind:value={selectedEnvId}
options={[
{ value: '', label: $t.tasks.select_env },
...environments.map(e => ({ value: e.id, label: e.name }))
]}
/>
</div>
<Button
variant="primary"
on:click={handleCreateBackup}
disabled={creating || !selectedEnvId}
>
{creating ? $t.common.loading : $t.tasks.start_backup}
</Button>
</div>
{#if selectedEnvId}
<div class="pt-6 border-t border-gray-100 mt-4">
<h3 class="text-sm font-semibold text-gray-800 mb-4 flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{$t.tasks.backup_schedule}
</h3>
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
<div class="flex flex-col md:flex-row md:items-start gap-6">
<div class="pt-8">
<label class="flex items-center gap-3 cursor-pointer group">
<div class="relative inline-flex items-center">
<input type="checkbox" bind:checked={scheduleEnabled} class="sr-only peer" />
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-gray-900 transition-colors">{$t.tasks.schedule_enabled}</span>
</div>
</label>
</div>
<div class="flex-1 space-y-2">
<Input
label={$t.tasks.cron_label}
placeholder="0 0 * * *"
bind:value={cronExpression}
disabled={!scheduleEnabled}
/>
<p class="text-xs text-gray-500 italic">{$t.tasks.cron_hint}</p>
</div>
<div class="pt-8">
<Button
variant="secondary"
on:click={handleUpdateSchedule}
disabled={savingSchedule}
class="min-w-[100px]"
>
{#if savingSchedule}
<span class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{$t.common.loading}
</span>
{:else}
{$t.common.save}
{/if}
</Button>
</div>
</div>
</div>
</div>
{/if}
</div>
</Card>
<div class="space-y-3">
<h2 class="text-lg font-semibold text-gray-700">{$t.storage.backups}</h2>
{#if loading}
<div class="py-10 text-center text-gray-500">{$t.common.loading}</div>
{:else}
<BackupList {backups} />
{/if}
</div>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BackupManager:Component] -->

View File

@@ -1,8 +1,9 @@
<!-- [DEF:FileList:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: storage, files, list, table
@PURPOSE: Displays a table of files with metadata and actions.
@LAYER: Component
@LAYER: UI
@RELATION: DEPENDS_ON -> storageService
@PROPS: files (Array) - List of StoredFile objects.
@@ -22,10 +23,13 @@
// [DEF:isDirectory:Function]
/**
* @purpose Checks if a file object represents a directory.
* @pre file object has mime_type property.
* @post Returns boolean.
* @param {Object} file - The file object to check.
* @return {boolean} True if it's a directory, false otherwise.
*/
function isDirectory(file) {
console.log("[isDirectory][Action] Checking file type");
return file.mime_type === 'directory';
}
// [/DEF:isDirectory:Function]
@@ -33,10 +37,13 @@
// [DEF:formatSize:Function]
/**
* @purpose Formats file size in bytes into a human-readable string.
* @pre bytes is a number.
* @post Returns formatted string.
* @param {number} bytes - The size in bytes.
* @return {string} Formatted size (e.g., "1.2 MB").
*/
function formatSize(bytes) {
console.log(`[formatSize][Action] Formatting ${bytes} bytes`);
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
@@ -48,10 +55,13 @@
// [DEF:formatDate:Function]
/**
* @purpose Formats an ISO date string into a localized readable format.
* @pre dateStr is a valid date string.
* @post Returns localized string.
* @param {string} dateStr - The date string to format.
* @return {string} Localized date and time.
*/
function formatDate(dateStr) {
console.log("[formatDate][Action] Formatting date string");
return new Date(dateStr).toLocaleString();
}
// [/DEF:formatDate:Function]

View File

@@ -1,8 +1,9 @@
<!-- [DEF:FileUpload:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: storage, upload, files
@PURPOSE: Provides a form for uploading files to a specific category.
@LAYER: Component
@LAYER: UI
@RELATION: DEPENDS_ON -> storageService
@PROPS: None

View File

@@ -8,6 +8,7 @@
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { api } from '../../lib/api.js';
import { runTask, getTaskStatus } from '../../services/toolsService.js';
import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js';
@@ -32,8 +33,7 @@
*/
async function fetchEnvironments() {
try {
const res = await fetch('/api/environments');
envs = await res.json();
envs = await api.getEnvironmentsList();
} catch (e) {
addToast('Failed to fetch environments', 'error');
}

View File

@@ -13,6 +13,8 @@
import { getConnections } from '../../services/connectionService.js';
import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js';
import { t } from '../../lib/i18n';
import { Button, Card, Select, Input } from '../../lib/ui';
// [/SECTION]
let envs = [];
@@ -36,7 +38,7 @@
envs = await envsRes.json();
connections = await getConnections();
} catch (e) {
addToast('Failed to fetch data', 'error');
addToast($t.mapper.errors.fetch_failed, 'error');
}
}
// [/DEF:fetchData:Function]
@@ -47,17 +49,17 @@
// @POST: Mapper task is started and selectedTask is updated.
async function handleRunMapper() {
if (!selectedEnv || !datasetId) {
addToast('Please fill in required fields', 'warning');
addToast($t.mapper.errors.required_fields, 'warning');
return;
}
if (source === 'postgres' && (!selectedConnection || !tableName)) {
addToast('Connection and Table Name are required for postgres source', 'warning');
addToast($t.mapper.errors.postgres_required, 'warning');
return;
}
if (source === 'excel' && !excelPath) {
addToast('Excel path is required for excel source', 'warning');
addToast($t.mapper.errors.excel_required, 'warning');
return;
}
@@ -75,7 +77,7 @@
});
selectedTask.set(task);
addToast('Mapper task started', 'success');
addToast($t.mapper.success.started, 'success');
} catch (e) {
addToast(e.message, 'error');
} finally {
@@ -88,78 +90,94 @@
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4">Dataset Column Mapper</h3>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="mapper-env" class="block text-sm font-medium text-gray-700">Environment</label>
<select id="mapper-env" bind:value={selectedEnv} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="" disabled>-- Select Environment --</option>
{#each envs as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div>
<label for="mapper-ds-id" class="block text-sm font-medium text-gray-700">Dataset ID</label>
<input type="number" id="mapper-ds-id" bind:value={datasetId} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Mapping Source</label>
<div class="mt-2 flex space-x-4">
<label class="inline-flex items-center">
<input type="radio" bind:group={source} value="postgres" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">PostgreSQL</span>
</label>
<label class="inline-flex items-center">
<input type="radio" bind:group={source} value="excel" class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">Excel</span>
</label>
</div>
</div>
{#if source === 'postgres'}
<div class="space-y-4 p-4 bg-gray-50 rounded-md border border-gray-100">
<div class="space-y-6">
<Card title={$t.mapper.title}>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="mapper-conn" class="block text-sm font-medium text-gray-700">Saved Connection</label>
<select id="mapper-conn" bind:value={selectedConnection} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<option value="" disabled>-- Select Connection --</option>
{#each connections as conn}
<option value={conn.id}>{conn.name}</option>
{/each}
</select>
<Select
label={$t.mapper.environment}
bind:value={selectedEnv}
options={[
{ value: '', label: $t.mapper.select_env },
...envs.map(e => ({ value: e.id, label: e.name }))
]}
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="mapper-table" class="block text-sm font-medium text-gray-700">Table Name</label>
<input type="text" id="mapper-table" bind:value={tableName} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
<div>
<label for="mapper-schema" class="block text-sm font-medium text-gray-700">Table Schema</label>
<input type="text" id="mapper-schema" bind:value={tableSchema} class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
<div>
<Input
label={$t.mapper.dataset_id}
type="number"
bind:value={datasetId}
/>
</div>
</div>
{:else}
<div class="p-4 bg-gray-50 rounded-md border border-gray-100">
<label for="mapper-excel" class="block text-sm font-medium text-gray-700">Excel File Path</label>
<input type="text" id="mapper-excel" bind:value={excelPath} placeholder="/path/to/mapping.xlsx" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" />
</div>
{/if}
<div class="flex justify-end">
<button
on:click={handleRunMapper}
disabled={isRunning}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{isRunning ? 'Starting...' : 'Run Mapper'}
</button>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">{$t.mapper.source}</label>
<div class="flex space-x-4">
<label class="inline-flex items-center">
<input type="radio" bind:group={source} value="postgres" class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">{$t.mapper.source_postgres}</span>
</label>
<label class="inline-flex items-center">
<input type="radio" bind:group={source} value="excel" class="focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300" />
<span class="ml-2 text-sm text-gray-700">{$t.mapper.source_excel}</span>
</label>
</div>
</div>
{#if source === 'postgres'}
<div class="space-y-4 p-4 bg-gray-50 rounded-md border border-gray-100">
<div>
<Select
label={$t.mapper.connection}
bind:value={selectedConnection}
options={[
{ value: '', label: $t.mapper.select_connection },
...connections.map(c => ({ value: c.id, label: c.name }))
]}
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
label={$t.mapper.table_name}
type="text"
bind:value={tableName}
/>
</div>
<div>
<Input
label={$t.mapper.table_schema}
type="text"
bind:value={tableSchema}
/>
</div>
</div>
</div>
{:else}
<div class="p-4 bg-gray-50 rounded-md border border-gray-100">
<Input
label={$t.mapper.excel_path}
type="text"
bind:value={excelPath}
placeholder="/path/to/mapping.xlsx"
/>
</div>
{/if}
<div class="flex justify-end pt-2">
<Button
variant="primary"
on:click={handleRunMapper}
disabled={isRunning}
>
{isRunning ? $t.mapper.starting : $t.mapper.run}
</Button>
</div>
</div>
</div>
</Card>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:MapperTool:Component] -->

View File

@@ -1,186 +0,0 @@
<!-- [DEF:SearchTool:Component] -->
<!--
@SEMANTICS: search, tool, dataset, regex
@PURPOSE: UI component for searching datasets using the SearchPlugin.
@LAYER: UI
@RELATION: USES -> frontend/src/services/toolsService.js
-->
<script>
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { runTask, getTaskStatus } from '../../services/toolsService.js';
import { selectedTask } from '../../lib/stores.js';
import { addToast } from '../../lib/toasts.js';
// [/SECTION]
let envs = [];
let selectedEnv = '';
let searchQuery = '';
let isRunning = false;
let results = null;
let pollInterval;
// [DEF:fetchEnvironments:Function]
// @PURPOSE: Fetches the list of available environments.
// @PRE: None.
// @POST: envs array is populated.
async function fetchEnvironments() {
try {
const res = await fetch('/api/environments');
envs = await res.json();
} catch (e) {
addToast('Failed to fetch environments', 'error');
}
}
// [/DEF:fetchEnvironments:Function]
// [DEF:handleSearch:Function]
// @PURPOSE: Triggers the SearchPlugin task.
// @PRE: selectedEnv and searchQuery must be set.
// @POST: Task is started and polling begins.
async function handleSearch() {
if (!selectedEnv || !searchQuery) {
addToast('Please select environment and enter query', 'warning');
return;
}
isRunning = true;
results = null;
try {
// Find the environment name from ID
const env = envs.find(e => e.id === selectedEnv);
const task = await runTask('search-datasets', {
env: env.name,
query: searchQuery
});
selectedTask.set(task);
startPolling(task.id);
} catch (e) {
isRunning = false;
addToast(e.message, 'error');
}
}
// [/DEF:handleSearch:Function]
// [DEF:startPolling:Function]
// @PURPOSE: Polls for task completion and results.
// @PRE: taskId is provided.
// @POST: pollInterval is set and results are updated on success.
function startPolling(taskId) {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(async () => {
try {
const task = await getTaskStatus(taskId);
selectedTask.set(task);
if (task.status === 'SUCCESS') {
clearInterval(pollInterval);
isRunning = false;
results = task.result;
addToast('Search completed', 'success');
} else if (task.status === 'FAILED') {
clearInterval(pollInterval);
isRunning = false;
addToast('Search failed', 'error');
}
} catch (e) {
clearInterval(pollInterval);
isRunning = false;
addToast('Error polling task status', 'error');
}
}, 2000);
}
// [/DEF:startPolling:Function]
onMount(fetchEnvironments);
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="space-y-6">
<div class="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
<h3 class="text-lg font-medium text-gray-900 mb-4">Search Dataset Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-end">
<div>
<label for="env-select" class="block text-sm font-medium text-gray-700">Environment</label>
<select
id="env-select"
bind:value={selectedEnv}
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
>
<option value="" disabled>-- Select Environment --</option>
{#each envs as env}
<option value={env.id}>{env.name}</option>
{/each}
</select>
</div>
<div>
<label for="search-query" class="block text-sm font-medium text-gray-700">Regex Pattern</label>
<input
type="text"
id="search-query"
bind:value={searchQuery}
placeholder="e.g. from dm.*\.account"
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
</div>
<div class="mt-4 flex justify-end">
<button
on:click={handleSearch}
disabled={isRunning}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{#if isRunning}
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Searching...
{:else}
Search
{/if}
</button>
</div>
</div>
{#if results}
<div class="bg-white shadow overflow-hidden sm:rounded-md border border-gray-200">
<div class="px-4 py-5 sm:px-6 flex justify-between items-center bg-gray-50 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Search Results
</h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{results.count} matches
</span>
</div>
<ul class="divide-y divide-gray-200">
{#each results.results as item}
<li class="p-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<div class="text-sm font-medium text-indigo-600 truncate">
{item.dataset_name} (ID: {item.dataset_id})
</div>
<div class="ml-2 flex-shrink-0 flex">
<p class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Field: {item.field}
</p>
</div>
</div>
<div class="mt-2">
<pre class="text-xs text-gray-500 bg-gray-50 p-2 rounded border border-gray-100 overflow-x-auto">{item.match_context}</pre>
</div>
</li>
{/each}
{#if results.count === 0}
<li class="p-8 text-center text-gray-500 italic">
No matches found for the given pattern.
</li>
{/if}
</ul>
</div>
{/if}
</div>
<!-- [/SECTION] -->
<!-- [/DEF:SearchTool:Component] -->

View File

@@ -25,6 +25,22 @@ export const getWsUrl = (taskId) => {
};
// [/DEF:getWsUrl:Function]
// [DEF:getAuthHeaders:Function]
// @PURPOSE: Returns headers with Authorization if token exists.
function getAuthHeaders() {
const headers = {
'Content-Type': 'application/json',
};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
// [/DEF:getAuthHeaders:Function]
// [DEF:fetchApi:Function]
// @PURPOSE: Generic GET request wrapper.
// @PRE: endpoint string is provided.
@@ -34,10 +50,18 @@ export const getWsUrl = (taskId) => {
async function fetchApi(endpoint) {
try {
console.log(`[api.fetchApi][Action] Fetching from context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: getAuthHeaders()
});
console.log(`[api.fetchApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.fetchApi][Coherence:Failed] Error fetching from ${endpoint}:`, error);
@@ -59,14 +83,18 @@ async function postApi(endpoint, body) {
console.log(`[api.postApi][Action] Posting to context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: getAuthHeaders(),
body: JSON.stringify(body),
});
console.log(`[api.postApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
const errorData = await response.json().catch(() => ({}));
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
throw new Error(message);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.postApi][Coherence:Failed] Error posting to ${endpoint}:`, error);
@@ -85,17 +113,24 @@ async function requestApi(endpoint, method = 'GET', body = null) {
console.log(`[api.requestApi][Action] ${method} to context={{'endpoint': '${endpoint}'}}`);
const options = {
method,
headers: {
'Content-Type': 'application/json',
},
headers: getAuthHeaders(),
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
console.log(`[api.requestApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `API request failed with status ${response.status}`);
const message = errorData.detail
? (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail))
: `API request failed with status ${response.status}`;
console.error(`[api.requestApi][Action] Request failed context={{'status': ${response.status}, 'message': '${message}'}}`);
throw new Error(message);
}
if (response.status === 204) {
console.log('[api.requestApi][Action] 204 No Content received');
return null;
}
return await response.json();
} catch (error) {
@@ -109,6 +144,9 @@ async function requestApi(endpoint, method = 'GET', body = null) {
// [DEF:api:Data]
// @PURPOSE: API client object with specific methods.
export const api = {
fetchApi,
postApi,
requestApi,
getPlugins: () => fetchApi('/plugins'),
getTasks: () => fetchApi('/tasks'),
getTask: (taskId) => fetchApi(`/tasks/${taskId}`),
@@ -132,6 +170,7 @@ export const api = {
// [/DEF:api_module:Module]
// Export individual functions for easier use in components
export { requestApi };
export const getPlugins = api.getPlugins;
export const getTasks = api.getTasks;
export const getTask = api.getTask;

View File

@@ -3,16 +3,28 @@
import Navbar from '../components/Navbar.svelte';
import Footer from '../components/Footer.svelte';
import Toast from '../components/Toast.svelte';
import ProtectedRoute from '../components/auth/ProtectedRoute.svelte';
import { page } from '$app/stores';
$: isLoginPage = $page.url.pathname === '/login';
</script>
<Toast />
<main class="bg-gray-50 min-h-screen flex flex-col">
<Navbar />
{#if isLoginPage}
<div class="p-4 flex-grow">
<slot />
</div>
{:else}
<ProtectedRoute>
<Navbar />
<div class="p-4 flex-grow">
<slot />
</div>
<div class="p-4 flex-grow">
<slot />
</div>
<Footer />
<Footer />
</ProtectedRoute>
{/if}
</main>

View File

@@ -23,10 +23,8 @@
*/
function selectPlugin(plugin) {
console.log(`[Dashboard][Action] Selecting plugin: ${plugin.id}`);
if (plugin.id === 'superset-migration') {
goto('/migration');
} else if (plugin.id === 'git-integration') {
goto('/git');
if (plugin.ui_route) {
goto(plugin.ui_route);
} else {
selectedPlugin.set(plugin);
}
@@ -82,7 +80,7 @@
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each data.plugins as plugin}
{#each data.plugins.filter(p => p.id !== 'superset-search') as plugin}
<div
on:click={() => selectPlugin(plugin)}
role="button"

View File

@@ -0,0 +1,236 @@
<!-- [DEF:AdminRolesPage:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: admin, role-management, rbac
@PURPOSE: UI for managing system roles and their permissions.
@LAYER: Domain
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with Admin role.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let roles = [];
let permissions = [];
let loading = true;
let error = null;
let showModal = false;
let isEditing = false;
let currentRoleId = null;
let roleForm = {
name: '',
description: '',
permissions: []
};
// [DEF:loadData:Function]
/**
* @purpose Fetches roles and available permissions.
* @pre Component mounted.
* @post roles and permissions arrays populated.
*/
async function loadData() {
console.log('[AdminRolesPage][loadData][Entry]');
loading = true;
try {
[roles, permissions] = await Promise.all([
adminService.getRoles(),
adminService.getPermissions()
]);
console.log('[AdminRolesPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load roles data.";
console.error('[AdminRolesPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:openCreateModal:Function]
/**
* @purpose Initializes state for creating a new role.
* @pre None.
* @post showModal is true, roleForm is reset.
*/
function openCreateModal() {
console.log("[openCreateModal][Action] Opening create modal");
isEditing = false;
currentRoleId = null;
roleForm = { name: '', description: '', permissions: [] };
showModal = true;
}
// [/DEF:openCreateModal:Function]
// [DEF:openEditModal:Function]
/**
* @purpose Initializes state for editing an existing role.
* @pre role object is provided.
* @post showModal is true, roleForm is populated.
*/
function openEditModal(role) {
console.log(`[openEditModal][Action] Opening edit modal for role ${role.id}`);
isEditing = true;
currentRoleId = role.id;
roleForm = {
name: role.name,
description: role.description || '',
permissions: role.permissions.map(p => p.id)
};
showModal = true;
}
// [/DEF:openEditModal:Function]
// [DEF:handleSaveRole:Function]
/**
* @purpose Submits role data (create or update).
* @pre roleForm contains valid data.
* @post Role is saved, modal closed, data reloaded.
*/
async function handleSaveRole() {
console.log('[AdminRolesPage][handleSaveRole][Entry]');
try {
if (isEditing) {
await adminService.updateRole(currentRoleId, roleForm);
} else {
await adminService.createRole(roleForm);
}
showModal = false;
await loadData();
console.log('[AdminRolesPage][handleSaveRole][Coherence:OK]');
} catch (e) {
alert("Failed to save role: " + e.message);
console.error('[AdminRolesPage][handleSaveRole][Coherence:Failed]', e);
}
}
// [/DEF:handleSaveRole:Function]
// [DEF:handleDeleteRole:Function]
/**
* @purpose Deletes a role after confirmation.
* @pre role object is provided.
* @post Role is deleted if confirmed, data reloaded.
*/
async function handleDeleteRole(role) {
if (!confirm($t.admin.roles.confirm_delete.replace('{name}', role.name))) return;
console.log('[AdminRolesPage][handleDeleteRole][Entry]');
try {
await adminService.deleteRole(role.id);
await loadData();
console.log('[AdminRolesPage][handleDeleteRole][Coherence:OK]');
} catch (e) {
alert("Failed to delete role: " + e.message);
console.error('[AdminRolesPage][handleDeleteRole][Coherence:Failed]', e);
}
}
// [/DEF:handleDeleteRole:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:roles">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.roles.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={openCreateModal}
>
{$t.admin.roles.create}
</button>
</div>
{#if loading}
<p>{$t.admin.roles.loading}</p>
{:else if error}
<div class="bg-red-100 text-red-700 p-4 rounded">{error}</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.name}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.description}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.roles.permissions}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each roles as role}
<tr>
<td class="px-6 py-4 whitespace-nowrap font-medium">{role.name}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{role.description || '-'}</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{#each role.permissions as perm}
<span class="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded border border-blue-100">
{perm.resource}:{perm.action}
</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(role)} class="text-blue-600 hover:text-blue-900 mr-3">{$t.common.edit}</button>
<button on:click={() => handleDeleteRole(role)} class="text-red-600 hover:text-red-900">{$t.common.delete}</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<h2 class="text-xl font-bold mb-4">
{isEditing ? $t.admin.roles.modal_edit_title : $t.admin.roles.modal_create_title}
</h2>
<form on:submit|preventDefault={handleSaveRole}>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">{$t.admin.roles.name}</label>
<input type="text" bind:value={roleForm.name} class="w-full border p-2 rounded" required readonly={isEditing} />
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1">{$t.admin.roles.description}</label>
<textarea bind:value={roleForm.description} class="w-full border p-2 rounded h-20"></textarea>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-2">{$t.admin.roles.permissions}</label>
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 border p-3 rounded bg-gray-50">
{#each permissions as perm}
<label class="flex items-center space-x-2 p-1 hover:bg-white rounded cursor-pointer">
<input type="checkbox" value={perm.id} bind:group={roleForm.permissions} class="rounded text-blue-600" />
<span class="text-xs">{perm.resource}:{perm.action}</span>
</label>
{/each}
</div>
<p class="text-xs text-gray-500 mt-2">{$t.admin.roles.permissions_hint}</p>
</div>
<div class="flex justify-end gap-2 pt-4 border-t">
<button type="button" class="px-4 py-2 text-gray-600" on:click={() => showModal = false}>{$t.common.cancel}</button>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">{$t.common.save}</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminRolesPage:Component] -->

View File

@@ -0,0 +1,213 @@
<!-- [DEF:AdminSettingsPage:Component] -->
<!--
@SEMANTICS: admin, adfs, mappings, configuration
@PURPOSE: UI for configuring Active Directory Group to local Role mappings for ADFS SSO.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with "admin:settings" permission.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let mappings = [];
let roles = [];
let loading = true;
let error = null;
let showCreateModal = false;
let newMapping = {
ad_group: '',
role_id: ''
};
// [DEF:loadData:Function]
/**
* @purpose Fetches AD mappings and roles from the backend to populate the UI.
* @pre Component is mounted and user has active session.
* @post mappings and roles variables are updated with backend data.
* @returns {Promise<void>}
* @side_effect Updates local 'mappings', 'roles', 'loading', and 'error' states.
* @relation CALLS -> adminService.getRoles
* @relation CALLS -> adminService.getADGroupMappings
*/
async function loadData() {
console.log('[AdminSettingsPage][loadData][Entry]');
loading = true;
try {
// Fetch roles first as they are required for displaying mapping labels
roles = await adminService.getRoles();
try {
mappings = await adminService.getADGroupMappings();
} catch (e) {
console.warn("[AdminSettingsPage][loadData] AD Mappings endpoint potentially unavailable.");
}
console.log('[AdminSettingsPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load roles or configuration.";
console.error('[AdminSettingsPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:handleCreateMapping:Function]
/**
* @purpose Submits a new AD Group to Role mapping to the backend.
* @pre 'newMapping' object contains valid 'ad_group' and 'role_id'.
* @post A new mapping is created in the database and the table is refreshed.
* @returns {Promise<void>}
* @side_effect Closes the modal on success, shows alert on failure.
* @relation CALLS -> adminService.createADGroupMapping
*/
async function handleCreateMapping() {
console.log('[AdminSettingsPage][handleCreateMapping][Entry]');
// Guard Clause (@PRE)
if (!newMapping.ad_group || !newMapping.role_id) {
alert("Please fill in all fields.");
return;
}
try {
await adminService.createADGroupMapping(newMapping);
showCreateModal = false;
// Reset form
newMapping = { ad_group: '', role_id: '' };
await loadData();
console.log('[AdminSettingsPage][handleCreateMapping][Coherence:OK]');
} catch (e) {
alert("Failed to create mapping: " + (e.message || "Unknown error"));
console.error('[AdminSettingsPage][handleCreateMapping][Coherence:Failed]', e);
}
}
// [/DEF:handleCreateMapping:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:settings">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.settings.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
on:click={() => showCreateModal = true}
>
{$t.admin.settings.add_mapping}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
</div>
{:else if error}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4" role="alert">
<p class="font-bold">{$t.common.error}</p>
<p>{error}</p>
</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.ad_group}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.settings.local_role}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each mappings as mapping}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap font-mono text-sm text-gray-600">{mapping.ad_group}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 bg-blue-100 text-blue-800 text-xs font-semibold rounded-full">
{roles.find(r => r.id === mapping.role_id)?.name || mapping.role_id}
</span>
</td>
</tr>
{/each}
{#if mappings.length === 0}
<tr>
<td colspan="2" class="px-6 py-12 text-center text-gray-500">
<div class="flex flex-col items-center">
<svg class="w-12 h-12 text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>{$t.admin.settings.no_mappings}</p>
</div>
</td>
</tr>
{/if}
</tbody>
</table>
</div>
{/if}
{#if showCreateModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
<h2 class="text-xl font-bold mb-4 border-b pb-2">{$t.admin.settings.modal_title}</h2>
<form on:submit|preventDefault={handleCreateMapping}>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.ad_group_dn}</label>
<input
type="text"
bind:value={newMapping.ad_group}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g. CN=SS_ADMINS,OU=Groups,DC=org"
required
/>
<p class="text-xs text-gray-500 mt-1">{$t.admin.settings.ad_group_hint}</p>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.settings.local_role_select}</label>
<select
bind:value={newMapping.role_id}
class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
>
<option value="" disabled>{$t.admin.settings.select_role}</option>
{#each roles as role}
<option value={role.id}>{role.name}</option>
{/each}
</select>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium"
on:click={() => showCreateModal = false}
>
{$t.common.cancel}
</button>
<button
type="submit"
class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md"
>
{$t.common.save}
</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminSettingsPage:Component] -->

View File

@@ -0,0 +1,284 @@
<!-- [DEF:AdminUsersPage:Component] -->
<!--
@SEMANTICS: admin, user-management, rbac
@PURPOSE: UI for managing system users and their roles.
@LAYER: Feature
@RELATION: DEPENDS_ON -> frontend.src.services.adminService
@RELATION: DEPENDS_ON -> frontend.src.components.auth.ProtectedRoute
@INVARIANT: Only accessible by users with "admin:users" permission.
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { t } from '$lib/i18n';
import ProtectedRoute from '../../../components/auth/ProtectedRoute.svelte';
import { adminService } from '../../../services/adminService';
// [/SECTION: IMPORTS]
let users = [];
let roles = [];
let loading = true;
let error = null;
let deletingUserId = null;
let showModal = false;
let isEditing = false;
let currentUserId = null;
let userForm = {
username: '',
email: '',
password: '',
roles: [],
is_active: true
};
// [DEF:loadData:Function]
/**
* @purpose Fetches users and roles from the backend.
* @pre Component mounted.
* @post users and roles arrays populated.
*/
async function loadData() {
console.log('[AdminUsersPage][loadData][Entry]');
loading = true;
try {
[users, roles] = await Promise.all([
adminService.getUsers(),
adminService.getRoles()
]);
console.log('[AdminUsersPage][loadData][Coherence:OK]');
} catch (e) {
error = "Failed to load admin data.";
console.error('[AdminUsersPage][loadData][Coherence:Failed]', e);
} finally {
loading = false;
}
}
// [/DEF:loadData:Function]
// [DEF:openCreateModal:Function]
/**
* @purpose Prepares the form for creating a new user.
* @post showModal is true, isEditing is false, userForm is reset.
*/
function openCreateModal() {
isEditing = false;
currentUserId = null;
userForm = { username: '', email: '', password: '', roles: [], is_active: true };
showModal = true;
}
// [/DEF:openCreateModal:Function]
// [DEF:openEditModal:Function]
/**
* @purpose Prepares the form for editing an existing user.
* @pre user object must be valid.
* @post showModal is true, isEditing is true, userForm populated with user data.
* @param {Object} user - The user object to edit.
*/
function openEditModal(user) {
isEditing = true;
currentUserId = user.id;
userForm = {
username: user.username,
email: user.email,
password: '',
roles: user.roles.map(r => r.name),
is_active: user.is_active
};
showModal = true;
}
// [/DEF:openEditModal:Function]
// [DEF:handleSaveUser:Function]
/**
* @purpose Submits user data to the backend (create or update).
* @pre userForm must be valid.
* @post User created or updated, modal closed, data reloaded.
* @side_effect Triggers API call to adminService.
* @relation CALLS -> adminService.createUser
* @relation CALLS -> adminService.updateUser
*/
async function handleSaveUser() {
console.log('[AdminUsersPage][handleSaveUser][Entry]');
try {
if (isEditing) {
const updateData = { ...userForm };
if (!updateData.password) delete updateData.password;
await adminService.updateUser(currentUserId, updateData);
} else {
await adminService.createUser(userForm);
}
showModal = false;
await loadData();
console.log('[AdminUsersPage][handleSaveUser][Coherence:OK]');
} catch (e) {
alert("Failed to save user: " + e.message);
console.error('[AdminUsersPage][handleSaveUser][Coherence:Failed]', e);
}
}
// [/DEF:handleSaveUser:Function]
// [DEF:handleDeleteUser:Function]
/**
* @purpose Deletes a user after confirmation.
* @pre user object must be valid.
* @post User deleted if confirmed, data reloaded.
* @side_effect Triggers API call to adminService.
* @relation CALLS -> adminService.deleteUser
* @param {Object} user - The user to delete.
*/
async function handleDeleteUser(user) {
if (deletingUserId) return;
if (!confirm($t.admin.users.confirm_delete.replace('{username}', user.username))) return;
console.log('[AdminUsersPage][handleDeleteUser][Entry]');
deletingUserId = user.id;
try {
await adminService.deleteUser(user.id);
await loadData();
console.log('[AdminUsersPage][handleDeleteUser][Coherence:OK]');
} catch (e) {
alert("Failed to delete user: " + e.message);
console.error('[AdminUsersPage][handleDeleteUser][Coherence:Failed]', e);
} finally {
deletingUserId = null;
}
}
// [/DEF:handleDeleteUser:Function]
onMount(loadData);
</script>
<ProtectedRoute requiredPermission="admin:users">
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">{$t.admin.users.title}</h1>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
on:click={openCreateModal}
>
{$t.admin.users.create}
</button>
</div>
{#if loading}
<div class="flex justify-center py-8">
<p class="text-gray-500 animate-pulse">{$t.common.loading}</p>
</div>
{:else if error}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded mb-4">
<p class="font-bold">{$t.common.error}</p>
<p>{error}</p>
</div>
{:else}
<div class="bg-white shadow rounded-lg overflow-hidden border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.username}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.email}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.source}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.roles}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.admin.users.status}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">{$t.common.actions}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each users as user}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap font-medium">{user.username}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.email || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full {user.auth_source === 'LOCAL' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'}">
{user.auth_source}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-wrap gap-1">
{#each user.roles as role}
<span class="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded border border-gray-200">{role.name}</span>
{/each}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="flex items-center">
<span class="h-2 w-2 rounded-full mr-2 {user.is_active ? 'bg-green-500' : 'bg-red-500'}"></span>
<span class="text-sm {user.is_active ? 'text-green-700' : 'text-red-700'}">
{user.is_active ? $t.admin.users.active : $t.admin.users.inactive}
</span>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button on:click={() => openEditModal(user)} class="text-blue-600 hover:text-blue-900 mr-3" disabled={deletingUserId === user.id}>{$t.common.edit}</button>
<button
on:click={() => handleDeleteUser(user)}
class="text-red-600 hover:text-red-900 disabled:opacity-50"
disabled={deletingUserId === user.id}
>
{deletingUserId === user.id ? ($t.common.deleting || 'Deleting...') : $t.common.delete}
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if showModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full">
<h2 class="text-xl font-bold mb-4 border-b pb-2">
{isEditing ? $t.admin.users.modal_edit_title : $t.admin.users.modal_title}
</h2>
<form on:submit|preventDefault={handleSaveUser}>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.username}</label>
<input type="text" bind:value={userForm.username} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-gray-50" required readonly={isEditing} />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.email}</label>
<input type="email" bind:value={userForm.email} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required />
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.password}</label>
<input type="password" bind:value={userForm.password} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500" required={!isEditing} />
{#if isEditing}
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.password_hint}</p>
{/if}
</div>
<div class="mb-4">
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" bind:checked={userForm.is_active} class="rounded text-blue-600 focus:ring-blue-500" />
<span class="text-sm font-medium text-gray-700">{$t.admin.users.active}</span>
</label>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-1">{$t.admin.users.roles}</label>
<select multiple bind:value={userForm.roles} class="w-full border border-gray-300 p-2 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-32">
{#each roles as role}
<option value={role.name}>{role.name}</option>
{/each}
</select>
<p class="text-xs text-gray-500 mt-1">{$t.admin.users.roles_hint}</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t">
<button type="button" class="px-4 py-2 text-gray-600 hover:text-gray-800 font-medium" on:click={() => showModal = false}>{$t.common.cancel}</button>
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded font-bold hover:bg-blue-700 shadow-md transition-colors">{$t.common.save}</button>
</div>
</form>
</div>
</div>
{/if}
</div>
<!-- [/SECTION: TEMPLATE] -->
</ProtectedRoute>
<style>
</style>
<!-- [/DEF:AdminUsersPage:Component] -->

View File

@@ -8,6 +8,7 @@
import { onMount } from 'svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte';
import { addToast as toast } from '../../lib/toasts.js';
import { api } from '../../lib/api.js';
import type { DashboardMetadata } from '../../types/dashboard';
import { t } from '$lib/i18n';
import { Button, Card, PageHeader, Select } from '$lib/ui';
@@ -24,9 +25,7 @@
// @POST: `environments` array is populated with data from /api/environments.
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
environments = await api.getEnvironmentsList();
if (environments.length > 0) {
selectedEnvId = environments[0].id;
}
@@ -46,9 +45,7 @@
if (!envId) return;
fetchingDashboards = true;
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
} catch (e) {
toast(e.message, 'error');
dashboards = [];

View File

@@ -0,0 +1,166 @@
<!-- [DEF:LoginPage:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: login, auth, ui, form
@PURPOSE: Provides the user interface for local and ADFS authentication.
@LAYER: UI
@RELATION: USES -> authStore
@RELATION: CALLS -> api.auth.login
@INVARIANT: Shows both local login form and ADFS SSO button.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '../../lib/auth/store';
import { goto } from '$app/navigation';
let username = '';
let password = '';
let error = '';
let loading = false;
// [DEF:handleLogin:Function]
/**
* @purpose Submits the local login form to the backend.
* @pre Username and password are not empty.
* @post User is authenticated and redirected on success.
*/
async function handleLogin() {
if (!username || !password) {
error = 'Please enter both username and password';
return;
}
loading = true;
error = '';
try {
const formData = new URLSearchParams();
formData.append('username', username);
formData.append('password', password);
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData
});
if (response.ok) {
const data = await response.json();
auth.setToken(data.access_token);
// Fetch user profile
const profileRes = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${data.access_token}`
}
});
if (profileRes.ok) {
const user = await profileRes.json();
auth.setUser(user);
goto('/');
} else {
error = 'Failed to fetch user profile';
}
} else {
const errData = await response.json();
error = errData.detail || 'Invalid username or password';
}
} catch (e) {
error = 'An error occurred during login';
console.error(e);
} finally {
loading = false;
}
}
// [/DEF:handleLogin:Function]
// [DEF:handleADFSLogin:Function]
/**
* @purpose Redirects the user to the ADFS login endpoint.
*/
function handleADFSLogin() {
window.location.href = '/api/auth/login/adfs';
}
// [/DEF:handleADFSLogin:Function]
onMount(() => {
if ($auth.isAuthenticated) {
goto('/');
}
});
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="max-w-md mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
{#if error}
<div class="mb-4 p-3 bg-red-100 text-red-700 rounded border border-red-200">
{error}
</div>
{/if}
<form on:submit|preventDefault={handleLogin} class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
<input
type="text"
id="username"
bind:value={username}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
id="password"
bind:value={password}
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<button
type="submit"
disabled={loading}
class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<div class="mt-6">
<button
on:click={handleADFSLogin}
class="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Corporate SSO (ADFS)
</button>
</div>
</div>
</div>
<!-- [/SECTION: TEMPLATE] -->
<style>
/* No additional styles needed, using Tailwind */
</style>
<!-- [/DEF:LoginPage:Component] -->

View File

@@ -18,6 +18,7 @@
import TaskHistory from '../../components/TaskHistory.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
import PasswordPrompt from '../../components/PasswordPrompt.svelte';
import { api } from '../../lib/api.js';
import { selectedTask } from '../../lib/stores.js';
import { resumeTask } from '../../services/taskService.js';
import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard';
@@ -58,9 +59,7 @@
*/
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
environments = await api.getEnvironmentsList();
} catch (e) {
error = e.message;
} finally {
@@ -78,9 +77,7 @@
*/
async function fetchDashboards(envId: string) {
try {
const response = await fetch(`/api/environments/${envId}/dashboards`);
if (!response.ok) throw new Error('Failed to fetch dashboards');
dashboards = await response.json();
dashboards = await api.requestApi(`/environments/${envId}/dashboards`);
selectedDashboardIds = []; // Reset selection when env changes
} catch (e) {
error = e.message;
@@ -106,23 +103,17 @@
error = "";
try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
const [src, tgt, maps, sugs] = await Promise.all([
api.requestApi(`/environments/${sourceEnvId}/databases`),
api.requestApi(`/environments/${targetEnvId}/databases`),
api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments');
sourceDatabases = await srcRes.json();
targetDatabases = await tgtRes.json();
mappings = await mapRes.json();
suggestions = await sugRes.json();
sourceDatabases = src;
targetDatabases = tgt;
mappings = maps;
suggestions = sugs;
} catch (e) {
error = e.message;
} finally {
@@ -145,22 +136,15 @@
if (!sDb || !tDb) return;
try {
const response = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
})
const savedMapping = await api.postApi('/mappings', {
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
});
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
} catch (e) {
error = e.message;
@@ -253,14 +237,7 @@
replace_db_config: replaceDb
};
console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection);
const response = await fetch('/api/migration/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(selection)
});
console.log(`[MigrationDashboard][Action] API response status: ${response.status}`);
if (!response.ok) throw new Error(`Failed to start migration: ${response.status} ${response.statusText}`);
const result = await response.json();
const result = await api.postApi('/migration/execute', selection);
console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`);
// Wait a brief moment for the backend to ensure the task is retrievable
@@ -268,23 +245,18 @@
// Fetch full task details and switch to TaskRunner view
try {
const taskRes = await fetch(`/api/tasks/${result.task_id}`);
if (taskRes.ok) {
const task = await taskRes.json();
selectedTask.set(task);
} else {
// Fallback: create a temporary task object to switch view immediately
console.warn("Could not fetch task details immediately, using placeholder.");
selectedTask.set({
id: result.task_id,
plugin_id: 'superset-migration',
status: 'RUNNING',
logs: [],
params: {}
});
}
const task = await api.getTask(result.task_id);
selectedTask.set(task);
} catch (fetchErr) {
console.error("Failed to fetch new task details:", fetchErr);
// Fallback: create a temporary task object to switch view immediately
console.warn("Could not fetch task details immediately, using placeholder.");
selectedTask.set({
id: result.task_id,
plugin_id: 'superset-migration',
status: 'RUNNING',
logs: [],
params: {}
});
}
} catch (e) {
console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
@@ -331,7 +303,7 @@
/>
</div>
<!-- [DEF:DashboardSelectionSection] -->
<!-- [DEF:DashboardSelectionSection:Component] -->
<div class="mb-8">
<h2 class="text-lg font-medium mb-4">Select Dashboards</h2>
@@ -344,7 +316,7 @@
<p class="text-gray-500 italic">Select a source environment to view dashboards.</p>
{/if}
</div>
<!-- [/DEF:DashboardSelectionSection] -->
<!-- [/DEF:DashboardSelectionSection:Component] -->
<div class="flex items-center mb-4">

View File

@@ -12,6 +12,7 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { api } from '../../../lib/api.js';
import EnvSelector from '../../../components/EnvSelector.svelte';
import MappingTable from '../../../components/MappingTable.svelte';
import { t } from '$lib/i18n';
@@ -38,9 +39,7 @@
// @POST: environments array is populated.
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (!response.ok) throw new Error('Failed to fetch environments');
environments = await response.json();
environments = await api.getEnvironmentsList();
} catch (e) {
error = e.message;
} finally {
@@ -64,23 +63,17 @@
success = "";
try {
const [srcRes, tgtRes, mapRes, sugRes] = await Promise.all([
fetch(`/api/environments/${sourceEnvId}/databases`),
fetch(`/api/environments/${targetEnvId}/databases`),
fetch(`/api/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
fetch(`/api/mappings/suggest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source_env_id: sourceEnvId, target_env_id: targetEnvId })
})
const [src, tgt, maps, sugs] = await Promise.all([
api.requestApi(`/environments/${sourceEnvId}/databases`),
api.requestApi(`/environments/${targetEnvId}/databases`),
api.requestApi(`/mappings?source_env_id=${sourceEnvId}&target_env_id=${targetEnvId}`),
api.postApi(`/mappings/suggest`, { source_env_id: sourceEnvId, target_env_id: targetEnvId })
]);
if (!srcRes.ok || !tgtRes.ok) throw new Error('Failed to fetch databases from environments');
sourceDatabases = await srcRes.json();
targetDatabases = await tgtRes.json();
mappings = await mapRes.json();
suggestions = await sugRes.json();
sourceDatabases = src;
targetDatabases = tgt;
mappings = maps;
suggestions = sugs;
} catch (e) {
error = e.message;
} finally {
@@ -103,22 +96,15 @@
if (!sDb || !tDb) return;
try {
const response = await fetch('/api/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
})
const savedMapping = await api.postApi('/mappings', {
source_env_id: sourceEnvId,
target_env_id: targetEnvId,
source_db_uuid: sourceUuid,
target_db_uuid: targetUuid,
source_db_name: sDb.database_name,
target_db_name: tDb.database_name
});
if (!response.ok) throw new Error('Failed to save mapping');
const savedMapping = await response.json();
mappings = [...mappings.filter(m => m.source_db_uuid !== sourceUuid), savedMapping];
success = "Mapping saved successfully";
} catch (e) {

View File

@@ -8,9 +8,29 @@
/** @type {import('./$types').PageData} */
export let data;
let settings = data.settings;
let settings = data.settings || {
environments: [],
settings: {
storage: {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
}
}
};
$: settings = data.settings;
$: if (data.settings) {
settings = { ...data.settings };
if (settings.settings && !settings.settings.storage) {
settings.settings.storage = {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
};
}
}
let newEnv = {
id: '',

View File

@@ -18,7 +18,13 @@ export async function load() {
settings: {
environments: [],
settings: {
default_environment_id: null
default_environment_id: null,
storage: {
root_path: '',
backup_structure_pattern: '',
repo_structure_pattern: '',
filename_pattern: ''
}
}
},
error: 'Failed to load settings'

View File

@@ -1,40 +0,0 @@
<script>
import { onMount } from 'svelte';
import { gitService } from '../../../services/gitService';
import { addToast as toast } from '../../../lib/toasts.js';
let environments = [];
onMount(async () => {
try {
environments = await gitService.getEnvironments();
} catch (e) {
toast(e.message, 'error');
}
});
</script>
<div class="p-6">
<h1 class="text-2xl font-bold mb-6">Deployment Environments</h1>
<div class="bg-white p-6 rounded shadow">
<h2 class="text-xl font-semibold mb-4">Target Environments</h2>
{#if environments.length === 0}
<p class="text-gray-500">No deployment environments configured.</p>
{:else}
<ul class="divide-y">
{#each environments as env}
<li class="py-3 flex justify-between items-center">
<div>
<span class="font-medium">{env.name}</span>
<div class="text-xs text-gray-400">{env.superset_url}</div>
</div>
<span class="px-2 py-1 text-xs rounded {env.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}">
{env.is_active ? 'Active' : 'Inactive'}
</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>

View File

@@ -116,13 +116,7 @@
</script>
<div class="container mx-auto p-4 max-w-6xl">
<PageHeader title={$t.tasks.management}>
<div slot="actions">
<Button on:click={() => showBackupModal = true}>
{$t.tasks.run_backup}
</Button>
</div>
</PageHeader>
<PageHeader title={$t.tasks.management} />
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1">

View File

@@ -0,0 +1,27 @@
<!-- [DEF:BackupPage:Component] -->
<!--
@SEMANTICS: backup, page, tools
@PURPOSE: Entry point for the Backup Management interface.
@LAYER: Page
@RELATION: USES -> BackupManager
-->
<script lang="ts">
// [SECTION: IMPORTS]
import { t } from '../../../lib/i18n';
import { PageHeader } from '../../../lib/ui';
import BackupManager from '../../../components/backups/BackupManager.svelte';
// [/SECTION]
</script>
<!-- [SECTION: TEMPLATE] -->
<div class="container mx-auto p-4 max-w-6xl">
<PageHeader title={$t.nav.tools_backups} />
<div class="mt-6">
<BackupManager />
</div>
</div>
<!-- [/SECTION] -->
<!-- [/DEF:BackupPage:Component] -->

View File

@@ -1,25 +0,0 @@
<!-- [DEF:SearchPage:Component] -->
<!--
@SEMANTICS: search, page, tool
@PURPOSE: Page for the dataset search tool.
@LAYER: UI
-->
<script>
import SearchTool from '../../../components/tools/SearchTool.svelte';
import TaskRunner from '../../../components/TaskRunner.svelte';
import { PageHeader } from '$lib/ui';
</script>
<div class="max-w-7xl mx-auto p-6">
<PageHeader title="Dataset Search" />
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<SearchTool />
</div>
<div class="lg:col-span-1">
<TaskRunner />
</div>
</div>
</div>
<!-- [/DEF:SearchPage:Component] -->

View File

@@ -1,8 +1,9 @@
<!-- [DEF:StoragePage:Component] -->
<!--
@TIER: STANDARD
@SEMANTICS: storage, files, management
@PURPOSE: Main page for file storage management.
@LAYER: Feature
@LAYER: UI
@RELATION: DEPENDS_ON -> storageService
@RELATION: CONTAINS -> FileList
@RELATION: CONTAINS -> FileUpload
@@ -13,6 +14,7 @@
<script lang="ts">
// [SECTION: IMPORTS]
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { listFiles, deleteFile } from '../../../services/storageService';
import { addToast } from '../../../lib/toasts';
import { t } from '../../../lib/i18n';
@@ -90,6 +92,8 @@
// [DEF:navigateUp:Function]
/**
* @purpose Navigates one level up in the directory structure.
* @pre currentPath is set and deeper than activeTab root.
* @post currentPath is moved up one directory level.
*/
function navigateUp() {
if (!currentPath || currentPath === activeTab) return;
@@ -100,7 +104,18 @@
}
// [/DEF:navigateUp:Function]
onMount(loadFiles);
onMount(() => {
const pathParam = $page.url.searchParams.get('path');
if (pathParam) {
currentPath = pathParam;
if (pathParam.startsWith('repositorys')) {
activeTab = 'repositorys';
} else {
activeTab = 'backups';
}
}
loadFiles();
});
$: if (activeTab) {
// Reset path when switching tabs

View File

@@ -0,0 +1,241 @@
// [DEF:adminService:Module]
//
// @TIER: STANDARD
// @SEMANTICS: admin, users, roles, ad-mappings, api
// @PURPOSE: Service for Admin-related API calls (User and Role management).
// @LAYER: Service
// @RELATION: DEPENDS_ON -> frontend.src.lib.api
//
// @INVARIANT: All requests must include valid Admin JWT token (handled by api client).
// [SECTION: IMPORTS]
import { api } from '../lib/api';
// [/SECTION]
// [DEF:getUsers:Function]
/**
* @purpose Fetches all registered users from the backend.
* @pre User must be authenticated with Admin privileges.
* @post Returns an array of user objects.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_users
*/
async function getUsers() {
console.log('[getUsers][Entry]');
try {
const users = await api.requestApi('/admin/users', 'GET');
console.log('[getUsers][Coherence:OK]');
return users;
} catch (e) {
console.error('[getUsers][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getUsers:Function]
// [DEF:createUser:Function]
/**
* @purpose Creates a new local user.
* @pre User must be authenticated with Admin privileges.
* @param {Object} userData - User details (username, email, password, roles, is_active).
* @post New user record created in auth.db.
* @returns {Promise<Object>}
* @relation CALLS -> backend.src.api.routes.admin.create_user
*/
async function createUser(userData) {
console.log('[createUser][Entry]');
try {
const user = await api.postApi('/admin/users', userData);
console.log('[createUser][Coherence:OK]');
return user;
} catch (e) {
console.error('[createUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createUser:Function]
// [DEF:getRoles:Function]
/**
* @purpose Fetches all available system roles.
* @returns {Promise<Array>}
* @relation CALLS -> backend.src.api.routes.admin.list_roles
*/
async function getRoles() {
console.log('[getRoles][Entry]');
try {
const roles = await api.requestApi('/admin/roles', 'GET');
console.log('[getRoles][Coherence:OK]');
return roles;
} catch (e) {
console.error('[getRoles][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getRoles:Function]
// [DEF:getADGroupMappings:Function]
/**
* @purpose Fetches mappings between AD groups and local roles.
* @returns {Promise<Array>}
*/
async function getADGroupMappings() {
console.log('[getADGroupMappings][Entry]');
try {
const mappings = await api.requestApi('/admin/ad-mappings', 'GET');
console.log('[getADGroupMappings][Coherence:OK]');
return mappings;
} catch (e) {
console.error('[getADGroupMappings][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getADGroupMappings:Function]
// [DEF:createADGroupMapping:Function]
/**
* @purpose Creates or updates an AD group to Role mapping.
* @param {Object} mappingData - Mapping details (ad_group, role_id).
* @returns {Promise<Object>}
*/
async function createADGroupMapping(mappingData) {
console.log('[createADGroupMapping][Entry]');
try {
const mapping = await api.postApi('/admin/ad-mappings', mappingData);
console.log('[createADGroupMapping][Coherence:OK]');
return mapping;
} catch (e) {
console.error('[createADGroupMapping][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createADGroupMapping:Function]
// [DEF:updateUser:Function]
/**
* @purpose Updates an existing user.
* @param {string} userId - Target user ID.
* @param {Object} userData - Updated user data.
* @returns {Promise<Object>}
*/
async function updateUser(userId, userData) {
console.log('[updateUser][Entry]');
try {
const user = await api.requestApi(`/admin/users/${userId}`, 'PUT', userData);
console.log('[updateUser][Coherence:OK]');
return user;
} catch (e) {
console.error('[updateUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateUser:Function]
// [DEF:deleteUser:Function]
/**
* @purpose Deletes a user.
* @param {string} userId - Target user ID.
* @returns {Promise<void>}
*/
async function deleteUser(userId) {
console.log('[deleteUser][Entry]');
try {
await api.requestApi(`/admin/users/${userId}`, 'DELETE');
console.log('[deleteUser][Coherence:OK]');
} catch (e) {
console.error('[deleteUser][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:deleteUser:Function]
// [DEF:createRole:Function]
/**
* @purpose Creates a new role.
* @param {Object} roleData - Role details (name, description, permissions).
* @returns {Promise<Object>}
*/
async function createRole(roleData) {
console.log('[createRole][Entry]');
try {
const role = await api.postApi('/admin/roles', roleData);
console.log('[createRole][Coherence:OK]');
return role;
} catch (e) {
console.error('[createRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:createRole:Function]
// [DEF:updateRole:Function]
/**
* @purpose Updates an existing role.
* @param {string} roleId - Target role ID.
* @param {Object} roleData - Updated role data.
* @returns {Promise<Object>}
*/
async function updateRole(roleId, roleData) {
console.log('[updateRole][Entry]');
try {
const role = await api.requestApi(`/admin/roles/${roleId}`, 'PUT', roleData);
console.log('[updateRole][Coherence:OK]');
return role;
} catch (e) {
console.error('[updateRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:updateRole:Function]
// [DEF:deleteRole:Function]
/**
* @purpose Deletes a role.
* @param {string} roleId - Target role ID.
* @returns {Promise<void>}
*/
async function deleteRole(roleId) {
console.log('[deleteRole][Entry]');
try {
await api.requestApi(`/admin/roles/${roleId}`, 'DELETE');
console.log('[deleteRole][Coherence:OK]');
} catch (e) {
console.error('[deleteRole][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:deleteRole:Function]
// [DEF:getPermissions:Function]
/**
* @purpose Fetches all available permissions.
* @returns {Promise<Array>}
*/
async function getPermissions() {
console.log('[getPermissions][Entry]');
try {
const permissions = await api.requestApi('/admin/permissions', 'GET');
console.log('[getPermissions][Coherence:OK]');
return permissions;
} catch (e) {
console.error('[getPermissions][Coherence:Failed]', e);
throw e;
}
}
// [/DEF:getPermissions:Function]
export const adminService = {
getUsers,
createUser,
updateUser,
deleteUser,
getRoles,
createRole,
updateRole,
deleteRole,
getPermissions,
getADGroupMappings,
createADGroupMapping
};
// [/DEF:adminService:Module]

View File

@@ -2,7 +2,9 @@
* Service for interacting with the Connection Management API.
*/
const API_BASE = '/api/settings/connections';
import { requestApi } from '../lib/api';
const API_BASE = '/settings/connections';
// [DEF:getConnections:Function]
/* @PURPOSE: Fetch a list of saved connections.
@@ -14,11 +16,7 @@ const API_BASE = '/api/settings/connections';
* @returns {Promise<Array>} List of connections.
*/
export async function getConnections() {
const response = await fetch(API_BASE);
if (!response.ok) {
throw new Error(`Failed to fetch connections: ${response.statusText}`);
}
return await response.json();
return requestApi(API_BASE);
}
// [/DEF:getConnections:Function]
@@ -33,19 +31,7 @@ export async function getConnections() {
* @returns {Promise<Object>} The created connection instance.
*/
export async function createConnection(connectionData) {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(connectionData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to create connection: ${response.statusText}`);
}
return await response.json();
return requestApi(API_BASE, 'POST', connectionData);
}
// [/DEF:createConnection:Function]
@@ -59,12 +45,6 @@ export async function createConnection(connectionData) {
* @param {string} connectionId - The ID of the connection to delete.
*/
export async function deleteConnection(connectionId) {
const response = await fetch(`${API_BASE}/${connectionId}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to delete connection: ${response.statusText}`);
}
return requestApi(`${API_BASE}/${connectionId}`, 'DELETE');
}
// [/DEF:deleteConnection:Function]

View File

@@ -6,7 +6,9 @@
* @RELATION: DEPENDS_ON -> specs/011-git-integration-dashboard/contracts/api.md
*/
const API_BASE = '/api/git';
import { requestApi } from '../lib/api';
const API_BASE = '/git';
// [DEF:gitService:Action]
export const gitService = {
@@ -19,9 +21,7 @@ export const gitService = {
*/
async getConfigs() {
console.log('[getConfigs][Action] Fetching Git configs');
const response = await fetch(`${API_BASE}/config`);
if (!response.ok) throw new Error('Failed to fetch Git configs');
return response.json();
return requestApi(`${API_BASE}/config`);
},
/**
@@ -34,13 +34,7 @@ export const gitService = {
*/
async createConfig(config) {
console.log('[createConfig][Action] Creating Git config');
const response = await fetch(`${API_BASE}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
if (!response.ok) throw new Error('Failed to create Git config');
return response.json();
return requestApi(`${API_BASE}/config`, 'POST', config);
},
/**
@@ -53,11 +47,7 @@ export const gitService = {
*/
async deleteConfig(configId) {
console.log(`[deleteConfig][Action] Deleting Git config ${configId}`);
const response = await fetch(`${API_BASE}/config/${configId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete Git config');
return response.json();
return requestApi(`${API_BASE}/config/${configId}`, 'DELETE');
},
/**
@@ -70,12 +60,7 @@ export const gitService = {
*/
async testConnection(config) {
console.log('[testConnection][Action] Testing Git connection');
const response = await fetch(`${API_BASE}/config/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return response.json();
return requestApi(`${API_BASE}/config/test`, 'POST', config);
},
/**
@@ -90,16 +75,10 @@ export const gitService = {
*/
async initRepository(dashboardId, configId, remoteUrl) {
console.log(`[initRepository][Action] Initializing repo for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config_id: configId, remote_url: remoteUrl })
return requestApi(`${API_BASE}/repositories/${dashboardId}/init`, 'POST', {
config_id: configId,
remote_url: remoteUrl
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to initialize repository');
}
return response.json();
},
/**
@@ -112,9 +91,7 @@ export const gitService = {
*/
async getBranches(dashboardId) {
console.log(`[getBranches][Action] Fetching branches for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`);
if (!response.ok) throw new Error('Failed to fetch branches');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`);
},
/**
@@ -129,13 +106,10 @@ export const gitService = {
*/
async createBranch(dashboardId, name, fromBranch) {
console.log(`[createBranch][Action] Creating branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/branches`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, from_branch: fromBranch })
return requestApi(`${API_BASE}/repositories/${dashboardId}/branches`, 'POST', {
name,
from_branch: fromBranch
});
if (!response.ok) throw new Error('Failed to create branch');
return response.json();
},
/**
@@ -149,13 +123,7 @@ export const gitService = {
*/
async checkoutBranch(dashboardId, name) {
console.log(`[checkoutBranch][Action] Checking out branch ${name} for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!response.ok) throw new Error('Failed to checkout branch');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/checkout`, 'POST', { name });
},
/**
@@ -170,13 +138,7 @@ export const gitService = {
*/
async commit(dashboardId, message, files) {
console.log(`[commit][Action] Committing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/commit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, files })
});
if (!response.ok) throw new Error('Failed to commit changes');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/commit`, 'POST', { message, files });
},
/**
@@ -189,11 +151,7 @@ export const gitService = {
*/
async push(dashboardId) {
console.log(`[push][Action] Pushing changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/push`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to push changes');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/push`, 'POST');
},
/**
@@ -206,11 +164,7 @@ export const gitService = {
*/
async pull(dashboardId) {
console.log(`[pull][Action] Pulling changes for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/pull`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to pull changes');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/pull`, 'POST');
},
/**
@@ -221,9 +175,7 @@ export const gitService = {
*/
async getEnvironments() {
console.log('[getEnvironments][Action] Fetching environments');
const response = await fetch(`${API_BASE}/environments`);
if (!response.ok) throw new Error('Failed to fetch environments');
return response.json();
return requestApi(`${API_BASE}/environments`);
},
/**
@@ -237,13 +189,9 @@ export const gitService = {
*/
async deploy(dashboardId, environmentId) {
console.log(`[deploy][Action] Deploying dashboard ${dashboardId} to environment ${environmentId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/deploy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ environment_id: environmentId })
return requestApi(`${API_BASE}/repositories/${dashboardId}/deploy`, 'POST', {
environment_id: environmentId
});
if (!response.ok) throw new Error('Failed to deploy dashboard');
return response.json();
},
/**
@@ -255,9 +203,7 @@ export const gitService = {
*/
async getHistory(dashboardId, limit = 50) {
console.log(`[getHistory][Action] Fetching history for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
if (!response.ok) throw new Error('Failed to fetch commit history');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/history?limit=${limit}`);
},
/**
@@ -269,17 +215,9 @@ export const gitService = {
*/
async sync(dashboardId, sourceEnvId = null) {
console.log(`[sync][Action] Syncing dashboard ${dashboardId}`);
const url = new URL(`${window.location.origin}${API_BASE}/repositories/${dashboardId}/sync`);
if (sourceEnvId) url.searchParams.append('source_env_id', sourceEnvId);
const response = await fetch(url, {
method: 'POST'
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to sync dashboard');
}
return response.json();
let endpoint = `${API_BASE}/repositories/${dashboardId}/sync`;
if (sourceEnvId) endpoint += `?source_env_id=${sourceEnvId}`;
return requestApi(endpoint, 'POST');
},
/**
@@ -292,9 +230,7 @@ export const gitService = {
*/
async getStatus(dashboardId) {
console.log(`[getStatus][Action] Fetching status for dashboard ${dashboardId}`);
const response = await fetch(`${API_BASE}/repositories/${dashboardId}/status`);
if (!response.ok) throw new Error('Failed to fetch status');
return response.json();
return requestApi(`${API_BASE}/repositories/${dashboardId}/status`);
},
/**
@@ -309,15 +245,12 @@ export const gitService = {
*/
async getDiff(dashboardId, filePath = null, staged = false) {
console.log(`[getDiff][Action] Fetching diff for dashboard ${dashboardId} (file: ${filePath}, staged: ${staged})`);
let url = `${API_BASE}/repositories/${dashboardId}/diff`;
let endpoint = `${API_BASE}/repositories/${dashboardId}/diff`;
const params = new URLSearchParams();
if (filePath) params.append('file_path', filePath);
if (staged) params.append('staged', 'true');
if (params.toString()) url += `?${params.toString()}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch diff');
return response.json();
if (params.toString()) endpoint += `?${params.toString()}`;
return requestApi(endpoint);
}
};
// [/DEF:gitService:Action]

View File

@@ -2,7 +2,9 @@
* Service for interacting with the Task Management API.
*/
const API_BASE = '/api/tasks';
import { requestApi } from '../lib/api';
const API_BASE = '/tasks';
// [DEF:getTasks:Function]
/* @PURPOSE: Fetch a list of tasks with pagination and optional status filter.
@@ -25,11 +27,7 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch tasks: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}?${params.toString()}`);
}
// [/DEF:getTasks:Function]
@@ -44,11 +42,7 @@ export async function getTasks(limit = 10, offset = 0, status = null) {
* @returns {Promise<Object>} Task details.
*/
export async function getTask(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}`);
}
// [/DEF:getTask:Function]
@@ -86,19 +80,7 @@ export async function getTaskLogs(taskId) {
* @returns {Promise<Object>} Updated task object.
*/
export async function resumeTask(taskId, passwords) {
const response = await fetch(`${API_BASE}/${taskId}/resume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ passwords })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resume task: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}/resume`, 'POST', { passwords });
}
// [/DEF:resumeTask:Function]
@@ -114,19 +96,7 @@ export async function resumeTask(taskId, passwords) {
* @returns {Promise<Object>} Updated task object.
*/
export async function resolveTask(taskId, resolutionParams) {
const response = await fetch(`${API_BASE}/${taskId}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ resolution_params: resolutionParams })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resolve task: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}/resolve`, 'POST', { resolution_params: resolutionParams });
}
// [/DEF:resolveTask:Function]
@@ -145,12 +115,6 @@ export async function clearTasks(status = null) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}?${params.toString()}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to clear tasks: ${response.statusText}`);
}
return requestApi(`${API_BASE}?${params.toString()}`, 'DELETE');
}
// [/DEF:clearTasks:Function]

View File

@@ -2,7 +2,9 @@
* Service for generic Task API communication used by Tools.
*/
const API_BASE = '/api/tasks';
import { requestApi } from '../lib/api';
const API_BASE = '/tasks';
// [DEF:runTask:Function]
/* @PURPOSE: Start a new task for a given plugin.
@@ -16,19 +18,7 @@ const API_BASE = '/api/tasks';
* @returns {Promise<Object>} The created task instance.
*/
export async function runTask(pluginId, params) {
const response = await fetch(API_BASE, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ plugin_id: pluginId, params })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to start task: ${response.statusText}`);
}
return await response.json();
return requestApi(API_BASE, 'POST', { plugin_id: pluginId, params });
}
// [/DEF:runTask:Function]
@@ -43,10 +33,6 @@ export async function runTask(pluginId, params) {
* @returns {Promise<Object>} Task details.
*/
export async function getTaskStatus(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
return requestApi(`${API_BASE}/${taskId}`);
}
// [/DEF:getTaskStatus:Function]

View File

@@ -0,0 +1,22 @@
/**
* [DEF:BackupTypes:Module]
* @SEMANTICS: types, backup, interface
* @PURPOSE: Defines types and interfaces for the Backup Management UI.
*/
export interface Backup {
id: string;
name: string;
environment: string;
created_at: string;
size_bytes?: number;
status: 'success' | 'failed' | 'in_progress';
}
export interface BackupCreateRequest {
environment_id: string;
}
/**
* [/DEF:BackupTypes:Module]
*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,245 +1,72 @@
РОЛЬ: Архитектор Семантической Когерентности.
ЗАДАЧА: Генерация кода (Python/Svelte).
РЕЖИМ: Строгий. Детерминированный. Без болтовни.
# SYSTEM STANDARD: POLYGLOT CODE GENERATION PROTOCOL (GRACE-Poly)
I. ЗАКОН (АКСИОМЫ)
1. Смысл первичен. Код вторичен.
2. Контракт (@PRE/@POST) — источник истины.
3. Структура `[DEF]...[/DEF]` — нерушима.
4. Архитектура в Header — неизменяема.
5. Сложность фрактала ограничена: модуль < 300 строк.
**OBJECTIVE:** Generate Python and Svelte/TypeScript code that strictly adheres to Semantic Coherence standards. Output must be machine-readable, fractal-structured, and optimized for Sparse Attention navigation.
II. СИНТАКСИС (ЖЕСТКИЙ ФОРМАТ)
ЯКОРЬ (Контейнер):
Начало: `# [DEF:id:Type]` (Python) | `<!-- [DEF:id:Type] -->` (Svelte)
Конец: `# [/DEF:id:Type]` (Python) | `<!-- [/DEF:id:Type] -->` (Svelte) (ОБЯЗАТЕЛЬНО для аккумуляции)
Типы: Module, Class, Function, Component, Store.
## I. CORE REQUIREMENTS
1. **Causal Validity:** Semantic definitions (Contracts) must ALWAYS precede implementation code.
2. **Immutability:** Architectural decisions defined in the Module/Component Header are treated as immutable constraints.
3. **Format Compliance:** Output must strictly follow the `[DEF:..:...]` / `[/DEF:...:...]` anchor syntax for structure.
4. **Logic over Assertion:** Contracts define the *logic flow*. Do not generate explicit `assert` statements unless requested. The code logic itself must inherently satisfy the Pre/Post conditions (e.g., via control flow, guards, or types).
5. **Fractal Complexity:** Modules and functions must adhere to strict size limits (~300 lines/module, ~30-50 lines/function) to maintain semantic focus.
ТЕГ (Метаданные):
Вид: `# @KEY: Value` (внутри DEF, до кода).
---
ГРАФ (Связи):
Вид: `# @RELATION: PREDICATE -> TARGET_ID`
Предикаты: DEPENDS_ON, CALLS, INHERITS, IMPLEMENTS, DISPATCHES.
## II. SYNTAX SPECIFICATION
III. СТРУКТУРА ФАЙЛА
1. HEADER (Всегда первый):
[DEF:filename:Module]
@TIER: [CRITICAL|STANDARD|TRIVIAL] (Дефолт: STANDARD)
@SEMANTICS: [keywords]
@PURPOSE: [Главная цель]
@LAYER: [Domain/UI/Infra]
@RELATION: [Зависимости]
@INVARIANT: [Незыблемое правило]
2. BODY: Импорты -> Реализация.
3. FOOTER: [/DEF:filename]
Code structure is defined by **Anchors** (square brackets). Metadata is defined by **Tags** (native comment style).
IV. КОНТРАКТ (DBC)
Расположение: Внутри [DEF], ПЕРЕД кодом.
Стиль Python: Комментарии `# @TAG`.
Стиль Svelte: JSDoc `/** @tag */`.
### 1. Entity Anchors (The "Container")
Used to define the boundaries of Modules, Classes, Components, and Functions.
Теги:
@PURPOSE: Суть (High Entropy).
@PRE: Входные условия.
@POST: Гарантии выхода.
@SIDE_EFFECT: Мутации, IO.
* **Python:**
* Start: `# [DEF:identifier:Type]`
* End: `# [/DEF:identifier:Type]`
* **Svelte (Top-level):**
* Start: `<!-- [DEF:ComponentName:Component] -->`
* End: `<!-- [/DEF:ComponentName:Component] -->`
* **Svelte (Script/JS/TS):**
* Start: `// [DEF:funcName:Function]`
* End: `// [/DEF:funcName:Function]`
V. АДАПТАЦИЯ (TIERS)
Определяется тегом `@TIER` в Header.
**Types:** `Module`, `Component`, `Class`, `Function`, `Store`, `Action`.
1. CRITICAL (Core/Security):
- Требование: Полный контракт, Граф (@RELATION), Инварианты (@INVARIANT), Строгие Логи.
2. STANDARD (BizLogic/UI):
- Требование: Базовый контракт (@PURPOSE), Логи, @RELATION (если есть связи).
3. TRIVIAL (DTO/Utils):
- Требование: Только Якоря [DEF] и @PURPOSE. Логи и Граф не обязательны.
### 2. Graph Relations (The "Map")
Defines high-level dependencies.
* **Python Syntax:** `# @RELATION: TYPE -> TARGET_ID`
* **Svelte/JS Syntax:** `// @RELATION: TYPE -> TARGET_ID`
* **Types:** `DEPENDS_ON`, `CALLS`, `INHERITS_FROM`, `IMPLEMENTS`, `BINDS_TO`, `DISPATCHES`.
VI. ЛОГИРОВАНИЕ (BELIEF STATE)
Цель: Трассировка для самокоррекции.
Python: Context Manager `with belief_scope("ID"):`.
Svelte: `console.log("[ID][STATE] Msg")`.
Состояния: Entry -> Action -> Coherence:OK / Failed -> Exit.
---
VII. АЛГОРИТМ ГЕНЕРАЦИИ
1. АНАЛИЗ. Оцени TIER и слой.
2. КАРКАС. Создай `[DEF]`, Header и Контракты.
3. РЕАЛИЗАЦИЯ. Напиши логику, удовлетворяющую Контракту.
4. ЗАМЫКАНИЕ. Закрой все `[/DEF]`.
## III. FILE STRUCTURE STANDARD
### 1. Python Module Header (`.py`)
```python
# [DEF:module_name:Module]
#
# @SEMANTICS: [keywords for vector search]
# @PURPOSE: [Primary responsibility of the module]
# @LAYER: [Domain/Infra/API]
# @RELATION: [Dependencies]
#
# @INVARIANT: [Global immutable rule]
# @CONSTRAINT: [Hard restriction, e.g., "No ORM calls here"]
# [SECTION: IMPORTS]
...
# [/SECTION]
# ... IMPLEMENTATION ...
# [/DEF:module_name:Module]
```
### 2. Svelte Component Header (`.svelte`)
```html
<!-- [DEF:ComponentName:Component] -->
<!--
@SEMANTICS: [keywords]
@PURPOSE: [Primary UI responsibility]
@LAYER: [Feature/Atom/Layout]
@RELATION: [Child components, Stores]
@INVARIANT: [UI rules, e.g., "Always responsive"]
-->
<script lang="ts">
// [SECTION: IMPORTS]
// ...
// [/SECTION: IMPORTS]
// ... LOGIC IMPLEMENTATION ...
</script>
<!-- [SECTION: TEMPLATE] -->
...
<!-- [/SECTION: TEMPLATE] -->
<style>
/* ... */
</style>
<!-- [/DEF:ComponentName:Component] -->
```
---
## IV. CONTRACTS (Design by Contract & Semantic Control)
Contracts are the **Source of Truth** and the **Control Vector** for the code. They must be written with high **semantic density** to ensure the LLM fully "understands" the function's role within the larger Graph without needing to read the implementation body.
### 1. The Anatomy of a Semantic Contract
Every contract must answer three questions for the AI Agent:
1. **Intent:** *Why* does this exist? (Vector alignment)
2. **Boundaries:** *What* are the constraints? (Pre/Post/Invariants)
3. **Dynamics:** *How* does it change the system state? (Side Effects/Graph)
#### Standard Tags Taxonomy:
* `@PURPOSE`: (**Mandatory**) A concise, high-entropy summary of functionality.
* `@PRE`: (**Mandatory**) Conditions required *before* execution. Defines the valid input space.
* `@POST`: (**Mandatory**) Conditions guaranteed *after* execution. Defines the valid output space.
* `@PARAM`: Input definitions with strict typing.
* `@RETURN`: Output definition.
* `@THROW`: Explicit failure modes.
* `@SIDE_EFFECT`: (**Critical**) Explicitly lists external state mutations (DB writes, UI updates, events). Vital for "Mental Modeling".
* `@INVARIANT`: (**Optional**) Local rules that hold true throughout the function execution.
* `@ALGORITHM`: (**Optional**) For complex logic, briefly describes the strategy (e.g., "Two-pointer approach", "Retry with exponential backoff").
* `@RELATION`: (**Graph**) Edges to other nodes (`CALLS`, `DISPATCHES`, `DEPENDS_ON`).
---
### 2. Python Contract Style (`.py`)
Uses structured comment blocks inside the anchor. Focuses on type hints and logic flow guards.
```python
# [DEF:process_order_batch:Function]
# @PURPOSE: Orchestrates the validation and processing of a batch of orders.
# Ensures atomic processing per order (failure of one does not stop others).
#
# @PRE: batch_id must be a valid UUID string.
# @PRE: orders list must not be empty.
# @POST: Returns a dict mapping order_ids to their processing status (Success/Failed).
# @INVARIANT: The length of the returned dict must equal the length of input orders.
#
# @PARAM: batch_id (str) - The unique identifier for the batch trace.
# @PARAM: orders (List[OrderDTO]) - List of immutable order objects.
# @RETURN: Dict[str, OrderStatus] - Result map.
#
# @SIDE_EFFECT: Writes audit logs to DB.
# @SIDE_EFFECT: Publishes 'ORDER_PROCESSED' event to MessageBus.
#
# @RELATION: CALLS -> InventoryService.reserve_items
# @RELATION: CALLS -> PaymentGateway.authorize
# @RELATION: WRITES_TO -> Database.AuditLog
def process_order_batch(batch_id: str, orders: List[OrderDTO]) -> Dict[str, OrderStatus]:
# 1. Structural Guard Logic (Handling @PRE)
if not orders:
return {}
# 2. Implementation with @INVARIANT in mind
results = {}
for order in orders:
# ... logic ...
pass
# 3. Completion (Logic naturally satisfies @POST)
return results
# [/DEF:process_order_batch:Function]
```
### 3. Svelte/JS Contract Style (JSDoc++)
Uses enhanced JSDoc. Since JS is less strict than Python, the contract acts as a strict typing and behavioral guard.
```javascript
// [DEF:handleUserLogin:Function]
/**
* @purpose Authenticates the user and synchronizes the local UI state.
* Handles the complete lifecycle from form submission to redirection.
*
* @pre LoginForm must be valid (validated by UI constraints).
* @pre Network must be available (optimistic check).
* @post SessionStore contains a valid JWT token.
* @post User is redirected to the Dashboard.
*
* @param {LoginCredentials} credentials - Email and password object.
* @returns {Promise<void>}
* @throws {NetworkError} If API is unreachable.
* @throws {AuthError} If credentials are invalid (401).
*
* @side_effect Updates global $session store.
* @side_effect Clears any existing error toasts.
*
* @algorithm 1. Set loading state -> 2. API Call -> 3. Decode Token -> 4. Update Store -> 5. Redirect.
*/
// @RELATION: CALLS -> api.auth.login
// @RELATION: MODIFIES_STATE_OF -> stores.session
// @RELATION: DISPATCHES -> 'toast:success'
async function handleUserLogin(credentials) {
// 1. Guard Clause (@PRE)
if (!isValid(credentials)) return;
try {
// ... logic ...
} catch (e) {
// Error handling (@THROW)
}
}
// [/DEF:handleUserLogin:Function]
```
---
### 4. Semantic Rules for Contracts
1. **Completeness:** A developer (or Agent) must be able to write the function body *solely* by reading the Contract, without guessing.
2. **No Implementation Leakage:** Describe *what* happens, not *how* (unless using `@ALGORITHM` for complexity reasons). E.g., say "Persists user" instead of "Inserts into users table via SQL".
3. **Active Voice:** Use active verbs (`Calculates`, `Updates`, `Enforces`) to stronger vector alignment.
4. **Graph Connectivity:** The `@RELATION` tags must explicitly link to other `[DEF:...]` IDs existing in the codebase. This builds the navigation graph for RAG.
---
## V. LOGGING STANDARD (BELIEF STATE)
Logs delineate the agent's internal state.
* **Python:** MUST use a Context Manager (e.g., `with belief_scope("ANCHOR_ID"):`) to ensure state consistency and automatic handling of Entry/Exit/Error states.
* Manual logging (inside scope): `logger.info(f"[{ANCHOR_ID}][{STATE}] Msg")`
* **Svelte/JS:** `console.log(\`[${ANCHOR_ID}][${STATE}] Msg\`)`
**Required States:**
1. `Entry` (Start of block - Auto-logged by Context Manager)
2. `Action` (Key business logic - Manual log)
3. `Coherence:OK` (Logic successfully completed - Auto-logged by Context Manager)
4. `Coherence:Failed` (Exception/Error - Auto-logged by Context Manager)
5. `Exit` (End of block - Auto-logged by Context Manager)
---
## VI. FRACTAL COMPLEXITY LIMIT
To maintain semantic coherence and avoid "Attention Sink" issues:
* **Module Size:** If a Module body exceeds ~300 lines (or logical complexity), it MUST be refactored into sub-modules or a package structure.
* **Function Size:** Functions should fit within a standard attention "chunk" (approx. 30-50 lines). If larger, logic MUST be decomposed into helper functions with their own contracts.
This ensures every vector embedding remains sharp and focused.
---
## VII. GENERATION WORKFLOW
1. **Context Analysis:** Identify language (Python vs Svelte) and Architecture Layer.
2. **Scaffolding:** Generate the `[DEF:...:...]` Anchors and Header/Contract **before** writing any logic.
3. **Implementation:** Write the code. Ensure the code logic handles the `@PRE` conditions (e.g., via `if/return` or guards) and satisfies `@POST` conditions naturally. **Do not write explicit `assert` statements unless debugging mode is requested.**
4. **Closure:** Ensure every `[DEF:...:...]` is closed with `[/DEF:...:...]` to accumulate semantic context.
ЕСЛИ ошибка или противоречие -> СТОП. Выведи `[COHERENCE_CHECK_FAILED]`.

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@
## Final Phase: Polish & Cross-cutting concerns
- [ ] T022 Implement task cleanup/retention policy (e.g., delete tasks older than 30 days)
- [ ] T023 Add real-time updates for task status using WebSockets (optional/refinement)
- [x] T023 Add real-time updates for task status using WebSockets (optional/refinement)
- [x] T024 Ensure consistent error handling and logging across scheduler and task manager
## Dependencies

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Frontend Navigation Redesign
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-26
**Feature**: [specs/015-frontend-nav-redesign/spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,18 @@
# Backup Contracts
## Component: BackupManager
### Props
None (Top-level page component)
### Events
- `on:backup-create`: Triggered when user requests a new backup.
- `on:backup-restore`: Triggered when user requests a restore.
### Data Dependencies
- `GET /api/environments`: Fetch list of available environments.
- `GET /api/storage/files?category=backups`: Fetch list of backup files.
- `POST /api/tasks`: Create new backup task.
- Body: `{ plugin_id: 'superset-backup', params: { environment_id: string } }`
- `PUT /api/environments/{id}/schedule`: Update backup schedule.
- Body: `{ enabled: boolean, cron_expression: string }`

View File

@@ -0,0 +1,47 @@
# Data Model: Frontend Navigation Redesign
## Plugin Configuration
The `PluginConfig` model is extended to support backend-driven navigation.
```python
class PluginConfig(BaseModel):
"""Pydantic model for plugin configuration."""
id: str = Field(..., description="Unique identifier for the plugin")
name: str = Field(..., description="Human-readable name for the plugin")
description: str = Field(..., description="Brief description of what the plugin does")
version: str = Field(..., description="Version of the plugin")
ui_route: Optional[str] = Field(None, description="Frontend route for the plugin UI")
input_schema: Dict[str, Any] = Field(..., description="JSON schema for input parameters", alias="schema")
```
### ui_route
- **Type**: `Optional[str]`
- **Description**: Specifies the client-side route (URL path) where the plugin's custom UI is hosted.
- **Behavior**:
- If `None` (default): The dashboard will open the plugin using the generic `DynamicForm` modal.
- If set (e.g., `"/tools/mapper"`): The dashboard will navigate (`goto`) to this route when the plugin card is clicked.
## Backup Management (New)
### Backup Types
```typescript
// frontend/src/types/backup.ts
export interface BackupFile {
name: string; // e.g., "prod-dashboard-export-2024.zip"
path: string; // Relative path in storage
size: number; // Bytes
created_at: string; // ISO Date
category: 'backups'; // Fixed category
mime_type?: string;
}
export interface BackupState {
isLoading: boolean;
files: BackupFile[];
error: string | null;
selectedBackup: BackupFile | null;
}

View File

@@ -0,0 +1,88 @@
# Implementation Plan: Frontend Navigation Redesign
**Branch**: `015-frontend-nav-redesign` | **Date**: 2026-01-26 | **Spec**: [specs/015-frontend-nav-redesign/spec.md](../spec.md)
**Input**: Feature specification from `specs/015-frontend-nav-redesign/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
## Summary
This feature redesigns the frontend navigation to shift from a Navbar-heavy approach to a Dashboard-centric model. Key changes include moving tool access (Mapper, Storage, Backups) to the Dashboard, simplifying the Navbar to global contexts (Tasks, Settings), removing deprecated features (Dataset Search, Environments), and implementing a dedicated Backup Management UI based on backend capabilities from feature 009.
Additionally, the navigation architecture is refactored to be backend-driven. Plugins now expose a `ui_route` property, allowing the frontend to dynamically determine the correct navigation path without hardcoded mapping.
## Technical Context
**Language/Version**: Python 3.9+ (Backend), Node.js 18+ (Frontend)
**Primary Dependencies**: FastAPI (Backend), SvelteKit + Tailwind CSS (Frontend)
**Storage**: N/A (UI reorganization and API integration)
**Testing**: Playwright (E2E - if available), Vitest (Unit)
**Target Platform**: Web Browser
**Project Type**: Web Application (Frontend + Backend)
**Performance Goals**: Instant navigation (<100ms), fast dashboard load
**Constraints**: Must maintain responsive design; Backup UI must interface with existing backend endpoints
**Scale/Scope**: ~5-10 file modifications, 1 new major component (BackupManager)
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- [x] **Semantic Protocol Compliance**: All new components will follow Svelte component header standards.
- [x] **Causal Validity**: Contracts (props/events) will be defined before implementation.
- [x] **Immutability of Architecture**: No core architectural changes; only UI reorganization.
- [x] **Design by Contract**: New Backup component will define clear interface contracts.
- [x] **Everything is a Plugin**: N/A (Frontend changes primarily, backend remains plugin-based).
- [x] **Unified Frontend Experience**: All new UI components will use standardized components and internationalization (i18n).
## Project Structure
### Documentation (this feature)
```text
specs/015-frontend-nav-redesign/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output
```
### Source Code (repository root)
```text
backend/
├── src/
│ ├── core/
│ │ ├── plugin_base.py # (Modify: Add ui_route property)
│ │ └── plugin_loader.py # (Modify: Populate ui_route in PluginConfig)
│ ├── plugins/ # (Modify: Implement ui_route in all plugins)
│ └── api/routes/ # (Verify backup routes exist)
frontend/
├── src/
│ ├── components/
│ │ ├── Navbar.svelte # (Modify: Simplify items)
│ │ ├── DashboardGrid.svelte # (Modify: Add tool links)
│ │ └── backups/ # (New: Backup UI)
│ │ ├── BackupManager.svelte
│ │ └── BackupList.svelte
│ ├── pages/
│ │ └── Dashboard.svelte # (Modify: Layout updates)
│ └── routes/
│ ├── +layout.svelte # (Check global nav injection)
│ ├── +page.svelte # (Modify: Use plugin.ui_route for navigation)
│ └── tools/
│ └── backups/ # (New Route)
│ └── +page.svelte
```
**Structure Decision**: Standard SvelteKit structure. New `backups` component directory for the complex backup UI. Route added under `tools/` to match existing pattern (mapper, storage).
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| N/A | | |

View File

@@ -0,0 +1,29 @@
# Quickstart: Frontend Navigation Redesign
## Overview
This feature reorganizes the application navigation to be dashboard-centric and introduces a dedicated UI for Backup Management.
## New Routes
- `/tools/backups`: The new Backup Management interface.
- `/`: Dashboard (updated with new tool links).
## Removed Routes
- `/tools/search`: Deprecated and removed.
- `/settings/environments`: Deprecated and removed.
## Development
### Running the Backup UI
1. Ensure the backend is running: `cd backend && uvicorn src.app:app --reload`
2. Start the frontend: `cd frontend && npm run dev`
3. Navigate to `http://localhost:5173/tools/backups`
### Key Components
- `frontend/src/components/backups/BackupManager.svelte`: Main container for backup operations.
- `frontend/src/components/DashboardGrid.svelte`: Updated grid with new tool cards.
- `frontend/src/components/Navbar.svelte`: Simplified navigation bar.
## Verification
1. Check Dashboard: Should see cards for Mapper, Storage, and Backups.
2. Check Navbar: Should ONLY show Tasks and Settings.
3. Check Backup Tool: Should load and display backup status/controls.

View File

@@ -0,0 +1,23 @@
# Research: Frontend Navigation Redesign
## Decisions
### 1. Backup Management UI Strategy
**Decision**: Create a dedicated `BackupManager` component in `frontend/src/components/backups/`.
**Rationale**: The requirement is to have a "full component" accessible from the dashboard. Separating it into its own directory ensures modularity and keeps the Dashboard component clean. It will consume the existing backup APIs (likely `/api/tasks` with specific backup types or a dedicated backup endpoint if one exists - *to be verified in Phase 1*).
**Alternatives considered**: Embedding backup controls directly in the Dashboard (rejected: clutters the main view), reusing the TaskRunner component (rejected: need specific backup context/history view).
### 2. Navigation State Management
**Decision**: Use SvelteKit's layout system (`+layout.svelte`) and simple component props for Navbar state.
**Rationale**: The Navbar changes are global. Removing items is a static change to the `Navbar.svelte` component. No complex state management (stores) is needed for this structural change.
**Alternatives considered**: Dynamic config-based menu (rejected: overkill for this specific redesign).
### 3. Deprecation Strategy
**Decision**: Hard removal of "Dataset Search" and "Deployment Environments" components and routes.
**Rationale**: The spec explicitly calls for removal. Keeping dead code increases maintenance burden.
**Alternatives considered**: Hiding behind a feature flag (rejected: requirement is explicit removal).
### 4. Dashboard Grid Layout
**Decision**: Update `DashboardGrid.svelte` to include new cards for Mapper, Storage, and Backups.
**Rationale**: Reusing the existing grid component maintains consistency.
**Alternatives considered**: Creating a separate "Tools" page (rejected: spec requires access from main Dashboard).

View File

@@ -0,0 +1,97 @@
# Feature Specification: Frontend Navigation Redesign
**Feature Branch**: `015-frontend-nav-redesign`
**Created**: 2026-01-26
**Status**: Draft
**Input**: User description: "Я хочу провести редизайн фронта в части навигации. 1. Удалить Dataset Search (из Navbar и дашборда), Deployment Environments 2. Вкладку Tasks оставить для просмотра всех задач - убрать оттуда кнопку Run backup 3. Должен быть полноценный компонент бэкапов, как мы разрабатывали в 009-backup-scheduler. Доступ - из дашборда главного 4. Перенести ссылку на Dataset mapper из Navbar на дашборд 5. Перенести ссылку на Storage manager на дашборд Общая логика - на дашборде должны быть ссылки на полноценные инструменты, навбар - для настроек и общей Tasks"
## Clarifications
### Session 2026-01-26
- Q: Do I need to build the Backup Management UI from scratch? → A: Yes, create the UI for backup using data from task 009-backup-scheduler.
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Centralized Tool Access via Dashboard (Priority: P1)
As a user, I want to access all main tools (Backups, Mapper, Storage) from the main Dashboard so that I have a central hub for operations.
**Why this priority**: This is the core of the redesign, shifting the navigation paradigm to a dashboard-centric model for tools.
**Independent Test**: Can be tested by verifying the Dashboard contains links/cards for Backups, Mapper, and Storage, and that clicking them navigates to the correct full-page tools.
**Acceptance Scenarios**:
1. **Given** I am on the main Dashboard, **When** I look at the available tools, **Then** I see options for "Backup Manager", "Dataset Mapper", and "Storage Manager".
2. **Given** I am on the Dashboard, **When** I click "Backup Manager", **Then** I am taken to the full Backup management interface.
3. **Given** I am on the Dashboard, **When** I click "Dataset Mapper", **Then** I am taken to the Mapper tool.
4. **Given** I am on the Dashboard, **When** I click "Storage Manager", **Then** I am taken to the Storage tool.
---
### User Story 2 - Simplified Navigation Bar (Priority: P1)
As a user, I want a clean Navbar containing only global context items (Tasks, Settings) so that the interface is less cluttered and navigation is distinct from tool usage.
**Why this priority**: Enforces the separation of concerns between "Global Status/Settings" (Navbar) and "Operational Tools" (Dashboard).
**Independent Test**: Can be tested by inspecting the Navbar across the application to ensure removed items (Search, Mapper, Environments) are gone and only Tasks and Settings remain.
**Acceptance Scenarios**:
1. **Given** I am on any page, **When** I view the Navbar, **Then** I do NOT see links for "Dataset Search", "Dataset Mapper", or "Deployment Environments".
2. **Given** I am on any page, **When** I view the Navbar, **Then** I see links for "Tasks" and "Settings".
3. **Given** I am on the Tasks page, **When** I look for the "Run backup" button, **Then** it is NOT present (as it belongs in the Backup tool).
---
### User Story 3 - Deprecation of Unused Features (Priority: P2)
As a user, I want removed features (Dataset Search, Deployment Environments) to be inaccessible so that I don't use deprecated workflows.
**Why this priority**: Cleans up the UI and prevents confusion with features that are being removed or hidden.
**Independent Test**: Verify that UI elements for Dataset Search and Deployment Environments are removed from both Navbar and Dashboard.
**Acceptance Scenarios**:
1. **Given** I am on the Dashboard, **When** I look for "Dataset Search", **Then** it is not visible.
2. **Given** I am on the Dashboard or Navbar, **When** I look for "Deployment Environments", **Then** it is not visible.
### Edge Cases
- **Direct URL Access**: If a user attempts to access the URL of a removed page (e.g., `/search` or `/environments`) via bookmark or history, they should be redirected to the Dashboard or shown a 404 page (standard app behavior).
- **Mobile View**: The simplified Navbar must remain responsive; with fewer items, it should likely avoid collapsing into a hamburger menu unless necessary on very small screens.
### Assumptions
- The backend logic and UI components for the "Backup Scheduler" (Feature 009) are available and ready to be integrated into the main Dashboard view.
- Existing tools (Dataset Mapper, Storage Manager) function independently of the Navbar context and will work correctly when accessed via the Dashboard.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST display "Dataset Mapper" entry point on the main Dashboard.
- **FR-002**: System MUST display "Storage Manager" entry point on the main Dashboard.
- **FR-003**: System MUST display "Backup Scheduler" (or similar name) entry point on the main Dashboard.
- **FR-004**: System MUST provide access to the full Backup Management component (newly created based on feature 009 data) via the Dashboard link.
- **FR-005**: Navbar MUST NOT contain links to "Dataset Search", "Dataset Mapper", or "Deployment Environments".
- **FR-006**: Dashboard MUST NOT contain "Dataset Search" widget or link.
- **FR-007**: Tasks page MUST NOT show the "Run backup" button (backup initiation moves to Backup tool).
- **FR-008**: Navbar MUST retain "Tasks" and "Settings" links.
- **FR-009**: Backup Manager MUST support configuring automated backup schedules (enabled/disabled, cron expression) per environment.
- **FR-010**: Backup List MUST provide a "Go to Storage" action that navigates to the Storage Manager with the correct path selected.
### Key Entities
- **Dashboard**: The main landing page serving as the registry for tools.
- **Navbar**: The persistent top navigation for global application state/config.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can navigate to Dataset Mapper, Storage Manager, and Backup Manager within 1 click from the Dashboard.
- **SC-002**: Navbar contains strictly 0 links to operational tools (Mapper, Search, Storage), containing only Tasks and Settings.
- **SC-003**: "Run backup" action is successfully performed via the new Dashboard -> Backup route.

View File

@@ -0,0 +1,116 @@
# Tasks: Frontend Navigation Redesign
**Feature Branch**: `015-frontend-nav-redesign`
**Spec**: [specs/015-frontend-nav-redesign/spec.md](../spec.md)
**Plan**: [specs/015-frontend-nav-redesign/plan.md](../plan.md)
## Phase 1: Setup
*Goal: Initialize project structure for the new feature.*
- [x] T001 Create backup component directory structure
- Path: `frontend/src/components/backups/`
- [x] T002 Create backup page route directory
- Path: `frontend/src/routes/tools/backups/`
## Phase 2: Foundational
*Goal: Prepare core components for integration and verify backend connectivity.*
- [x] T003 Verify backend API endpoints for backups (via `009-backup-scheduler`)
- Path: `backend/src/api/routes/tasks.py` (or relevant backup route)
- [x] T004 Define Backup types and interfaces in frontend
- Path: `frontend/src/types/backup.ts`
## Phase 3: User Story 1 - Centralized Tool Access via Dashboard
*Goal: Update the main dashboard to include all tool entry points.*
**User Story**: As a user, I want to access all main tools (Backups, Mapper, Storage) from the main Dashboard so that I have a central hub for operations. (P1)
- [x] T005 [US1] Update DashboardGrid to include "Backup Manager" card
- Path: `frontend/src/components/DashboardGrid.svelte`
- [x] T006 [US1] Update DashboardGrid to ensure "Dataset Mapper" and "Storage Manager" cards are present
- Path: `frontend/src/components/DashboardGrid.svelte`
- [x] T007 [US1] Remove "Dataset Search" card from DashboardGrid
- Path: `frontend/src/components/DashboardGrid.svelte`
## Phase 4: User Story 2 - Simplified Navigation Bar
*Goal: Clean up the Navbar to show only global context items.*
**User Story**: As a user, I want a clean Navbar containing only global context items (Tasks, Settings) so that the interface is less cluttered and navigation is distinct from tool usage. (P1)
- [x] T008 [US2] Remove "Dataset Search" link from Navbar
- Path: `frontend/src/components/Navbar.svelte`
- [x] T009 [US2] Remove "Dataset Mapper" link from Navbar
- Path: `frontend/src/components/Navbar.svelte`
- [x] T010 [US2] Remove "Deployment Environments" link from Navbar
- Path: `frontend/src/components/Navbar.svelte`
- [x] T011 [US2] Verify "Tasks" and "Settings" links remain in Navbar
- Path: `frontend/src/components/Navbar.svelte`
- [x] T012 [US2] Remove "Run backup" button from Tasks page
- Path: `frontend/src/routes/tasks/+page.svelte` (or relevant component)
## Phase 5: Backup Management UI
*Goal: Implement the dedicated Backup Management interface.*
**User Story**: (Implicit P1 from FR-004) System MUST provide access to the full Backup Management component via the Dashboard link.
- [x] T013 [US1] Create BackupList component to display existing backups (Must use `src/lib/ui` components and `src/lib/i18n`)
- Path: `frontend/src/components/backups/BackupList.svelte`
- [x] T014 [US1] Create BackupManager main component (container) (Must use `src/lib/ui` components and `src/lib/i18n`)
- Path: `frontend/src/components/backups/BackupManager.svelte`
- [x] T015 [US1] Implement "Create Backup" functionality in BackupManager (Must use `src/lib/ui` components and `src/lib/i18n`)
- Path: `frontend/src/components/backups/BackupManager.svelte`
- [x] T016 [US1] Implement "Restore Backup" functionality (if supported by backend) (Must use `src/lib/ui` components and `src/lib/i18n`)
- Path: `frontend/src/components/backups/BackupManager.svelte`
- [x] T017 [US1] Create Backup page to host the manager (Must use `src/lib/ui` components and `src/lib/i18n`)
- Path: `frontend/src/routes/tools/backups/+page.svelte`
- [x] T017b [US1] Implement Backup Schedule configuration in BackupManager
- Path: `frontend/src/components/backups/BackupManager.svelte`
- [x] T017c [US1] Implement "Go to Storage" navigation in BackupList
- Path: `frontend/src/components/backups/BackupList.svelte`
## Phase 6: User Story 3 - Deprecation
*Goal: Remove deprecated routes and code.*
**User Story**: As a user, I want removed features (Dataset Search, Deployment Environments) to be inaccessible so that I don't use deprecated workflows. (P2)
- [x] T018 [US3] Delete Dataset Search route
- Path: `frontend/src/routes/tools/search/` (delete directory)
- [x] T019 [US3] Delete Deployment Environments route
- Path: `frontend/src/routes/settings/environments/` (delete directory)
- [x] T020 [US3] Delete Dataset Search component (if not used elsewhere)
- Path: `frontend/src/components/tools/SearchTool.svelte`
- [x] T021 [US3] Delete EnvSelector component (if not used elsewhere)
- Path: `frontend/src/components/EnvSelector.svelte`
## Phase 7: Polish & Cross-Cutting
*Goal: Final verification and cleanup.*
- [x] T022 Verify all navigation links work correctly
- Path: `frontend/src/components/Navbar.svelte`
- [x] T023 Verify responsive layout of new Dashboard grid
- Path: `frontend/src/components/DashboardGrid.svelte`
- [x] T024 Ensure i18n strings are extracted for new Backup UI
- Path: `frontend/src/lib/i18n/` (or relevant locale files)
- [x] T025 Verify "Run backup" action successfully triggers backup job (Manual/E2E check)
- Path: `frontend/src/components/backups/BackupManager.svelte`
## Dependencies
1. **Phase 1 & 2** must be completed first.
2. **Phase 3 (Dashboard)** and **Phase 4 (Navbar)** can be done in parallel.
3. **Phase 5 (Backup UI)** depends on Phase 1 & 2.
4. **Phase 6 (Deprecation)** should be done last to ensure no regressions before removal.
## Implementation Strategy
1. **Setup**: Create the new directory structure.
2. **Dashboard & Navbar**: Quick wins to reshape the navigation.
3. **Backup UI**: The core development effort. Connect to existing backend.
4. **Cleanup**: Remove old code once the new flows are verified.

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Multi-User Authentication and Authorization
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-26
**Feature**: [Link to spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`

View File

@@ -0,0 +1,39 @@
# Security Requirements Checklist: Multi-User Auth
**Purpose**: Validate completeness and rigor of security requirements for authentication and authorization.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Authentication Security
- [x] CHK001 Are password complexity requirements specified for local users? [Completeness, Gap] (Covered by T037)
- [x] CHK002 Is the exact hashing algorithm (bcrypt) and work factor specified? [Clarity, Spec §Research] (Covered by T006)
- [x] CHK003 Are account lockout policies defined for failed login attempts? [Coverage, Gap] (Covered by T033)
- [x] CHK004 Is the behavior for inactive/disabled accounts explicitly defined for both local and ADFS users? [Edge Case, Spec §Edge Cases] (Covered by T044)
- [x] CHK005 Are requirements defined for session revocation (e.g., logout, admin action)? [Completeness] (Covered by T043)
## ADFS & SSO Security
- [x] CHK006 Are token validation requirements (signature, issuer, audience) specified for ADFS OIDC tokens? [Completeness] (Covered by T007)
- [x] CHK007 Is the mapping behavior defined when an ADFS user is removed from a mapped AD group? [Edge Case, Gap] (Covered by T028)
- [x] CHK008 Are requirements defined for handling ADFS token expiration and refresh? [Coverage] (Covered by T046)
- [x] CHK009 Is the JIT provisioning process secure against privilege escalation (e.g., default role)? [Security, Spec §FR-008] (Covered by T028)
## Authorization & RBAC
- [x] CHK010 Are "default deny" requirements specified for plugin access? [Clarity, Spec §SC-002] (Covered by T020)
- [x] CHK011 Is the behavior defined when a user has multiple roles with conflicting permissions? [Edge Case, Gap] (Covered by T045)
- [x] CHK012 Are requirements specified for preventing admins from removing their own admin privileges (lockout prevention)? [Edge Case] (Covered by T022)
- [x] CHK013 Is the scope of "Execute" vs "Read" permission clearly defined for each plugin? [Clarity] (Covered by T019)
## Data Protection
- [x] CHK014 Are requirements defined for protecting sensitive data (passwords, tokens) in logs? [Completeness, Spec §Constitution] (Covered by T047)
- [x] CHK015 Are HttpOnly and Secure flags required for session cookies? [Clarity, Spec §Research] (Covered by T032)
- [x] CHK016 Is the storage mechanism for ADFS client secrets defined securely? [Completeness] (Covered by T002)
## API Security
- [x] CHK017 Are authentication requirements enforced on ALL API endpoints (except login)? [Coverage] (Covered by T021)
- [x] CHK018 Are rate limiting requirements defined for login endpoints to prevent brute force? [Gap] (Covered by T033)
- [x] CHK019 Are error messages required to be generic to avoid username enumeration? [Clarity] (Covered by T034)

View File

@@ -0,0 +1,31 @@
# Technical Readiness Checklist: Multi-User Auth
**Purpose**: Validate technical specifications, schema, and API contracts.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Data Model & Schema
- [x] CHK001 Are all necessary fields defined for the `User` entity (e.g., last_login)? [Completeness, Spec §Data Model] (Covered by T004)
- [x] CHK002 Are foreign key constraints explicitly defined for `ADGroupMapping`? [Clarity, Spec §Data Model] (Covered by T027)
- [x] CHK003 Is the uniqueness constraint for `username` and `email` specified? [Consistency] (Covered by T004)
- [x] CHK004 Are database migration requirements defined for the new `auth.db`? [Completeness, Gap] (Covered by T005)
## API Contracts
- [x] CHK005 Are request/response schemas defined for the `login` endpoint? [Completeness, Spec §Contracts] (Covered by T009)
- [x] CHK006 Are error response codes (401, 403, 404) standardized across all auth endpoints? [Consistency] (Covered by T012)
- [x] CHK007 Is the structure of the JWT payload (claims) explicitly defined? [Clarity, Spec §Research] (Covered by T007)
- [x] CHK008 Are pagination requirements defined for the "List Users" admin endpoint? [Gap] (Covered by T023)
## Dependencies & Integration
- [x] CHK009 Are version requirements specified for `Authlib` and `Passlib`? [Clarity, Spec §Plan] (Covered by T001)
- [x] CHK010 Is the dependency on the existing `TaskManager` for plugin execution defined? [Integration] (Covered by T021)
- [x] CHK011 Are requirements defined for the CLI admin creation tool? [Completeness, Spec §FR-009] (Covered by T008)
## Non-Functional Requirements
- [x] CHK012 Is the maximum acceptable latency for auth verification specified? [Clarity, Spec §Plan] (Covered by T013)
- [x] CHK013 Are concurrency requirements defined for the SQLite `auth.db` (WAL mode)? [Completeness, Spec §Research] (Covered by T003)
- [x] CHK014 Are logging requirements defined for audit trails (who did what)? [Completeness] (Covered by T047)

View File

@@ -0,0 +1,26 @@
# Testing Requirements Checklist: Multi-User Auth
**Purpose**: Validate test scenario coverage and strategy.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Functional Coverage
- [x] CHK001 Are positive test scenarios defined for Local Login? [Coverage, Spec §US-1] (Covered by T049)
- [x] CHK002 Are positive test scenarios defined for ADFS Login (mocked)? [Coverage, Spec §US-3] (Covered by T050)
- [x] CHK003 Are negative test scenarios defined for invalid passwords? [Coverage] (Covered by T049)
- [x] CHK004 Are negative test scenarios defined for unauthorized plugin access? [Coverage, Spec §US-2] (Covered by T049)
- [x] CHK005 Are test scenarios defined for switching between auth methods on the same screen? [Coverage] (Covered by T050)
## Edge Cases
- [x] CHK005 Are test scenarios defined for mixed-case username handling? [Edge Case] (Covered by T049)
- [x] CHK006 Are test scenarios defined for ADFS JIT provisioning with missing groups? [Edge Case] (Covered by T050)
- [x] CHK007 Are test scenarios defined for accessing the API with an expired token? [Edge Case] (Covered by T049)
- [x] CHK008 Are test scenarios defined for concurrent login sessions? [Edge Case] (Covered by T049)
## Integration & System
- [x] CHK009 Is the strategy defined for mocking ADFS during CI/CD tests? [Completeness] (Covered by T041)
- [x] CHK010 Are end-to-end tests required for the full admin user creation flow? [Coverage] (Covered by T050)
- [x] CHK011 Are tests required to verify the CLI admin creation tool? [Coverage] (Covered by T049)

View File

@@ -0,0 +1,31 @@
# UX Requirements Checklist: Multi-User Auth
**Purpose**: Validate user experience and interface requirements.
**Created**: 2026-01-27
**Feature**: [Link to spec.md](../spec.md)
## Login Flow
- [x] CHK001 Are feedback requirements defined for invalid credentials (generic message)? [Clarity, Spec §US-1] (Covered by T016)
- [x] CHK002 Is the redirect behavior specified after successful login (dashboard vs deep link)? [Clarity, Spec §US-1] (Covered by T016)
- [x] CHK003 Are loading states required during the ADFS redirection process? [Completeness] (Covered by T030)
- [x] CHK004 Is the "Session Expired" user flow defined? [Edge Case, Gap] (Covered by T035)
- [x] CHK005 Are requirements defined for the dual-mode login screen layout (Form + ADFS Button)? [Clarity, Spec §FR-013] (Covered by T030)
## Admin Interface
- [x] CHK005 Are requirements defined for the User Management list view (columns, sorting)? [Completeness] (Covered by T024)
- [x] CHK006 Is the feedback mechanism defined for successful/failed user creation? [Clarity] (Covered by T024)
- [x] CHK007 Are confirmation dialogs required for deleting users? [Safety, Gap] (Covered by T040)
- [x] CHK008 Is the UI behavior defined when assigning roles (dropdown, search)? [Clarity] (Covered by T024)
## Navigation & Visibility
- [x] CHK009 Are requirements defined for hiding menu items the user lacks permission for? [Completeness, Spec §FR-006] (Covered by T025)
- [x] CHK010 Is the behavior defined if a user tries to access a restricted URL directly? [Edge Case] (Covered by T042)
- [x] CHK011 Are user profile/logout controls required to be visible on all pages? [Consistency] (Covered by T025)
## Accessibility
- [x] CHK012 Are keyboard navigation requirements defined for the login form? [Coverage] (Covered by T048)
- [x] CHK013 Are error message accessibility requirements (ARIA alerts) specified? [Coverage] (Covered by T048)

Some files were not shown because too many files have changed in this diff Show More