Compare commits
9 Commits
014-file-s
...
016-multi-
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e0e26e2f7 | |||
| 18b42f8dd0 | |||
| e7b31accd6 | |||
| d3c3a80ed2 | |||
| cc244c2d86 | |||
| d10c23e658 | |||
| 1042b35d1b | |||
| 16ffeb1ed6 | |||
| da34deac02 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
4
.kilocode/workflows/read_semantic.md
Normal file
4
.kilocode/workflows/read_semantic.md
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
description: USE SEMANTIC
|
||||
---
|
||||
Прочитай semantic_protocol.md. ОБЯЗАТЕЛЬНО используй его при разработке
|
||||
@@ -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
BIN
backend/backend/auth.db
Normal file
Binary file not shown.
137345
backend/logs/app.log.1
137345
backend/logs/app.log.1
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
310
backend/src/api/routes/admin.py
Normal file
310
backend/src/api/routes/admin.py
Normal 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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
"""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"])
|
||||
|
||||
45
backend/src/core/auth/config.py
Normal file
45
backend/src/core/auth/config.py
Normal 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]
|
||||
54
backend/src/core/auth/jwt.py
Normal file
54
backend/src/core/auth/jwt.py
Normal 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]
|
||||
31
backend/src/core/auth/logger.py
Normal file
31
backend/src/core/auth/logger.py
Normal 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]
|
||||
51
backend/src/core/auth/oauth.py
Normal file
51
backend/src/core/auth/oauth.py
Normal 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]
|
||||
123
backend/src/core/auth/repository.py
Normal file
123
backend/src/core/auth/repository.py
Normal 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]
|
||||
42
backend/src/core/auth/security.py
Normal file
42
backend/src/core/auth/security.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
105
backend/src/models/auth.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
128
backend/src/schemas/auth.py
Normal 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]
|
||||
82
backend/src/scripts/create_admin.py
Normal file
82
backend/src/scripts/create_admin.py
Normal 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]
|
||||
44
backend/src/scripts/init_auth_db.py
Normal file
44
backend/src/scripts/init_auth_db.py
Normal 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]
|
||||
116
backend/src/scripts/seed_permissions.py
Normal file
116
backend/src/scripts/seed_permissions.py
Normal 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]
|
||||
115
backend/src/services/auth_service.py
Normal file
115
backend/src/services/auth_service.py
Normal 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]
|
||||
@@ -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]
|
||||
|
||||
BIN
backend/tasks.db
BIN
backend/tasks.db
Binary file not shown.
162
backend/tests/test_auth.py
Normal file
162
backend/tests/test_auth.py
Normal 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
|
||||
@@ -57,4 +57,4 @@
|
||||
/* Component specific styles */
|
||||
</style>
|
||||
|
||||
<!-- [/DEF:EnvSelector:Component] -->
|
||||
<!-- [/DEF:EnvSelector:Component] -->
|
||||
@@ -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] -->
|
||||
|
||||
@@ -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);
|
||||
|
||||
61
frontend/src/components/auth/ProtectedRoute.svelte
Normal file
61
frontend/src/components/auth/ProtectedRoute.svelte
Normal 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] -->
|
||||
84
frontend/src/components/backups/BackupList.svelte
Normal file
84
frontend/src/components/backups/BackupList.svelte
Normal 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] -->
|
||||
241
frontend/src/components/backups/BackupManager.svelte
Normal file
241
frontend/src/components/backups/BackupManager.svelte
Normal 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] -->
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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] -->
|
||||
@@ -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] -->
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
236
frontend/src/routes/admin/roles/+page.svelte
Normal file
236
frontend/src/routes/admin/roles/+page.svelte
Normal 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] -->
|
||||
213
frontend/src/routes/admin/settings/+page.svelte
Normal file
213
frontend/src/routes/admin/settings/+page.svelte
Normal 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] -->
|
||||
284
frontend/src/routes/admin/users/+page.svelte
Normal file
284
frontend/src/routes/admin/users/+page.svelte
Normal 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] -->
|
||||
@@ -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 = [];
|
||||
|
||||
166
frontend/src/routes/login/+page.svelte
Normal file
166
frontend/src/routes/login/+page.svelte
Normal 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] -->
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
27
frontend/src/routes/tools/backups/+page.svelte
Normal file
27
frontend/src/routes/tools/backups/+page.svelte
Normal 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] -->
|
||||
@@ -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] -->
|
||||
@@ -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
|
||||
|
||||
241
frontend/src/services/adminService.js
Normal file
241
frontend/src/services/adminService.js
Normal 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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
22
frontend/src/types/backup.ts
Normal file
22
frontend/src/types/backup.ts
Normal 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
@@ -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
@@ -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
|
||||
|
||||
34
specs/015-frontend-nav-redesign/checklists/requirements.md
Normal file
34
specs/015-frontend-nav-redesign/checklists/requirements.md
Normal 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`
|
||||
@@ -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 }`
|
||||
47
specs/015-frontend-nav-redesign/data-model.md
Normal file
47
specs/015-frontend-nav-redesign/data-model.md
Normal 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;
|
||||
}
|
||||
88
specs/015-frontend-nav-redesign/plan.md
Normal file
88
specs/015-frontend-nav-redesign/plan.md
Normal 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 | | |
|
||||
29
specs/015-frontend-nav-redesign/quickstart.md
Normal file
29
specs/015-frontend-nav-redesign/quickstart.md
Normal 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.
|
||||
23
specs/015-frontend-nav-redesign/research.md
Normal file
23
specs/015-frontend-nav-redesign/research.md
Normal 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).
|
||||
97
specs/015-frontend-nav-redesign/spec.md
Normal file
97
specs/015-frontend-nav-redesign/spec.md
Normal 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.
|
||||
116
specs/015-frontend-nav-redesign/tasks.md
Normal file
116
specs/015-frontend-nav-redesign/tasks.md
Normal 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.
|
||||
34
specs/016-multi-user-auth/checklists/requirements.md
Normal file
34
specs/016-multi-user-auth/checklists/requirements.md
Normal 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`
|
||||
39
specs/016-multi-user-auth/checklists/security.md
Normal file
39
specs/016-multi-user-auth/checklists/security.md
Normal 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)
|
||||
31
specs/016-multi-user-auth/checklists/technical.md
Normal file
31
specs/016-multi-user-auth/checklists/technical.md
Normal 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)
|
||||
26
specs/016-multi-user-auth/checklists/testing.md
Normal file
26
specs/016-multi-user-auth/checklists/testing.md
Normal 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)
|
||||
31
specs/016-multi-user-auth/checklists/ux.md
Normal file
31
specs/016-multi-user-auth/checklists/ux.md
Normal 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
Reference in New Issue
Block a user