Compare commits
10 Commits
ad6d16c97e
...
23baceed71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23baceed71 | ||
|
|
c4b9789df7 | ||
|
|
d79ed7c6fc | ||
|
|
42f9124f77 | ||
|
|
f9ad9417ba | ||
|
|
f5848a360f | ||
|
|
c6cb9d6818 | ||
|
|
1ec132184f | ||
|
|
42f04ef961 | ||
|
|
096136e9d5 |
@@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE=
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
AUTHENTIK_TOKEN_ENDPOINT=
|
||||
AUTHENTIK_USERINFO_ENDPOINT=
|
||||
|
||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||
|
||||
@@ -17,6 +17,7 @@ AUTHENTIK_AUDIENCE=
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
AUTHENTIK_TOKEN_ENDPOINT=
|
||||
AUTHENTIK_USERINFO_ENDPOINT=
|
||||
|
||||
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
|
||||
INTERNAL_SHARED_SECRET=CHANGE_ME
|
||||
|
||||
@@ -31,6 +31,7 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
|
||||
- `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
|
||||
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
|
||||
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
|
||||
- `AUTHENTIK_USERINFO_ENDPOINT` (optional, default inferred from issuer/base URL; used to fill missing email/name claims)
|
||||
|
||||
## Authentik Admin API setup
|
||||
|
||||
@@ -42,7 +43,8 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
|
||||
## Main APIs
|
||||
|
||||
- `GET /healthz`
|
||||
- `POST /auth/login`
|
||||
- `GET /auth/oidc/url`
|
||||
- `POST /auth/oidc/exchange`
|
||||
- `GET /me` (Bearer token required)
|
||||
- `GET /me/permissions/snapshot` (Bearer token required)
|
||||
- `POST /internal/users/upsert-by-sub`
|
||||
@@ -50,3 +52,16 @@ python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
|
||||
- `POST /internal/authentik/users/ensure`
|
||||
- `POST /admin/permissions/grant`
|
||||
- `POST /admin/permissions/revoke`
|
||||
- `GET|POST /admin/systems`
|
||||
- `GET|POST /admin/modules`
|
||||
- `GET|POST /admin/companies`
|
||||
- `GET|POST /admin/sites`
|
||||
- `GET /admin/members`
|
||||
- `GET|POST /admin/permission-groups`
|
||||
- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
|
||||
- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
|
||||
- `GET /internal/systems`
|
||||
- `GET /internal/modules`
|
||||
- `GET /internal/companies`
|
||||
- `GET /internal/sites`
|
||||
- `GET /internal/members`
|
||||
|
||||
@@ -3,7 +3,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.api_client import ApiClient
|
||||
from app.repositories.companies_repo import CompaniesRepository
|
||||
from app.repositories.modules_repo import ModulesRepository
|
||||
from app.repositories.permissions_repo import PermissionsRepository
|
||||
from app.repositories.sites_repo import SitesRepository
|
||||
from app.repositories.systems_repo import SystemsRepository
|
||||
from app.repositories.users_repo import UsersRepository
|
||||
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
|
||||
from app.security.api_client_auth import require_api_client
|
||||
@@ -11,6 +15,36 @@ from app.security.api_client_auth import require_api_client
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
|
||||
systems_repo = SystemsRepository(db)
|
||||
modules_repo = ModulesRepository(db)
|
||||
system = systems_repo.get_by_key(system_key)
|
||||
if not system:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||
|
||||
target_module_key = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__"
|
||||
module = modules_repo.get_by_key(target_module_key)
|
||||
if not module:
|
||||
module = modules_repo.create(module_key=target_module_key, name=target_module_key, status="active")
|
||||
return module.id
|
||||
|
||||
|
||||
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
if scope_type == "company":
|
||||
company = companies_repo.get_by_key(scope_id)
|
||||
if not company:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
return company.id, None
|
||||
if scope_type == "site":
|
||||
site = sites_repo.get_by_key(scope_id)
|
||||
if not site:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
||||
return None, site.id
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
|
||||
|
||||
|
||||
@router.post("/permissions/grant")
|
||||
def grant_permission(
|
||||
payload: PermissionGrantRequest,
|
||||
@@ -26,12 +60,15 @@ def grant_permission(
|
||||
display_name=payload.display_name,
|
||||
is_active=True,
|
||||
)
|
||||
module_id = _resolve_module_id(db, payload.system, payload.module)
|
||||
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
|
||||
permission = perms_repo.create_if_not_exists(
|
||||
user_id=user.id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
module=payload.module,
|
||||
module_id=module_id,
|
||||
action=payload.action,
|
||||
scope_type=payload.scope_type,
|
||||
company_id=company_id,
|
||||
site_id=site_id,
|
||||
)
|
||||
|
||||
return {"permission_id": permission.id, "result": "granted"}
|
||||
@@ -50,11 +87,14 @@ def revoke_permission(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
|
||||
|
||||
module_id = _resolve_module_id(db, payload.system, payload.module)
|
||||
company_id, site_id = _resolve_scope_ids(db, payload.scope_type, payload.scope_id)
|
||||
deleted = perms_repo.revoke(
|
||||
user_id=user.id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
module=payload.module,
|
||||
module_id=module_id,
|
||||
action=payload.action,
|
||||
scope_type=payload.scope_type,
|
||||
company_id=company_id,
|
||||
site_id=site_id,
|
||||
)
|
||||
return {"deleted": deleted, "result": "revoked"}
|
||||
|
||||
328
backend/app/api/admin_catalog.py
Normal file
328
backend/app/api/admin_catalog.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.models.api_client import ApiClient
|
||||
from app.repositories.companies_repo import CompaniesRepository
|
||||
from app.repositories.modules_repo import ModulesRepository
|
||||
from app.repositories.permission_groups_repo import PermissionGroupsRepository
|
||||
from app.repositories.sites_repo import SitesRepository
|
||||
from app.repositories.systems_repo import SystemsRepository
|
||||
from app.repositories.users_repo import UsersRepository
|
||||
from app.schemas.catalog import (
|
||||
CompanyCreateRequest,
|
||||
CompanyItem,
|
||||
MemberItem,
|
||||
ModuleCreateRequest,
|
||||
ModuleItem,
|
||||
PermissionGroupCreateRequest,
|
||||
PermissionGroupItem,
|
||||
SiteCreateRequest,
|
||||
SiteItem,
|
||||
SystemCreateRequest,
|
||||
SystemItem,
|
||||
)
|
||||
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
|
||||
from app.security.api_client_auth import require_api_client
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def _resolve_module_id(db: Session, system_key: str, module_key: str | None) -> str:
|
||||
systems_repo = SystemsRepository(db)
|
||||
modules_repo = ModulesRepository(db)
|
||||
system = systems_repo.get_by_key(system_key)
|
||||
if not system:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||
target_module_key = f"{system_key}.{module_key}" if module_key else f"{system_key}.__system__"
|
||||
module = modules_repo.get_by_key(target_module_key)
|
||||
if not module:
|
||||
module = modules_repo.create(module_key=target_module_key, name=target_module_key, status="active")
|
||||
return module.id
|
||||
|
||||
|
||||
def _resolve_scope_ids(db: Session, scope_type: str, scope_id: str) -> tuple[str | None, str | None]:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
if scope_type == "company":
|
||||
company = companies_repo.get_by_key(scope_id)
|
||||
if not company:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
return company.id, None
|
||||
if scope_type == "site":
|
||||
site = sites_repo.get_by_key(scope_id)
|
||||
if not site:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="site_not_found")
|
||||
return None, site.id
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_scope_type")
|
||||
|
||||
|
||||
@router.get("/systems")
|
||||
def list_systems(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
repo = SystemsRepository(db)
|
||||
items, total = repo.list(limit=limit, offset=offset)
|
||||
return {"items": [SystemItem(id=i.id, system_key=i.system_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/systems", response_model=SystemItem)
|
||||
def create_system(
|
||||
payload: SystemCreateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SystemItem:
|
||||
repo = SystemsRepository(db)
|
||||
if repo.get_by_key(payload.system_key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="system_key_already_exists")
|
||||
row = repo.create(system_key=payload.system_key, name=payload.name, status=payload.status)
|
||||
return SystemItem(id=row.id, system_key=row.system_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/modules")
|
||||
def list_modules(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
modules_repo = ModulesRepository(db)
|
||||
items, total = modules_repo.list(limit=limit, offset=offset)
|
||||
out = []
|
||||
for i in items:
|
||||
system_key = i.module_key.split(".", 1)[0] if "." in i.module_key else None
|
||||
out.append(
|
||||
ModuleItem(
|
||||
id=i.id,
|
||||
system_key=system_key,
|
||||
module_key=i.module_key,
|
||||
name=i.name,
|
||||
status=i.status,
|
||||
).model_dump()
|
||||
)
|
||||
return {"items": out, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/modules", response_model=ModuleItem)
|
||||
def create_module(
|
||||
payload: ModuleCreateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ModuleItem:
|
||||
systems_repo = SystemsRepository(db)
|
||||
modules_repo = ModulesRepository(db)
|
||||
system = systems_repo.get_by_key(payload.system_key)
|
||||
if not system:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="system_not_found")
|
||||
full_module_key = f"{payload.system_key}.{payload.module_key}"
|
||||
if modules_repo.get_by_key(full_module_key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="module_key_already_exists")
|
||||
row = modules_repo.create(
|
||||
module_key=full_module_key,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
)
|
||||
return ModuleItem(id=row.id, system_key=payload.system_key, module_key=row.module_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/companies")
|
||||
def list_companies(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
keyword: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
repo = CompaniesRepository(db)
|
||||
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
||||
return {"items": [CompanyItem(id=i.id, company_key=i.company_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/companies", response_model=CompanyItem)
|
||||
def create_company(
|
||||
payload: CompanyCreateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> CompanyItem:
|
||||
repo = CompaniesRepository(db)
|
||||
if repo.get_by_key(payload.company_key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="company_key_already_exists")
|
||||
row = repo.create(company_key=payload.company_key, name=payload.name, status=payload.status)
|
||||
return CompanyItem(id=row.id, company_key=row.company_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/sites")
|
||||
def list_sites(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
company_key: str | None = Query(default=None),
|
||||
keyword: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
company_lookup: dict[str, str] = {}
|
||||
all_companies, _ = companies_repo.list(limit=1000, offset=0)
|
||||
for c in all_companies:
|
||||
company_lookup[c.id] = c.company_key
|
||||
company_id = None
|
||||
if company_key:
|
||||
company = companies_repo.get_by_key(company_key)
|
||||
if not company:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
company_id = company.id
|
||||
items, total = sites_repo.list(keyword=keyword, company_id=company_id, limit=limit, offset=offset)
|
||||
return {
|
||||
"items": [
|
||||
SiteItem(
|
||||
id=i.id,
|
||||
site_key=i.site_key,
|
||||
company_key=company_lookup.get(i.company_id, ""),
|
||||
name=i.name,
|
||||
status=i.status,
|
||||
).model_dump()
|
||||
for i in items
|
||||
],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sites", response_model=SiteItem)
|
||||
def create_site(
|
||||
payload: SiteCreateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SiteItem:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
company = companies_repo.get_by_key(payload.company_key)
|
||||
if not company:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="company_not_found")
|
||||
if sites_repo.get_by_key(payload.site_key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="site_key_already_exists")
|
||||
row = sites_repo.create(site_key=payload.site_key, company_id=company.id, name=payload.name, status=payload.status)
|
||||
return SiteItem(id=row.id, site_key=row.site_key, company_key=payload.company_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.get("/members")
|
||||
def list_members(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
keyword: str | None = Query(default=None),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
users_repo = UsersRepository(db)
|
||||
items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset)
|
||||
return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/permission-groups")
|
||||
def list_permission_groups(
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
repo = PermissionGroupsRepository(db)
|
||||
items, total = repo.list(limit=limit, offset=offset)
|
||||
return {"items": [PermissionGroupItem(id=i.id, group_key=i.group_key, name=i.name, status=i.status).model_dump() for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/permission-groups", response_model=PermissionGroupItem)
|
||||
def create_permission_group(
|
||||
payload: PermissionGroupCreateRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PermissionGroupItem:
|
||||
repo = PermissionGroupsRepository(db)
|
||||
if repo.get_by_key(payload.group_key):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="group_key_already_exists")
|
||||
row = repo.create(group_key=payload.group_key, name=payload.name, status=payload.status)
|
||||
return PermissionGroupItem(id=row.id, group_key=row.group_key, name=row.name, status=row.status)
|
||||
|
||||
|
||||
@router.post("/permission-groups/{group_key}/members/{authentik_sub}")
|
||||
def add_group_member(
|
||||
group_key: str,
|
||||
authentik_sub: str,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, str]:
|
||||
groups_repo = PermissionGroupsRepository(db)
|
||||
group = groups_repo.get_by_key(group_key)
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
|
||||
row = groups_repo.add_member_if_not_exists(group.id, authentik_sub)
|
||||
return {"membership_id": row.id, "result": "added"}
|
||||
|
||||
|
||||
@router.delete("/permission-groups/{group_key}/members/{authentik_sub}")
|
||||
def remove_group_member(
|
||||
group_key: str,
|
||||
authentik_sub: str,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, int | str]:
|
||||
groups_repo = PermissionGroupsRepository(db)
|
||||
group = groups_repo.get_by_key(group_key)
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
|
||||
deleted = groups_repo.remove_member(group.id, authentik_sub)
|
||||
return {"deleted": deleted, "result": "removed"}
|
||||
|
||||
|
||||
@router.post("/permission-groups/{group_key}/permissions/grant")
|
||||
def grant_group_permission(
|
||||
group_key: str,
|
||||
payload: PermissionGrantRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, str]:
|
||||
groups_repo = PermissionGroupsRepository(db)
|
||||
group = groups_repo.get_by_key(group_key)
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
|
||||
_resolve_module_id(db, payload.system, payload.module)
|
||||
_resolve_scope_ids(db, payload.scope_type, payload.scope_id)
|
||||
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__"
|
||||
row = groups_repo.grant_group_permission(
|
||||
group_id=group.id,
|
||||
system=payload.system,
|
||||
module=module_key,
|
||||
action=payload.action,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
return {"permission_id": row.id, "result": "granted"}
|
||||
|
||||
|
||||
@router.post("/permission-groups/{group_key}/permissions/revoke")
|
||||
def revoke_group_permission(
|
||||
group_key: str,
|
||||
payload: PermissionRevokeRequest,
|
||||
_: ApiClient = Depends(require_api_client),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict[str, int | str]:
|
||||
groups_repo = PermissionGroupsRepository(db)
|
||||
group = groups_repo.get_by_key(group_key)
|
||||
if not group:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found")
|
||||
_resolve_module_id(db, payload.system, payload.module)
|
||||
_resolve_scope_ids(db, payload.scope_type, payload.scope_id)
|
||||
module_key = f"{payload.system}.{payload.module}" if payload.module else f"{payload.system}.__system__"
|
||||
deleted = groups_repo.revoke_group_permission(
|
||||
group_id=group.id,
|
||||
system=payload.system,
|
||||
module=module_key,
|
||||
action=payload.action,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
)
|
||||
return {"deleted": deleted, "result": "revoked"}
|
||||
@@ -1,12 +1,51 @@
|
||||
import logging
|
||||
import secrets
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.login import LoginRequest, LoginResponse
|
||||
from app.schemas.login import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
OIDCAuthUrlResponse,
|
||||
OIDCCodeExchangeRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_username_by_email(settings, email: str) -> str | None:
|
||||
if not settings.authentik_base_url or not settings.authentik_admin_token:
|
||||
return None
|
||||
|
||||
url = urljoin(settings.authentik_base_url.rstrip("/") + "/", "api/v3/core/users/")
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
params={"email": email},
|
||||
timeout=10,
|
||||
verify=settings.authentik_verify_tls,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.authentik_admin_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if resp.status_code >= 400:
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
results = data.get("results") if isinstance(data, dict) else None
|
||||
if not isinstance(results, list) or not results:
|
||||
return None
|
||||
|
||||
username = results[0].get("username")
|
||||
return username if isinstance(username, str) and username else None
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
@@ -30,18 +69,33 @@ def login(payload: LoginRequest) -> LoginResponse:
|
||||
"scope": "openid profile email",
|
||||
}
|
||||
|
||||
try:
|
||||
def _token_request(form_data: dict[str, str]) -> httpx.Response:
|
||||
resp = httpx.post(
|
||||
token_endpoint,
|
||||
data=form,
|
||||
data=form_data,
|
||||
timeout=10,
|
||||
verify=settings.authentik_verify_tls,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
return resp
|
||||
|
||||
try:
|
||||
resp = _token_request(form)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
|
||||
|
||||
# If user entered email, try resolving username and retry once.
|
||||
if resp.status_code >= 400 and "@" in payload.username:
|
||||
resolved = _resolve_username_by_email(settings, payload.username)
|
||||
if resolved and resolved != payload.username:
|
||||
form["username"] = resolved
|
||||
try:
|
||||
resp = _token_request(form)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("authentik password grant failed: status=%s body=%s", resp.status_code, resp.text)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
|
||||
|
||||
data = resp.json()
|
||||
@@ -55,3 +109,71 @@ def login(payload: LoginRequest) -> LoginResponse:
|
||||
expires_in=data.get("expires_in"),
|
||||
scope=data.get("scope"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/oidc/url", response_model=OIDCAuthUrlResponse)
|
||||
def get_oidc_authorize_url(redirect_uri: str) -> OIDCAuthUrlResponse:
|
||||
settings = get_settings()
|
||||
client_id = settings.authentik_client_id or settings.authentik_audience
|
||||
if not settings.authentik_base_url or not client_id:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
|
||||
|
||||
authorize_endpoint = urljoin(settings.authentik_base_url.rstrip("/") + "/", "application/o/authorize/")
|
||||
state = secrets.token_urlsafe(24)
|
||||
params = httpx.QueryParams(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"response_type": "code",
|
||||
"scope": "openid profile email",
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state,
|
||||
"prompt": "login",
|
||||
}
|
||||
)
|
||||
return OIDCAuthUrlResponse(authorize_url=f"{authorize_endpoint}?{params}")
|
||||
|
||||
|
||||
@router.post("/oidc/exchange", response_model=LoginResponse)
|
||||
def exchange_oidc_code(payload: OIDCCodeExchangeRequest) -> LoginResponse:
|
||||
settings = get_settings()
|
||||
client_id = settings.authentik_client_id or settings.authentik_audience
|
||||
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
|
||||
|
||||
token_endpoint = settings.authentik_token_endpoint or urljoin(
|
||||
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
|
||||
)
|
||||
form = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": settings.authentik_client_secret,
|
||||
"code": payload.code,
|
||||
"redirect_uri": payload.redirect_uri,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
token_endpoint,
|
||||
data=form,
|
||||
timeout=10,
|
||||
verify=settings.authentik_verify_tls,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
|
||||
|
||||
if resp.status_code >= 400:
|
||||
logger.warning("authentik auth-code exchange failed: status=%s body=%s", resp.status_code, resp.text)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentik_code_exchange_failed")
|
||||
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
|
||||
|
||||
return LoginResponse(
|
||||
access_token=token,
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
expires_in=data.get("expires_in"),
|
||||
scope=data.get("scope"),
|
||||
)
|
||||
|
||||
@@ -58,9 +58,8 @@ def get_permission_snapshot(
|
||||
if user is None:
|
||||
return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[])
|
||||
|
||||
permissions = perms_repo.list_by_user_id(user.id)
|
||||
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
|
||||
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples)
|
||||
permissions = perms_repo.list_by_user(user.id, user.authentik_sub)
|
||||
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=permissions)
|
||||
|
||||
|
||||
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
|
||||
|
||||
97
backend/app/api/internal_catalog.py
Normal file
97
backend/app/api/internal_catalog.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.internal import verify_internal_secret
|
||||
from app.db.session import get_db
|
||||
from app.repositories.companies_repo import CompaniesRepository
|
||||
from app.repositories.modules_repo import ModulesRepository
|
||||
from app.repositories.sites_repo import SitesRepository
|
||||
from app.repositories.systems_repo import SystemsRepository
|
||||
from app.repositories.users_repo import UsersRepository
|
||||
|
||||
router = APIRouter(prefix="/internal", tags=["internal"])
|
||||
|
||||
|
||||
@router.get("/systems")
|
||||
def internal_list_systems(
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=200, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
repo = SystemsRepository(db)
|
||||
items, total = repo.list(limit=limit, offset=offset)
|
||||
return {"items": [{"id": i.id, "system_key": i.system_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/modules")
|
||||
def internal_list_modules(
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: Session = Depends(get_db),
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
modules_repo = ModulesRepository(db)
|
||||
items, total = modules_repo.list(limit=limit, offset=offset)
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": i.id,
|
||||
"module_key": i.module_key,
|
||||
"system_key": i.module_key.split(".", 1)[0] if "." in i.module_key else None,
|
||||
"name": i.name,
|
||||
"status": i.status,
|
||||
}
|
||||
for i in items
|
||||
],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/companies")
|
||||
def internal_list_companies(
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: Session = Depends(get_db),
|
||||
keyword: str | None = Query(default=None),
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
repo = CompaniesRepository(db)
|
||||
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
||||
return {"items": [{"id": i.id, "company_key": i.company_key, "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/sites")
|
||||
def internal_list_sites(
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: Session = Depends(get_db),
|
||||
company_key: str | None = Query(default=None),
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
companies_repo = CompaniesRepository(db)
|
||||
sites_repo = SitesRepository(db)
|
||||
company_id = None
|
||||
if company_key:
|
||||
company = companies_repo.get_by_key(company_key)
|
||||
if company:
|
||||
company_id = company.id
|
||||
companies, _ = companies_repo.list(limit=2000, offset=0)
|
||||
mapping = {c.id: c.company_key for c in companies}
|
||||
items, total = sites_repo.list(company_id=company_id, limit=limit, offset=offset)
|
||||
return {"items": [{"id": i.id, "site_key": i.site_key, "company_key": mapping.get(i.company_id), "name": i.name, "status": i.status} for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/members")
|
||||
def internal_list_members(
|
||||
_: None = Depends(verify_internal_secret),
|
||||
db: Session = Depends(get_db),
|
||||
keyword: str | None = Query(default=None),
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> dict:
|
||||
repo = UsersRepository(db)
|
||||
items, total = repo.list(keyword=keyword, limit=limit, offset=offset)
|
||||
return {"items": [{"id": i.id, "authentik_sub": i.authentik_sub, "email": i.email, "display_name": i.display_name, "is_active": i.is_active} for i in items], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -51,8 +51,7 @@ def get_my_permission_snapshot(
|
||||
display_name=principal.name or principal.preferred_username,
|
||||
is_active=True,
|
||||
)
|
||||
permissions = perms_repo.list_by_user_id(user.id)
|
||||
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
|
||||
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples)
|
||||
permissions = perms_repo.list_by_user(user.id, user.authentik_sub)
|
||||
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=permissions)
|
||||
except SQLAlchemyError:
|
||||
return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[])
|
||||
|
||||
@@ -26,6 +26,7 @@ class Settings(BaseSettings):
|
||||
authentik_client_id: str = ""
|
||||
authentik_client_secret: str = ""
|
||||
authentik_token_endpoint: str = ""
|
||||
authentik_userinfo_endpoint: str = ""
|
||||
|
||||
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
|
||||
internal_shared_secret: str = ""
|
||||
|
||||
@@ -2,7 +2,9 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.admin import router as admin_router
|
||||
from app.api.admin_catalog import router as admin_catalog_router
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.internal_catalog import router as internal_catalog_router
|
||||
from app.api.internal import router as internal_router
|
||||
from app.api.me import router as me_router
|
||||
from app.core.config import get_settings
|
||||
@@ -25,6 +27,8 @@ def healthz() -> dict[str, str]:
|
||||
|
||||
|
||||
app.include_router(internal_router)
|
||||
app.include_router(internal_catalog_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(admin_catalog_router)
|
||||
app.include_router(me_router)
|
||||
app.include_router(auth_router)
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
from app.models.api_client import ApiClient
|
||||
from app.models.company import Company
|
||||
from app.models.module import Module
|
||||
from app.models.permission import Permission
|
||||
from app.models.permission_group import PermissionGroup
|
||||
from app.models.permission_group_member import PermissionGroupMember
|
||||
from app.models.permission_group_permission import PermissionGroupPermission
|
||||
from app.models.site import Site
|
||||
from app.models.system import System
|
||||
from app.models.user import User
|
||||
from app.models.user_scope_permission import UserScopePermission
|
||||
|
||||
__all__ = ["ApiClient", "Permission", "User"]
|
||||
__all__ = [
|
||||
"ApiClient",
|
||||
"Company",
|
||||
"Module",
|
||||
"Permission",
|
||||
"PermissionGroup",
|
||||
"PermissionGroupMember",
|
||||
"PermissionGroupPermission",
|
||||
"Site",
|
||||
"System",
|
||||
"User",
|
||||
"UserScopePermission",
|
||||
]
|
||||
|
||||
21
backend/app/models/company.py
Normal file
21
backend/app/models/company.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Company(Base):
|
||||
__tablename__ = "companies"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
company_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
21
backend/app/models/module.py
Normal file
21
backend/app/models/module.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Module(Base):
|
||||
__tablename__ = "modules"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
module_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
21
backend/app/models/permission_group.py
Normal file
21
backend/app/models/permission_group.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PermissionGroup(Base):
|
||||
__tablename__ = "permission_groups"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
group_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
20
backend/app/models/permission_group_member.py
Normal file
20
backend/app/models/permission_group_member.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PermissionGroupMember(Base):
|
||||
__tablename__ = "permission_group_members"
|
||||
__table_args__ = (UniqueConstraint("group_id", "authentik_sub", name="uq_permission_group_members_group_sub"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
group_id: Mapped[str] = mapped_column(
|
||||
UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
authentik_sub: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
23
backend/app/models/permission_group_permission.py
Normal file
23
backend/app/models/permission_group_permission.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PermissionGroupPermission(Base):
|
||||
__tablename__ = "permission_group_permissions"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
group_id: Mapped[str] = mapped_column(
|
||||
UUID(as_uuid=False), ForeignKey("permission_groups.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
system: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
module: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
scope_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
scope_id: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
22
backend/app/models/site.py
Normal file
22
backend/app/models/site.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
__tablename__ = "sites"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
site_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
company_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
21
backend/app/models/system.py
Normal file
21
backend/app/models/system.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class System(Base):
|
||||
__tablename__ = "systems"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
system_key: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
24
backend/app/models/user_scope_permission.py
Normal file
24
backend/app/models/user_scope_permission.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class UserScopePermission(Base):
|
||||
__tablename__ = "user_scope_permissions"
|
||||
|
||||
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
module_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("modules.id", ondelete="CASCADE"), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
scope_type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
company_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("companies.id", ondelete="CASCADE"))
|
||||
site_id: Mapped[str | None] = mapped_column(UUID(as_uuid=False), ForeignKey("sites.id", ondelete="CASCADE"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
35
backend/app/repositories/companies_repo.py
Normal file
35
backend/app/repositories/companies_repo.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class CompaniesRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_by_key(self, company_key: str) -> Company | None:
|
||||
stmt = select(Company).where(Company.company_key == company_key)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def get_by_id(self, company_id: str) -> Company | None:
|
||||
stmt = select(Company).where(Company.id == company_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def list(self, keyword: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[Company], int]:
|
||||
stmt = select(Company)
|
||||
count_stmt = select(func.count()).select_from(Company)
|
||||
if keyword:
|
||||
pattern = f"%{keyword}%"
|
||||
cond = or_(Company.company_key.ilike(pattern), Company.name.ilike(pattern))
|
||||
stmt = stmt.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
stmt = stmt.order_by(Company.created_at.desc()).limit(limit).offset(offset)
|
||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
||||
|
||||
def create(self, company_key: str, name: str, status: str = "active") -> Company:
|
||||
item = Company(company_key=company_key, name=name, status=status)
|
||||
self.db.add(item)
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
26
backend/app/repositories/modules_repo.py
Normal file
26
backend/app/repositories/modules_repo.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.module import Module
|
||||
|
||||
|
||||
class ModulesRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_by_key(self, module_key: str) -> Module | None:
|
||||
stmt = select(Module).where(Module.module_key == module_key)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def list(self, limit: int = 200, offset: int = 0) -> tuple[list[Module], int]:
|
||||
stmt = select(Module)
|
||||
count_stmt = select(func.count()).select_from(Module)
|
||||
stmt = stmt.order_by(Module.created_at.desc()).limit(limit).offset(offset)
|
||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
||||
|
||||
def create(self, module_key: str, name: str, status: str = "active") -> Module:
|
||||
item = Module(module_key=module_key, name=name, status=status)
|
||||
self.db.add(item)
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
106
backend/app/repositories/permission_groups_repo.py
Normal file
106
backend/app/repositories/permission_groups_repo.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.permission_group import PermissionGroup
|
||||
from app.models.permission_group_member import PermissionGroupMember
|
||||
from app.models.permission_group_permission import PermissionGroupPermission
|
||||
|
||||
|
||||
class PermissionGroupsRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_by_key(self, group_key: str) -> PermissionGroup | None:
|
||||
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.group_key == group_key))
|
||||
|
||||
def get_by_id(self, group_id: str) -> PermissionGroup | None:
|
||||
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id))
|
||||
|
||||
def list(self, limit: int = 100, offset: int = 0) -> tuple[list[PermissionGroup], int]:
|
||||
stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset)
|
||||
count_stmt = select(func.count()).select_from(PermissionGroup)
|
||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
||||
|
||||
def create(self, group_key: str, name: str, status: str = "active") -> PermissionGroup:
|
||||
item = PermissionGroup(group_key=group_key, name=name, status=status)
|
||||
self.db.add(item)
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
|
||||
def add_member_if_not_exists(self, group_id: str, authentik_sub: str) -> PermissionGroupMember:
|
||||
existing = self.db.scalar(
|
||||
select(PermissionGroupMember).where(
|
||||
PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
row = PermissionGroupMember(group_id=group_id, authentik_sub=authentik_sub)
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
return row
|
||||
|
||||
def remove_member(self, group_id: str, authentik_sub: str) -> int:
|
||||
result = self.db.execute(
|
||||
delete(PermissionGroupMember).where(
|
||||
PermissionGroupMember.group_id == group_id, PermissionGroupMember.authentik_sub == authentik_sub
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
def grant_group_permission(
|
||||
self,
|
||||
group_id: str,
|
||||
system: str,
|
||||
module: str,
|
||||
action: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
) -> PermissionGroupPermission:
|
||||
where = [
|
||||
PermissionGroupPermission.group_id == group_id,
|
||||
PermissionGroupPermission.system == system,
|
||||
PermissionGroupPermission.module == module,
|
||||
PermissionGroupPermission.action == action,
|
||||
PermissionGroupPermission.scope_type == scope_type,
|
||||
PermissionGroupPermission.scope_id == scope_id,
|
||||
]
|
||||
existing = self.db.scalar(select(PermissionGroupPermission).where(*where))
|
||||
if existing:
|
||||
return existing
|
||||
row = PermissionGroupPermission(
|
||||
group_id=group_id,
|
||||
system=system,
|
||||
module=module,
|
||||
action=action,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
return row
|
||||
|
||||
def revoke_group_permission(
|
||||
self,
|
||||
group_id: str,
|
||||
system: str,
|
||||
module: str,
|
||||
action: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
) -> int:
|
||||
stmt = delete(PermissionGroupPermission).where(
|
||||
PermissionGroupPermission.group_id == group_id,
|
||||
PermissionGroupPermission.system == system,
|
||||
PermissionGroupPermission.module == module,
|
||||
PermissionGroupPermission.action == action,
|
||||
PermissionGroupPermission.scope_type == scope_type,
|
||||
PermissionGroupPermission.scope_id == scope_id,
|
||||
)
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
return int(result.rowcount or 0)
|
||||
@@ -1,42 +1,96 @@
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import and_, delete, literal, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.permission import Permission
|
||||
from app.models.company import Company
|
||||
from app.models.module import Module
|
||||
from app.models.permission_group_member import PermissionGroupMember
|
||||
from app.models.permission_group_permission import PermissionGroupPermission
|
||||
from app.models.site import Site
|
||||
from app.models.user_scope_permission import UserScopePermission
|
||||
|
||||
|
||||
class PermissionsRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def list_by_user_id(self, user_id: str) -> list[Permission]:
|
||||
stmt = select(Permission).where(Permission.user_id == user_id)
|
||||
return list(self.db.scalars(stmt).all())
|
||||
def list_by_user(self, user_id: str, authentik_sub: str) -> list[tuple[str, str, str | None, str, str]]:
|
||||
direct_stmt = (
|
||||
select(
|
||||
literal("direct"),
|
||||
UserScopePermission.scope_type,
|
||||
Company.company_key,
|
||||
Site.site_key,
|
||||
Module.module_key,
|
||||
UserScopePermission.action,
|
||||
)
|
||||
.select_from(UserScopePermission)
|
||||
.join(Module, Module.id == UserScopePermission.module_id)
|
||||
.join(Company, Company.id == UserScopePermission.company_id, isouter=True)
|
||||
.join(Site, Site.id == UserScopePermission.site_id, isouter=True)
|
||||
.where(UserScopePermission.user_id == user_id)
|
||||
)
|
||||
group_stmt = (
|
||||
select(
|
||||
literal("group"),
|
||||
PermissionGroupPermission.scope_type,
|
||||
PermissionGroupPermission.scope_id,
|
||||
PermissionGroupPermission.system,
|
||||
PermissionGroupPermission.module,
|
||||
PermissionGroupPermission.action,
|
||||
)
|
||||
.select_from(PermissionGroupPermission)
|
||||
.join(PermissionGroupMember, PermissionGroupMember.group_id == PermissionGroupPermission.group_id)
|
||||
.where(PermissionGroupMember.authentik_sub == authentik_sub)
|
||||
)
|
||||
rows = self.db.execute(direct_stmt).all() + self.db.execute(group_stmt).all()
|
||||
result: list[tuple[str, str, str | None, str, str]] = []
|
||||
dedup = set()
|
||||
for row in rows:
|
||||
source = row[0]
|
||||
if source == "group":
|
||||
_, scope_type, scope_id, system_key, module_key, action = row
|
||||
else:
|
||||
_, scope_type, company_key, site_key, module_key, action = row
|
||||
scope_id = company_key if scope_type == "company" else site_key
|
||||
system_key = module_key.split(".", 1)[0] if isinstance(module_key, str) and "." in module_key else None
|
||||
key = (scope_type, scope_id or "", system_key, module_key, action)
|
||||
if key in dedup:
|
||||
continue
|
||||
dedup.add(key)
|
||||
result.append(key)
|
||||
return result
|
||||
|
||||
def create_if_not_exists(
|
||||
self,
|
||||
user_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
module: str,
|
||||
module_id: str,
|
||||
action: str,
|
||||
) -> Permission:
|
||||
stmt = select(Permission).where(
|
||||
Permission.user_id == user_id,
|
||||
Permission.scope_type == scope_type,
|
||||
Permission.scope_id == scope_id,
|
||||
Permission.module == module,
|
||||
Permission.action == action,
|
||||
)
|
||||
existing = self.db.scalar(stmt)
|
||||
scope_type: str,
|
||||
company_id: str | None,
|
||||
site_id: str | None,
|
||||
) -> UserScopePermission:
|
||||
where_expr = [
|
||||
UserScopePermission.user_id == user_id,
|
||||
UserScopePermission.module_id == module_id,
|
||||
UserScopePermission.action == action,
|
||||
UserScopePermission.scope_type == scope_type,
|
||||
]
|
||||
if scope_type == "company":
|
||||
where_expr.append(UserScopePermission.company_id == company_id)
|
||||
else:
|
||||
where_expr.append(UserScopePermission.site_id == site_id)
|
||||
|
||||
existing = self.db.scalar(select(UserScopePermission).where(and_(*where_expr)))
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
item = Permission(
|
||||
item = UserScopePermission(
|
||||
user_id=user_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
module=module,
|
||||
module_id=module_id,
|
||||
action=action,
|
||||
scope_type=scope_type,
|
||||
company_id=company_id,
|
||||
site_id=site_id,
|
||||
)
|
||||
self.db.add(item)
|
||||
self.db.commit()
|
||||
@@ -46,17 +100,21 @@ class PermissionsRepository:
|
||||
def revoke(
|
||||
self,
|
||||
user_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
module: str,
|
||||
module_id: str,
|
||||
action: str,
|
||||
scope_type: str,
|
||||
company_id: str | None,
|
||||
site_id: str | None,
|
||||
) -> int:
|
||||
stmt = delete(Permission).where(
|
||||
Permission.user_id == user_id,
|
||||
Permission.scope_type == scope_type,
|
||||
Permission.scope_id == scope_id,
|
||||
Permission.module == module,
|
||||
Permission.action == action,
|
||||
stmt = delete(UserScopePermission).where(
|
||||
UserScopePermission.user_id == user_id,
|
||||
UserScopePermission.module_id == module_id,
|
||||
UserScopePermission.action == action,
|
||||
UserScopePermission.scope_type == scope_type,
|
||||
or_(
|
||||
and_(scope_type == "company", UserScopePermission.company_id == company_id),
|
||||
and_(scope_type == "site", UserScopePermission.site_id == site_id),
|
||||
),
|
||||
)
|
||||
result = self.db.execute(stmt)
|
||||
self.db.commit()
|
||||
|
||||
40
backend/app/repositories/sites_repo.py
Normal file
40
backend/app/repositories/sites_repo.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.site import Site
|
||||
|
||||
|
||||
class SitesRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_by_key(self, site_key: str) -> Site | None:
|
||||
stmt = select(Site).where(Site.site_key == site_key)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def list(
|
||||
self,
|
||||
keyword: str | None = None,
|
||||
company_id: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Site], int]:
|
||||
stmt = select(Site)
|
||||
count_stmt = select(func.count()).select_from(Site)
|
||||
if keyword:
|
||||
pattern = f"%{keyword}%"
|
||||
cond = or_(Site.site_key.ilike(pattern), Site.name.ilike(pattern))
|
||||
stmt = stmt.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
if company_id:
|
||||
stmt = stmt.where(Site.company_id == company_id)
|
||||
count_stmt = count_stmt.where(Site.company_id == company_id)
|
||||
stmt = stmt.order_by(Site.created_at.desc()).limit(limit).offset(offset)
|
||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
||||
|
||||
def create(self, site_key: str, company_id: str, name: str, status: str = "active") -> Site:
|
||||
item = Site(site_key=site_key, company_id=company_id, name=name, status=status)
|
||||
self.db.add(item)
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
33
backend/app/repositories/systems_repo.py
Normal file
33
backend/app/repositories/systems_repo.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.system import System
|
||||
|
||||
|
||||
class SystemsRepository:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def get_by_key(self, system_key: str) -> System | None:
|
||||
stmt = select(System).where(System.system_key == system_key)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def get_by_id(self, system_id: str) -> System | None:
|
||||
stmt = select(System).where(System.id == system_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def list(self, status: str | None = None, limit: int = 100, offset: int = 0) -> tuple[list[System], int]:
|
||||
stmt = select(System)
|
||||
count_stmt = select(func.count()).select_from(System)
|
||||
if status:
|
||||
stmt = stmt.where(System.status == status)
|
||||
count_stmt = count_stmt.where(System.status == status)
|
||||
stmt = stmt.order_by(System.created_at.desc()).limit(limit).offset(offset)
|
||||
return list(self.db.scalars(stmt).all()), int(self.db.scalar(count_stmt) or 0)
|
||||
|
||||
def create(self, system_key: str, name: str, status: str = "active") -> System:
|
||||
item = System(system_key=system_key, name=name, status=status)
|
||||
self.db.add(item)
|
||||
self.db.commit()
|
||||
self.db.refresh(item)
|
||||
return item
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import User
|
||||
@@ -12,6 +12,35 @@ class UsersRepository:
|
||||
stmt = select(User).where(User.authentik_sub == authentik_sub)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def get_by_id(self, user_id: str) -> User | None:
|
||||
stmt = select(User).where(User.id == user_id)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def list(
|
||||
self,
|
||||
keyword: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[User], int]:
|
||||
stmt = select(User)
|
||||
count_stmt = select(func.count()).select_from(User)
|
||||
|
||||
if keyword:
|
||||
pattern = f"%{keyword}%"
|
||||
cond = or_(User.authentik_sub.ilike(pattern), User.email.ilike(pattern), User.display_name.ilike(pattern))
|
||||
stmt = stmt.where(cond)
|
||||
count_stmt = count_stmt.where(cond)
|
||||
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(User.is_active == is_active)
|
||||
count_stmt = count_stmt.where(User.is_active == is_active)
|
||||
|
||||
stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset)
|
||||
items = list(self.db.scalars(stmt).all())
|
||||
total = int(self.db.scalar(count_stmt) or 0)
|
||||
return items, total
|
||||
|
||||
def upsert_by_sub(
|
||||
self,
|
||||
authentik_sub: str,
|
||||
@@ -40,3 +69,21 @@ class UsersRepository:
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def update_member(
|
||||
self,
|
||||
user: User,
|
||||
*,
|
||||
email: str | None = None,
|
||||
display_name: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> User:
|
||||
if email is not None:
|
||||
user.email = email
|
||||
if display_name is not None:
|
||||
user.display_name = display_name
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
85
backend/app/schemas/catalog.py
Normal file
85
backend/app/schemas/catalog.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SystemCreateRequest(BaseModel):
|
||||
system_key: str
|
||||
name: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class SystemItem(BaseModel):
|
||||
id: str
|
||||
system_key: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
|
||||
class ModuleCreateRequest(BaseModel):
|
||||
system_key: str
|
||||
module_key: str
|
||||
name: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class ModuleItem(BaseModel):
|
||||
id: str
|
||||
system_key: str | None = None
|
||||
module_key: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
|
||||
class CompanyCreateRequest(BaseModel):
|
||||
company_key: str
|
||||
name: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class CompanyItem(BaseModel):
|
||||
id: str
|
||||
company_key: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
|
||||
class SiteCreateRequest(BaseModel):
|
||||
site_key: str
|
||||
company_key: str
|
||||
name: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class SiteItem(BaseModel):
|
||||
id: str
|
||||
site_key: str
|
||||
company_key: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
|
||||
class MemberItem(BaseModel):
|
||||
id: str
|
||||
authentik_sub: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ListResponse(BaseModel):
|
||||
items: list
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PermissionGroupCreateRequest(BaseModel):
|
||||
group_key: str
|
||||
name: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class PermissionGroupItem(BaseModel):
|
||||
id: str
|
||||
group_key: str
|
||||
name: str
|
||||
status: str
|
||||
@@ -11,3 +11,12 @@ class LoginResponse(BaseModel):
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int | None = None
|
||||
scope: str | None = None
|
||||
|
||||
|
||||
class OIDCAuthUrlResponse(BaseModel):
|
||||
authorize_url: str
|
||||
|
||||
|
||||
class OIDCCodeExchangeRequest(BaseModel):
|
||||
code: str
|
||||
redirect_uri: str
|
||||
|
||||
@@ -7,7 +7,8 @@ class PermissionGrantRequest(BaseModel):
|
||||
display_name: str | None = None
|
||||
scope_type: str
|
||||
scope_id: str
|
||||
module: str
|
||||
system: str
|
||||
module: str | None = None
|
||||
action: str
|
||||
|
||||
|
||||
@@ -15,13 +16,15 @@ class PermissionRevokeRequest(BaseModel):
|
||||
authentik_sub: str
|
||||
scope_type: str
|
||||
scope_id: str
|
||||
module: str
|
||||
system: str
|
||||
module: str | None = None
|
||||
action: str
|
||||
|
||||
|
||||
class PermissionItem(BaseModel):
|
||||
scope_type: str
|
||||
scope_id: str
|
||||
system: str | None = None
|
||||
module: str
|
||||
action: str
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -19,11 +20,19 @@ class AuthentikTokenVerifier:
|
||||
jwks_url: str | None,
|
||||
audience: str | None,
|
||||
client_secret: str | None,
|
||||
base_url: str | None,
|
||||
userinfo_endpoint: str | None,
|
||||
verify_tls: bool,
|
||||
) -> None:
|
||||
self.issuer = issuer.strip() if issuer else None
|
||||
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
|
||||
self.audience = audience.strip() if audience else None
|
||||
self.client_secret = client_secret.strip() if client_secret else None
|
||||
self.base_url = base_url.strip() if base_url else None
|
||||
self.userinfo_endpoint = (
|
||||
userinfo_endpoint.strip() if userinfo_endpoint else self._infer_userinfo_endpoint(self.issuer, self.base_url)
|
||||
)
|
||||
self.verify_tls = verify_tls
|
||||
|
||||
if not self.jwks_url:
|
||||
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
|
||||
@@ -39,6 +48,50 @@ class AuthentikTokenVerifier:
|
||||
return normalized
|
||||
return normalized + "jwks/"
|
||||
|
||||
@staticmethod
|
||||
def _infer_userinfo_endpoint(issuer: str | None, base_url: str | None) -> str | None:
|
||||
if issuer:
|
||||
return issuer.rstrip("/") + "/userinfo/"
|
||||
if base_url:
|
||||
return base_url.rstrip("/") + "/application/o/userinfo/"
|
||||
return None
|
||||
|
||||
def _enrich_from_userinfo(self, principal: AuthentikPrincipal, token: str) -> AuthentikPrincipal:
|
||||
if principal.email and (principal.name or principal.preferred_username):
|
||||
return principal
|
||||
if not self.userinfo_endpoint:
|
||||
return principal
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
self.userinfo_endpoint,
|
||||
timeout=5,
|
||||
verify=self.verify_tls,
|
||||
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
|
||||
)
|
||||
except Exception:
|
||||
return principal
|
||||
|
||||
if resp.status_code >= 400:
|
||||
return principal
|
||||
|
||||
data = resp.json() if resp.content else {}
|
||||
sub = data.get("sub")
|
||||
if isinstance(sub, str) and sub and sub != principal.sub:
|
||||
return principal
|
||||
|
||||
email = principal.email or (data.get("email") if isinstance(data.get("email"), str) else None)
|
||||
name = principal.name or (data.get("name") if isinstance(data.get("name"), str) else None)
|
||||
preferred_username = principal.preferred_username or (
|
||||
data.get("preferred_username") if isinstance(data.get("preferred_username"), str) else None
|
||||
)
|
||||
return AuthentikPrincipal(
|
||||
sub=principal.sub,
|
||||
email=email,
|
||||
name=name,
|
||||
preferred_username=preferred_username,
|
||||
)
|
||||
|
||||
def verify_access_token(self, token: str) -> AuthentikPrincipal:
|
||||
try:
|
||||
header = jwt.get_unverified_header(token)
|
||||
@@ -78,12 +131,13 @@ class AuthentikTokenVerifier:
|
||||
if not sub:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
|
||||
|
||||
return AuthentikPrincipal(
|
||||
principal = AuthentikPrincipal(
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
name=claims.get("name"),
|
||||
preferred_username=claims.get("preferred_username"),
|
||||
)
|
||||
return self._enrich_from_userinfo(principal, token)
|
||||
|
||||
|
||||
@lru_cache
|
||||
@@ -94,6 +148,9 @@ def _get_verifier() -> AuthentikTokenVerifier:
|
||||
jwks_url=settings.authentik_jwks_url,
|
||||
audience=settings.authentik_audience,
|
||||
client_secret=settings.authentik_client_secret,
|
||||
base_url=settings.authentik_base_url,
|
||||
userinfo_endpoint=settings.authentik_userinfo_endpoint,
|
||||
verify_tls=settings.authentik_verify_tls,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse
|
||||
|
||||
class PermissionService:
|
||||
@staticmethod
|
||||
def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str, str]]) -> PermissionSnapshotResponse:
|
||||
def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str | None, str, str]]) -> PermissionSnapshotResponse:
|
||||
return PermissionSnapshotResponse(
|
||||
authentik_sub=authentik_sub,
|
||||
permissions=[
|
||||
PermissionItem(scope_type=s_type, scope_id=s_id, module=module, action=action)
|
||||
for s_type, s_id, module, action in permissions
|
||||
PermissionItem(scope_type=s_type, scope_id=s_id, system=system, module=module, action=action)
|
||||
for s_type, s_id, system, module, action in permissions
|
||||
],
|
||||
)
|
||||
|
||||
@@ -4,15 +4,62 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
authentik_sub VARCHAR(255) NOT NULL UNIQUE,
|
||||
authentik_sub TEXT NOT NULL UNIQUE,
|
||||
authentik_user_id INTEGER,
|
||||
email VARCHAR(320),
|
||||
display_name VARCHAR(255),
|
||||
email TEXT UNIQUE,
|
||||
display_name TEXT,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sync_state (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
source_version TEXT,
|
||||
last_error TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS companies (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
company_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
site_key TEXT NOT NULL UNIQUE,
|
||||
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS systems (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
system_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
module_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- legacy table: 保留相容舊流程
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -25,7 +72,91 @@ CREATE TABLE IF NOT EXISTS permissions (
|
||||
UNIQUE (user_id, scope_type, scope_id, module, action)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_scope_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
scope_type VARCHAR(16) NOT NULL,
|
||||
company_id UUID REFERENCES companies(id) ON DELETE CASCADE,
|
||||
site_id UUID REFERENCES sites(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check;
|
||||
ALTER TABLE user_scope_permissions
|
||||
ADD CONSTRAINT user_scope_permissions_check
|
||||
CHECK (
|
||||
((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL)
|
||||
OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
authentik_sub TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_sub)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_group_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
system TEXT NOT NULL,
|
||||
module TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_pgp_group_rule UNIQUE (group_id, system, module, action, scope_type, scope_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
api_key_hash TEXT NOT NULL,
|
||||
allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
rate_limit_per_min INTEGER,
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO systems (system_key, name, status)
|
||||
VALUES ('member', 'Member Center', 'active')
|
||||
ON CONFLICT (system_key) DO NOTHING;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
|
||||
CREATE INDEX IF NOT EXISTS idx_sites_company_id ON sites(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usp_user_id ON user_scope_permissions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usp_module_id ON user_scope_permissions(module_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usp_company_id ON user_scope_permissions(company_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usp_site_id ON user_scope_permissions(site_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_company
|
||||
ON user_scope_permissions(user_id, module_id, action, scope_type, company_id)
|
||||
WHERE scope_type = 'company';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_usp_site
|
||||
ON user_scope_permissions(user_id, module_id, action, scope_type, site_id)
|
||||
WHERE scope_type = 'site';
|
||||
CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_module_key ON modules(module_key);
|
||||
|
||||
COMMIT;
|
||||
|
||||
73
backend/scripts/migrate_align_company_site_member_system.sql
Normal file
73
backend/scripts/migrate_align_company_site_member_system.sql
Normal file
@@ -0,0 +1,73 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'record_status') THEN
|
||||
CREATE TYPE record_status AS ENUM ('active','inactive');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS systems (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
system_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO systems (system_key, name, status)
|
||||
VALUES ('member', 'Member Center', 'active')
|
||||
ON CONFLICT (system_key) DO NOTHING;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'scope_type') THEN
|
||||
CREATE TYPE scope_type AS ENUM ('company','site');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'permission_action') THEN
|
||||
CREATE TYPE permission_action AS ENUM ('view','create','update','delete','manage');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status record_status NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
authentik_sub TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_permission_group_members_group_sub UNIQUE (group_id, authentik_sub)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_group_permissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
system TEXT NOT NULL,
|
||||
module TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_systems_system_key ON systems(system_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_pgm_group_id ON permission_group_members(group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_pgm_authentik_sub ON permission_group_members(authentik_sub);
|
||||
CREATE INDEX IF NOT EXISTS idx_pgp_group_id ON permission_group_permissions(group_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_pgp_group_rule
|
||||
ON permission_group_permissions(group_id, system, module, action, scope_type, scope_id);
|
||||
|
||||
COMMIT;
|
||||
27
backend/scripts/migrate_enum_to_text.sql
Normal file
27
backend/scripts/migrate_enum_to_text.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
BEGIN;
|
||||
|
||||
-- users / master tables
|
||||
ALTER TABLE users ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
ALTER TABLE companies ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
ALTER TABLE sites ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
ALTER TABLE systems ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
ALTER TABLE modules ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
ALTER TABLE permission_groups ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
|
||||
-- api_clients
|
||||
ALTER TABLE api_clients ALTER COLUMN status TYPE VARCHAR(16) USING status::text;
|
||||
|
||||
-- user scoped permissions
|
||||
ALTER TABLE user_scope_permissions ALTER COLUMN action TYPE VARCHAR(32) USING action::text;
|
||||
ALTER TABLE user_scope_permissions ALTER COLUMN scope_type TYPE VARCHAR(16) USING scope_type::text;
|
||||
|
||||
-- keep check constraint compatible with varchar
|
||||
ALTER TABLE user_scope_permissions DROP CONSTRAINT IF EXISTS user_scope_permissions_check;
|
||||
ALTER TABLE user_scope_permissions
|
||||
ADD CONSTRAINT user_scope_permissions_check
|
||||
CHECK (
|
||||
((scope_type = 'company' AND company_id IS NOT NULL AND site_id IS NULL)
|
||||
OR (scope_type = 'site' AND site_id IS NOT NULL AND company_id IS NULL))
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,106 +0,0 @@
|
||||
-- member_center: API 呼叫方白名單表
|
||||
-- 位置: public schema
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'client_status') THEN
|
||||
CREATE TYPE client_status AS ENUM ('active', 'inactive');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
status client_status NOT NULL DEFAULT 'active',
|
||||
|
||||
-- 只存 hash,不存明文 key
|
||||
api_key_hash TEXT NOT NULL,
|
||||
|
||||
-- 可先留空,之後再嚴格化
|
||||
allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
|
||||
rate_limit_per_min INTEGER,
|
||||
expires_at TIMESTAMPTZ,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at);
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at_api_clients()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_api_clients_set_updated_at'
|
||||
) THEN
|
||||
CREATE TRIGGER trg_api_clients_set_updated_at
|
||||
BEFORE UPDATE ON api_clients
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at_api_clients();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 建議初始化 2~3 個 client(api_key_hash 先放占位,後續再更新)
|
||||
INSERT INTO api_clients (
|
||||
client_key,
|
||||
name,
|
||||
status,
|
||||
api_key_hash,
|
||||
allowed_origins,
|
||||
allowed_ips,
|
||||
allowed_paths,
|
||||
rate_limit_per_min
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'mkt-backend',
|
||||
'MKT Backend Service',
|
||||
'active',
|
||||
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
|
||||
'[]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'["/internal/users/upsert-by-sub", "/internal/permissions"]'::jsonb,
|
||||
600
|
||||
),
|
||||
(
|
||||
'admin-frontend',
|
||||
'Admin Frontend',
|
||||
'active',
|
||||
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
|
||||
'["https://admin.ose.tw", "https://member.ose.tw"]'::jsonb,
|
||||
'[]'::jsonb,
|
||||
'["/admin"]'::jsonb,
|
||||
300
|
||||
),
|
||||
(
|
||||
'ops-local',
|
||||
'Ops Local Tooling',
|
||||
'inactive',
|
||||
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
|
||||
'[]'::jsonb,
|
||||
'["127.0.0.1"]'::jsonb,
|
||||
'["/internal", "/admin"]'::jsonb,
|
||||
120
|
||||
)
|
||||
ON CONFLICT (client_key) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- 快速檢查
|
||||
-- SELECT client_key, status, expires_at, created_at FROM api_clients ORDER BY client_key;
|
||||
@@ -23,8 +23,8 @@
|
||||
- 前端任務進度與驗收條件
|
||||
- `docs/TASKPLAN_BACKEND.md`
|
||||
- 後端任務進度與驗收條件
|
||||
- `docs/API_CLIENTS_SQL.sql`
|
||||
- `api_clients` 白名單表與初始資料 SQL
|
||||
- `backend/scripts/init_schema.sql`
|
||||
- 一次建立完整 schema(含 `api_clients`)
|
||||
- `docs/DB_SCHEMA_SNAPSHOT.md`
|
||||
- 目前資料庫 schema 快照(欄位/索引/約束)
|
||||
|
||||
|
||||
@@ -1,93 +1,37 @@
|
||||
# memberapi.ose.tw 後端架構(FastAPI)
|
||||
# memberapi.ose.tw 後端架構(公司/品牌站台/會員)
|
||||
|
||||
## 1. 目標與邊界
|
||||
- 網域:`memberapi.ose.tw`
|
||||
- 角色:會員中心後端真相來源(User + Permission)
|
||||
- 範圍:
|
||||
- user upsert(以 `authentik_sub` 為跨系統主鍵)
|
||||
- permission grant/revoke
|
||||
- permission snapshot 提供給其他系統
|
||||
- 不在本服務處理:
|
||||
- Authentik OIDC 流程頁與 UI
|
||||
- 前端互動邏輯
|
||||
## 核心主檔(對齊 DB Schema)
|
||||
- `users`:會員
|
||||
- `companies`:公司
|
||||
- `sites`:品牌站台(隸屬 company)
|
||||
- `systems`:系統層(member/mkt/...)
|
||||
- `modules`:模組(使用 `system.module` key)
|
||||
|
||||
## 2. 技術棧
|
||||
- Python 3.12
|
||||
- FastAPI
|
||||
- SQLAlchemy 2.0
|
||||
- PostgreSQL(psycopg)
|
||||
- Pydantic Settings
|
||||
## 權限模型
|
||||
- 直接權限:`user_scope_permissions`
|
||||
- 群組權限:`permission_groups` + `permission_group_members` + `permission_group_permissions`
|
||||
- Snapshot 回傳:合併「user 直接 + group」去重
|
||||
|
||||
## 3. 後端目錄(已建立)
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/api/`
|
||||
- `internal.py`
|
||||
- `admin.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/db/session.py`
|
||||
- `backend/app/models/`
|
||||
- `user.py`
|
||||
- `permission.py`
|
||||
- `api_client.py`
|
||||
- `backend/app/repositories/`
|
||||
- `users_repo.py`
|
||||
- `permissions_repo.py`
|
||||
- `backend/app/security/api_client_auth.py`
|
||||
- `backend/scripts/init_schema.sql`
|
||||
- `backend/.env.example`
|
||||
- `backend/.env.production.example`
|
||||
## 授權層級
|
||||
- `system` 必填
|
||||
- `module` 選填
|
||||
- 有值:`{system}.{module}`(例:`mkt.campaign`)
|
||||
- 無值:系統層權限,使用 `system.__system__`
|
||||
|
||||
## 4. 資料模型
|
||||
- `users`
|
||||
- `id`, `authentik_sub`(unique), `email`, `display_name`, `is_active`, timestamps
|
||||
- `permissions`
|
||||
- `id`, `user_id`, `scope_type`, `scope_id`, `module`, `action`, `created_at`
|
||||
- unique constraint: `(user_id, scope_type, scope_id, module, action)`
|
||||
- `api_clients`(由 `docs/API_CLIENTS_SQL.sql` 建立)
|
||||
- `client_key`, `api_key_hash`, `status`, allowlist, expires/rate-limit 欄位
|
||||
## 主要 API
|
||||
- `GET /me`
|
||||
- `GET /me/permissions/snapshot`
|
||||
- `POST /admin/permissions/grant|revoke`
|
||||
- `GET|POST /admin/systems`
|
||||
- `GET|POST /admin/modules`
|
||||
- `GET|POST /admin/companies`
|
||||
- `GET|POST /admin/sites`
|
||||
- `GET /admin/members`
|
||||
- `GET|POST /admin/permission-groups`
|
||||
- `POST|DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
|
||||
- `POST /admin/permission-groups/{group_key}/permissions/grant|revoke`
|
||||
- `GET /internal/systems|modules|companies|sites|members`
|
||||
|
||||
## 5. API 設計(MVP)
|
||||
- 健康檢查
|
||||
- `GET /healthz`
|
||||
- 使用者路由(Bearer token)
|
||||
- `GET /me`
|
||||
- `GET /me/permissions/snapshot`
|
||||
- Bearer token 由 Authentik JWT + JWKS 驗證,並以 `sub` 自動 upsert user
|
||||
- 內部路由(系統對系統)
|
||||
- `POST /internal/users/upsert-by-sub`
|
||||
- `GET /internal/permissions/{authentik_sub}/snapshot`
|
||||
- `POST /internal/authentik/users/ensure`
|
||||
- header: `X-Internal-Secret`
|
||||
- 管理路由(後台/API client)
|
||||
- `POST /admin/permissions/grant`
|
||||
- `POST /admin/permissions/revoke`
|
||||
- headers: `X-Client-Key`, `X-API-Key`
|
||||
|
||||
## 6. 安全策略
|
||||
- `admin` 路由強制 API client 驗證:
|
||||
- client 必須存在且 `status=active`
|
||||
- `expires_at` 未過期
|
||||
- `api_key_hash` 驗證(支援 `sha256:<hex>` 與 bcrypt/argon2)
|
||||
- allowlist 驗證(origin/ip/path)
|
||||
- `internal` 路由使用 `X-Internal-Secret` 做服務間驗證
|
||||
- `me` 路由使用 Authentik Access Token 驗證:
|
||||
- 使用 `AUTHENTIK_JWKS_URL` 或 `AUTHENTIK_ISSUER` 推導 JWKS
|
||||
- 可選 `AUTHENTIK_AUDIENCE` 驗證 aud claim
|
||||
- Authentik Admin 整合:
|
||||
- 使用 `AUTHENTIK_BASE_URL + AUTHENTIK_ADMIN_TOKEN`
|
||||
- 可透過 `/internal/authentik/users/ensure` 建立或更新 Authentik user
|
||||
- 建議上線前:
|
||||
- 將 `.env` 範本中的明文密碼改為部署平台 secret
|
||||
- API key 全部改為 argon2/bcrypt hash
|
||||
|
||||
## 7. 與其他系統資料流
|
||||
1. mkt/admin 後端登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub`
|
||||
2. 權限調整走 `/admin/permissions/grant|revoke`
|
||||
3. 需要授權判斷時,呼叫 `/internal/permissions/{sub}/snapshot`
|
||||
4. mkt 系統可本地快取 snapshot,並做定時補償
|
||||
|
||||
## 8. 下一階段(建議)
|
||||
- 加入 Alembic migration
|
||||
- 為 permission/action 加 enum 與驗證規則
|
||||
- 增加 audit log(誰在何時授權/撤銷)
|
||||
- 加入 rate-limit 與可觀測性(metrics + request id)
|
||||
## DB Migration
|
||||
- 初始化:`backend/scripts/init_schema.sql`
|
||||
- 舊庫補齊:`backend/scripts/migrate_align_company_site_member_system.sql`
|
||||
|
||||
@@ -10,9 +10,9 @@ cp .env.example .env
|
||||
```
|
||||
|
||||
## 2. 建立資料表
|
||||
1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql`
|
||||
2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql`
|
||||
3. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
|
||||
1. 先執行 `member.ose.tw/backend/scripts/init_schema.sql`(已含 `api_clients`)
|
||||
2. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_align_company_site_member_system.sql`
|
||||
3. 若是更舊資料庫,再補 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
|
||||
|
||||
## 3. 啟動服務
|
||||
```bash
|
||||
|
||||
@@ -2,144 +2,99 @@
|
||||
|
||||
Base URL:`https://memberapi.ose.tw`
|
||||
|
||||
## 0. 帳號密碼登入
|
||||
### POST `/auth/login`
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"username": "your-authentik-username",
|
||||
"password": "your-password"
|
||||
}
|
||||
```
|
||||
|
||||
200 Response:
|
||||
```json
|
||||
{
|
||||
"access_token": "<jwt>",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "openid profile email"
|
||||
}
|
||||
```
|
||||
|
||||
401 Response:
|
||||
```json
|
||||
{ "detail": "invalid_username_or_password" }
|
||||
```
|
||||
## 0. OIDC 登入
|
||||
- `GET /auth/oidc/url?redirect_uri=...`
|
||||
- `POST /auth/oidc/exchange`
|
||||
|
||||
## 1. 使用者資訊
|
||||
### GET `/me`
|
||||
Headers:
|
||||
- `Authorization: Bearer <access_token>`
|
||||
- `GET /me`
|
||||
- `GET /me/permissions/snapshot`
|
||||
|
||||
200 Response:
|
||||
`permissions` item:
|
||||
```json
|
||||
{
|
||||
"sub": "authentik-sub-123",
|
||||
"email": "user@example.com",
|
||||
"display_name": "User Name"
|
||||
"scope_type": "company|site",
|
||||
"scope_id": "company_key_or_site_key",
|
||||
"system": "mkt",
|
||||
"module": "mkt.campaign",
|
||||
"action": "view"
|
||||
}
|
||||
```
|
||||
|
||||
401 Error:
|
||||
```json
|
||||
{ "detail": "missing_bearer_token" }
|
||||
```
|
||||
或
|
||||
```json
|
||||
{ "detail": "invalid_bearer_token" }
|
||||
```
|
||||
|
||||
## 2. 我的權限快照
|
||||
### GET `/me/permissions/snapshot`
|
||||
## 2. 權限(User 直接授權)
|
||||
Headers:
|
||||
- `Authorization: Bearer <access_token>`
|
||||
- `X-Client-Key`
|
||||
- `X-API-Key`
|
||||
|
||||
200 Response:
|
||||
```json
|
||||
{
|
||||
"authentik_sub": "authentik-sub-123",
|
||||
"permissions": [
|
||||
{
|
||||
"scope_type": "site",
|
||||
"scope_id": "tw-main",
|
||||
"module": "campaign",
|
||||
"action": "view"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Grant 權限
|
||||
### POST `/admin/permissions/grant`
|
||||
Headers:
|
||||
- `X-Client-Key: <client_key>`
|
||||
- `X-API-Key: <plain_api_key>`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"authentik_sub": "authentik-sub-123",
|
||||
"authentik_sub": "authentik-sub",
|
||||
"email": "user@example.com",
|
||||
"display_name": "User Name",
|
||||
"scope_type": "site",
|
||||
"scope_id": "tw-main",
|
||||
"display_name": "User",
|
||||
"scope_type": "company",
|
||||
"scope_id": "ose-main",
|
||||
"system": "mkt",
|
||||
"module": "campaign",
|
||||
"action": "view"
|
||||
}
|
||||
```
|
||||
|
||||
200 Response:
|
||||
```json
|
||||
{
|
||||
"permission_id": "uuid",
|
||||
"result": "granted"
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Revoke 權限
|
||||
### POST `/admin/permissions/revoke`
|
||||
Headers:
|
||||
- `X-Client-Key: <client_key>`
|
||||
- `X-API-Key: <plain_api_key>`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"authentik_sub": "authentik-sub-123",
|
||||
"authentik_sub": "authentik-sub",
|
||||
"scope_type": "site",
|
||||
"scope_id": "tw-main",
|
||||
"system": "mkt",
|
||||
"module": "campaign",
|
||||
"action": "view"
|
||||
}
|
||||
```
|
||||
|
||||
200 Response:
|
||||
```json
|
||||
{
|
||||
"deleted": 1,
|
||||
"result": "revoked"
|
||||
}
|
||||
```
|
||||
說明:
|
||||
- `module` 可省略,代表系統層權限,後端會使用 `system.__system__`。
|
||||
- `module` 有值時會組成 `{system}.{module}` 存入(例如 `mkt.campaign`)。
|
||||
|
||||
404 Response:
|
||||
```json
|
||||
{ "detail": "user_not_found" }
|
||||
```
|
||||
## 3. 主資料管理(admin)
|
||||
Headers:
|
||||
- `X-Client-Key`
|
||||
- `X-API-Key`
|
||||
|
||||
## 5. Health Check
|
||||
### GET `/healthz`
|
||||
200 Response:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
- `GET/POST /admin/systems`
|
||||
- `GET/POST /admin/modules`
|
||||
- `GET/POST /admin/companies`
|
||||
- `GET/POST /admin/sites`
|
||||
- `GET /admin/members`
|
||||
|
||||
## 6. 常見錯誤碼
|
||||
## 4. 權限群組(一組權限綁多個 user)
|
||||
Headers:
|
||||
- `X-Client-Key`
|
||||
- `X-API-Key`
|
||||
|
||||
- `GET/POST /admin/permission-groups`
|
||||
- `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
|
||||
- `DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}`
|
||||
- `POST /admin/permission-groups/{group_key}/permissions/grant`
|
||||
- `POST /admin/permission-groups/{group_key}/permissions/revoke`
|
||||
|
||||
群組授權 payload 與 user 授權 payload 相同(用 `system/module/scope/action`)。
|
||||
|
||||
## 5. Internal 查詢 API(其他系統)
|
||||
Headers:
|
||||
- `X-Internal-Secret`
|
||||
|
||||
- `GET /internal/systems`
|
||||
- `GET /internal/modules`
|
||||
- `GET /internal/companies`
|
||||
- `GET /internal/sites`
|
||||
- `GET /internal/members`
|
||||
- `GET /internal/permissions/{authentik_sub}/snapshot`
|
||||
|
||||
## 6. 常見錯誤
|
||||
- `401 invalid_client`
|
||||
- `401 invalid_api_key`
|
||||
- `401 client_expired`
|
||||
- `403 origin_not_allowed`
|
||||
- `403 ip_not_allowed`
|
||||
- `403 path_not_allowed`
|
||||
- `503 internal_secret_not_configured`
|
||||
- `503 authentik_admin_not_configured`
|
||||
- `401 invalid_internal_secret`
|
||||
- `404 system_not_found`
|
||||
- `404 company_not_found`
|
||||
- `404 site_not_found`
|
||||
|
||||
101
docs/FRONTEND_HANDOFF_SCHEMA_V2.md
Normal file
101
docs/FRONTEND_HANDOFF_SCHEMA_V2.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Frontend 交辦清單(Schema v2)✅ 已完成
|
||||
|
||||
## 目標
|
||||
前端實現對應後端新模型:
|
||||
- 公司(companies)
|
||||
- 品牌站台(sites)
|
||||
- 會員(users)
|
||||
- 系統/模組(systems/modules)
|
||||
- 權限群組(permission-groups)
|
||||
|
||||
## 既有頁面調整
|
||||
|
||||
### 1) 權限管理頁 `/admin/permissions` ✅
|
||||
- [x] Grant/Revoke payload 改為:
|
||||
- [x] `scope_type`: `company` 或 `site`(下拉選單)
|
||||
- [x] `scope_id`: `company_key` 或 `site_key`
|
||||
- [x] `system`: 必填(例如 `mkt`)
|
||||
- [x] `module`: 選填(空值代表系統層權限)
|
||||
- [x] `action`
|
||||
- [x] 表單新增 `system` 欄位
|
||||
- [x] `module` 改成可選
|
||||
|
||||
### 2) 我的權限頁 `/me/permissions` ✅
|
||||
- [x] 表格新增顯示欄位:
|
||||
- [x] `scope_type`
|
||||
- [x] `scope_id`
|
||||
- [x] `system`
|
||||
- [x] `module`
|
||||
- [x] `action`
|
||||
|
||||
## 新增頁面 ✅
|
||||
|
||||
### 3) 系統管理 `/admin/systems` ✅
|
||||
- [x] 列表:`GET /admin/systems`
|
||||
- [x] 新增:`POST /admin/systems`
|
||||
- [x] 表格顯示 system_key 與 name
|
||||
- [x] Dialog 表單新增系統
|
||||
|
||||
### 4) 模組管理 `/admin/modules` ✅
|
||||
- [x] 列表:`GET /admin/modules`
|
||||
- [x] 新增:`POST /admin/modules`
|
||||
- [x] `system_key`
|
||||
- [x] `module_key`
|
||||
- [x] `name`
|
||||
- [x] 表格顯示三個欄位
|
||||
- [x] Dialog 表單新增模組
|
||||
|
||||
### 5) 公司管理 `/admin/companies` ✅
|
||||
- [x] 列表:`GET /admin/companies`
|
||||
- [x] 新增:`POST /admin/companies`
|
||||
- [x] 表格顯示 company_key 與 name
|
||||
- [x] Dialog 表單新增公司
|
||||
|
||||
### 6) 站台管理 `/admin/sites` ✅
|
||||
- [x] 列表:`GET /admin/sites`
|
||||
- [x] 新增:`POST /admin/sites`
|
||||
- [x] `site_key`
|
||||
- [x] `company_key`
|
||||
- [x] `name`
|
||||
- [x] 表格顯示三個欄位
|
||||
- [x] Dialog 表單新增站台
|
||||
|
||||
### 7) 會員列表 `/admin/members` ✅
|
||||
- [x] 列表:`GET /admin/members`
|
||||
- [x] 表格顯示 authentik_sub、email、display_name
|
||||
- [x] 可重新整理
|
||||
|
||||
### 8) 權限群組 `/admin/permission-groups` ✅
|
||||
- [x] 群組管理 Tab:
|
||||
- [x] 列表:`GET /admin/permission-groups`
|
||||
- [x] 新增:`POST /admin/permission-groups`
|
||||
- [x] Dialog 表單新增群組
|
||||
- [x] 綁定會員 Tab:
|
||||
- [x] `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
|
||||
- [x] UI 支援群組選擇 + authentik_sub 輸入 + 加入按鈕
|
||||
- [x] 群組授權 Tab:
|
||||
- [x] `POST /admin/permission-groups/{group_key}/permissions/grant`
|
||||
- [x] `POST /admin/permission-groups/{group_key}/permissions/revoke`
|
||||
- [x] UI 支援選擇群組、輸入權限資訊、grant/revoke 按鈕
|
||||
|
||||
## 共用資料管理 ✅
|
||||
- [x] admin.js store 實現:
|
||||
- [x] 統一載入 systems、modules、companies、sites
|
||||
- [x] 供各管理頁使用,避免重複 API 呼叫
|
||||
|
||||
## 認證(管理 API) ✅
|
||||
- [x] 所有 `/admin/*` API 一律帶:
|
||||
- [x] `X-Client-Key`
|
||||
- [x] `X-API-Key`
|
||||
- [x] axios adminHttp client 自動注入 headers
|
||||
|
||||
## 驗收條件 ✅
|
||||
- [x] 可以新增 system/module/company/site
|
||||
- [x] 可以做 user 直接 grant/revoke(新 payload)
|
||||
- [x] 可以建立 permission-group、加會員、做群組 grant/revoke
|
||||
- [x] `/me/permissions/snapshot` 能看到所有權限欄位(scope_type/scope_id/system/module/action)
|
||||
|
||||
## 完成日期
|
||||
- 開始:2026-03-29
|
||||
- 完成:2026-03-30
|
||||
- 提交 Commit:`c4b9789`
|
||||
94
docs/ORG_MEMBER_MANAGEMENT_PLAN.md
Normal file
94
docs/ORG_MEMBER_MANAGEMENT_PLAN.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 組織與會員管理規劃(給前端/後端協作)
|
||||
|
||||
## 1. 目前狀態(你現在看到空白是正常)
|
||||
- 已完成:
|
||||
- Authentik 登入
|
||||
- `/me` 基本個人資料
|
||||
- 權限 grant/revoke 與 snapshot
|
||||
- 尚未完成:
|
||||
- 公司組織(Organization)CRUD
|
||||
- 會員(Member)清單/新增/編輯/停用
|
||||
- 會員與組織關聯管理
|
||||
|
||||
## 2. 建議產品資訊架構(IA)
|
||||
- `我的資料`:目前登入者基本資訊、登出
|
||||
- `我的權限`:目前登入者權限快照
|
||||
- `組織管理`:公司清單、建立公司、編輯公司、狀態切換
|
||||
- `會員管理`:會員清單、邀請/建立會員、編輯會員、停用會員、指派組織
|
||||
- `權限管理`:保留現有 grant/revoke(可作為管理員進階頁)
|
||||
|
||||
## 3. 後端 API(v1,已開)
|
||||
|
||||
### Organization(admin)
|
||||
- `GET /admin/organizations`
|
||||
- `POST /admin/organizations`
|
||||
- `GET /admin/organizations/{org_id}`
|
||||
- `PATCH /admin/organizations/{org_id}`
|
||||
- `POST /admin/organizations/{org_id}/activate`
|
||||
- `POST /admin/organizations/{org_id}/deactivate`
|
||||
|
||||
### Member(admin)
|
||||
- `GET /admin/members`
|
||||
- `POST /admin/members`
|
||||
- `GET /admin/members/{member_id}`
|
||||
- `PATCH /admin/members/{member_id}`
|
||||
- `POST /admin/members/{member_id}/activate`
|
||||
- `POST /admin/members/{member_id}/deactivate`
|
||||
|
||||
### Member x Organization(admin)
|
||||
- `GET /admin/members/{member_id}/organizations`
|
||||
- `POST /admin/members/{member_id}/organizations/{org_id}`
|
||||
- `DELETE /admin/members/{member_id}/organizations/{org_id}`
|
||||
|
||||
### Internal 查詢 API(給其他系統)
|
||||
- `GET /internal/members`
|
||||
- `GET /internal/members/by-sub/{authentik_sub}`
|
||||
- `GET /internal/organizations`
|
||||
- `GET /internal/organizations/by-code/{org_code}`
|
||||
- `GET /internal/members/{member_id}/organizations`
|
||||
|
||||
## 4. 建議資料表(最小可行)
|
||||
- `organizations`
|
||||
- `id` (uuid)
|
||||
- `org_code` (unique)
|
||||
- `name`
|
||||
- `tax_id` (nullable)
|
||||
- `status` (`active|inactive`)
|
||||
- `created_at`, `updated_at`
|
||||
- `members`
|
||||
- `id` (uuid)
|
||||
- `authentik_sub` (unique)
|
||||
- `email`
|
||||
- `display_name`
|
||||
- `status` (`active|inactive`)
|
||||
- `created_at`, `updated_at`
|
||||
- `member_organizations`
|
||||
- `member_id`
|
||||
- `organization_id`
|
||||
- unique(`member_id`, `organization_id`)
|
||||
|
||||
## 5. 前端頁面需求(給另一個 AI)
|
||||
- `/admin/organizations`
|
||||
- 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用
|
||||
- `/admin/members`
|
||||
- 表格 + 查詢 + 新增 Dialog + 編輯 Dialog + 啟停用
|
||||
- `/admin/members/:id/organizations`
|
||||
- 左側會員資訊,右側組織綁定清單 + 加入/移除
|
||||
|
||||
## 6. 權限模型(建議)
|
||||
- `org.manage`:組織管理
|
||||
- `member.manage`:會員管理
|
||||
- `permission.manage`:權限管理
|
||||
|
||||
可映射到現有權限欄位:
|
||||
- `scope_type=global`
|
||||
- `scope_id=member-center`
|
||||
- `module=organization|member|permission`
|
||||
- `action=view|create|update|deactivate|grant|revoke`
|
||||
|
||||
## 7. 驗收標準
|
||||
- 可以建立/修改/停用組織
|
||||
- 可以建立/修改/停用會員
|
||||
- 可以將會員加入/移出組織
|
||||
- UI 顯示成功/失敗訊息,並可重新整理資料
|
||||
- 所有管理 API 都有管理員金鑰驗證(`X-Client-Key` + `X-API-Key`)
|
||||
@@ -1,33 +1,60 @@
|
||||
# Frontend TaskPlan
|
||||
|
||||
## 目標
|
||||
完成 member.ose.tw 前端(Vue3 + JS + Vite + Element Plus + Tailwind),可獨立完成登入、查看個人資料、查看權限、管理授權。
|
||||
完成 member.ose.tw 前端(Vue3 + JS + Vite + Element Plus + Tailwind),支援 OIDC 登入、個人資料查看、權限管理(Schema v2)。
|
||||
|
||||
## 已完成(依目前程式)
|
||||
## Phase 1: 基礎框架 ✅
|
||||
- [x] Vite + Vue3 專案結構
|
||||
- [x] Element Plus + Tailwind 基礎接入
|
||||
- [x] Router 與頁面骨架
|
||||
- [x] `LoginPage`(token 輸入)
|
||||
- [x] `MePage`(`GET /me`)
|
||||
- [x] `PermissionSnapshotPage`(`GET /me/permissions/snapshot`)
|
||||
- [x] `PermissionAdminPage`(grant/revoke)
|
||||
- [x] Pinia store(auth + permission)
|
||||
- [x] Axios 分離 user/admin client
|
||||
- [x] Production build 可通過
|
||||
|
||||
## 進行中(建議近期)
|
||||
- [ ] 補路由守衛策略(是否限制 `/admin/permissions` 需登入)
|
||||
## Phase 2: OIDC 登入流程 ✅
|
||||
- [x] `LoginPage`(OIDC 前往按鈕,跳轉 Authentik)
|
||||
- [x] `AuthCallbackPage`(接收 code,交換 access_token)
|
||||
- [x] Token 自動存儲與路由守衛
|
||||
- [x] 401 時自動導向重新登入
|
||||
|
||||
## Phase 3: 用戶資訊與權限 ✅
|
||||
- [x] `MePage`(`GET /me` 顯示個人資料)
|
||||
- [x] `PermissionSnapshotPage`(`GET /me/permissions/snapshot`)
|
||||
- [x] 表格新增 `system` 欄位(Schema v2)
|
||||
|
||||
## Phase 4: 管理員授權(v1) ✅
|
||||
- [x] `PermissionAdminPage`(直接 grant/revoke 使用者)
|
||||
- [x] Payload 新增 `system` 必填、`module` 改為選填
|
||||
- [x] `scope_type` 改為 company/site 下拉選單
|
||||
|
||||
## Phase 5: Schema v2 管理頁面 ✅
|
||||
- [x] API 層:systems、modules、companies、sites、members、permission-groups
|
||||
- [x] Store:admin.js(統一管理公共清單)
|
||||
- [x] 6 個新管理頁面:
|
||||
- [x] `/admin/systems`(系統 CRUD)
|
||||
- [x] `/admin/modules`(模組 CRUD)
|
||||
- [x] `/admin/companies`(公司 CRUD)
|
||||
- [x] `/admin/sites`(站台 CRUD)
|
||||
- [x] `/admin/members`(會員列表)
|
||||
- [x] `/admin/permission-groups`(群組 CRUD + 綁會員 + 群組授權)
|
||||
- [x] 導覽列加入管理員群組下拉菜單
|
||||
|
||||
## 進行中(下一階段)
|
||||
- [ ] 組織與會員管理(`ORG_MEMBER_MANAGEMENT_PLAN.md`)
|
||||
- [ ] 路由守衛策略完善(是否限制某些管理頁)
|
||||
- [ ] 錯誤訊息 i18n 與統一顯示格式
|
||||
- [ ] 新增操作完成後自動刷新快照的 UX
|
||||
|
||||
## 待辦(上線前)
|
||||
- [ ] 增加 e2e / UI smoke 測試
|
||||
- [ ] 優化 bundle size(目前 main chunk 偏大)
|
||||
- [ ] 優化 bundle size(目前 main chunk 1.2MB,需考慮 lazy loading)
|
||||
- [ ] 加入環境切換策略(dev/staging/prod)
|
||||
- [ ] 加入登入來源與 token 取得說明頁
|
||||
|
||||
## 驗收條件
|
||||
- [ ] 未登入時導向登入頁行為正確
|
||||
- [ ] 登入後可穩定讀取 `/me` 與快照
|
||||
- [ ] 管理頁 grant/revoke 成功與錯誤提示完整
|
||||
- [ ] 與後端契約文件一致(`FRONTEND_API_CONTRACT.md`)
|
||||
## 驗收條件(Schema v2)
|
||||
- [x] 未登入時導向登入頁 → OIDC 流程 ✅
|
||||
- [x] 登入後可穩定讀取 `/me` 與快照 ✅
|
||||
- [x] 可新增 system/module/company/site ✅
|
||||
- [x] 可做用戶直接 grant/revoke(新 payload) ✅
|
||||
- [x] 可建立 permission-group、加會員、群組 grant/revoke ✅
|
||||
- [x] `/me/permissions/snapshot` 表格可顯示 system + module + action ✅
|
||||
- [x] 與後端契約文件一致 ✅
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
## 任務管理
|
||||
- `docs/TASKPLAN_FRONTEND.md`
|
||||
- `docs/TASKPLAN_BACKEND.md`
|
||||
- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃)
|
||||
- `docs/FRONTEND_HANDOFF_SCHEMA_V2.md`(前端交辦清單,直接給另一隻 AI)
|
||||
|
||||
## SQL 與配置
|
||||
- `docs/API_CLIENTS_SQL.sql`
|
||||
- `backend/scripts/init_schema.sql`
|
||||
- `docs/DB_SCHEMA_SNAPSHOT.md`
|
||||
|
||||
## 給前端 AI 的一句話交接
|
||||
|
||||
@@ -17,13 +17,26 @@
|
||||
>
|
||||
我的權限
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/permissions"
|
||||
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
|
||||
active-class="text-blue-600 font-medium"
|
||||
>
|
||||
權限管理
|
||||
</router-link>
|
||||
|
||||
<div class="flex items-center gap-4 border-l border-gray-300 pl-6">
|
||||
<el-dropdown @command="handleAdminNav">
|
||||
<span class="text-sm text-gray-600 hover:text-blue-600 cursor-pointer transition-colors">
|
||||
管理員 <el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="permissions">權限管理</el-dropdown-item>
|
||||
<el-dropdown-divider />
|
||||
<el-dropdown-item command="systems">系統管理</el-dropdown-item>
|
||||
<el-dropdown-item command="modules">模組管理</el-dropdown-item>
|
||||
<el-dropdown-item command="companies">公司管理</el-dropdown-item>
|
||||
<el-dropdown-item command="sites">站台管理</el-dropdown-item>
|
||||
<el-dropdown-item command="members">會員列表</el-dropdown-item>
|
||||
<el-dropdown-item command="groups">權限群組</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
|
||||
</nav>
|
||||
@@ -37,6 +50,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -44,6 +58,19 @@ const authStore = useAuthStore()
|
||||
|
||||
const isLoginPage = computed(() => route.name === 'login')
|
||||
|
||||
function handleAdminNav(command) {
|
||||
const routes = {
|
||||
permissions: '/admin/permissions',
|
||||
systems: '/admin/systems',
|
||||
modules: '/admin/modules',
|
||||
companies: '/admin/companies',
|
||||
sites: '/admin/sites',
|
||||
members: '/admin/members',
|
||||
groups: '/admin/permission-groups'
|
||||
}
|
||||
router.push(routes[command])
|
||||
}
|
||||
|
||||
function logout() {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
|
||||
@@ -2,3 +2,9 @@ import { userHttp } from './http'
|
||||
|
||||
export const loginWithPassword = (username, password) =>
|
||||
userHttp.post('/auth/login', { username, password })
|
||||
|
||||
export const getOidcAuthorizeUrl = (redirectUri) =>
|
||||
userHttp.get('/auth/oidc/url', { params: { redirect_uri: redirectUri } })
|
||||
|
||||
export const exchangeOidcCode = (code, redirectUri) =>
|
||||
userHttp.post('/auth/oidc/exchange', { code, redirect_uri: redirectUri })
|
||||
|
||||
4
frontend/src/api/companies.js
Normal file
4
frontend/src/api/companies.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getCompanies = () => adminHttp.get('/admin/companies')
|
||||
export const createCompany = (data) => adminHttp.post('/admin/companies', data)
|
||||
3
frontend/src/api/members.js
Normal file
3
frontend/src/api/members.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getMembers = () => adminHttp.get('/admin/members')
|
||||
4
frontend/src/api/modules.js
Normal file
4
frontend/src/api/modules.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getModules = () => adminHttp.get('/admin/modules')
|
||||
export const createModule = (data) => adminHttp.post('/admin/modules', data)
|
||||
16
frontend/src/api/permission-groups.js
Normal file
16
frontend/src/api/permission-groups.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getPermissionGroups = () => adminHttp.get('/admin/permission-groups')
|
||||
export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data)
|
||||
|
||||
export const addMemberToGroup = (groupKey, authentikSub) =>
|
||||
adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
|
||||
|
||||
export const removeMemberFromGroup = (groupKey, authentikSub) =>
|
||||
adminHttp.delete(`/admin/permission-groups/${groupKey}/members/${authentikSub}`)
|
||||
|
||||
export const groupGrant = (groupKey, data) =>
|
||||
adminHttp.post(`/admin/permission-groups/${groupKey}/permissions/grant`, data)
|
||||
|
||||
export const groupRevoke = (groupKey, data) =>
|
||||
adminHttp.post(`/admin/permission-groups/${groupKey}/permissions/revoke`, data)
|
||||
4
frontend/src/api/sites.js
Normal file
4
frontend/src/api/sites.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getSites = () => adminHttp.get('/admin/sites')
|
||||
export const createSite = (data) => adminHttp.post('/admin/sites', data)
|
||||
4
frontend/src/api/systems.js
Normal file
4
frontend/src/api/systems.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { adminHttp } from './http'
|
||||
|
||||
export const getSystems = () => adminHttp.get('/admin/systems')
|
||||
export const createSystem = (data) => adminHttp.post('/admin/systems', data)
|
||||
73
frontend/src/pages/AuthCallbackPage.vue
Normal file
73
frontend/src/pages/AuthCallbackPage.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-[70vh]">
|
||||
<el-card class="w-full max-w-md shadow-md">
|
||||
<div class="text-center">
|
||||
<el-icon class="text-3xl text-blue-600 mb-3">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<h2 class="text-lg font-bold text-gray-800 mb-2">正在登入...</h2>
|
||||
<p v-if="!error" class="text-sm text-gray-500">
|
||||
正在驗證身份,請稍候
|
||||
</p>
|
||||
<p v-if="error" class="text-sm text-red-600 font-medium">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { exchangeOidcCode } from '@/api/auth'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const code = route.query.code
|
||||
const state = route.query.state
|
||||
|
||||
if (!code) {
|
||||
error.value = '缺少驗證代碼,登入失敗'
|
||||
setTimeout(() => router.push('/login'), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
const res = await exchangeOidcCode(code, redirectUri)
|
||||
const { access_token } = res.data
|
||||
|
||||
if (!access_token) {
|
||||
error.value = '無法取得 access token'
|
||||
setTimeout(() => router.push('/login'), 2000)
|
||||
return
|
||||
}
|
||||
|
||||
// 存 token 並取得使用者資料
|
||||
authStore.setToken(access_token)
|
||||
await authStore.fetchMe()
|
||||
|
||||
// 導向原頁面或預設的 /me
|
||||
const redirect = sessionStorage.getItem('post_login_redirect') || '/me'
|
||||
sessionStorage.removeItem('post_login_redirect')
|
||||
router.push(redirect)
|
||||
} catch (err) {
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail === 'invalid_authorization_code') {
|
||||
error.value = '授權代碼無效,請重新登入'
|
||||
} else if (detail) {
|
||||
error.value = `登入失敗:${detail}`
|
||||
} else {
|
||||
error.value = '登入過程出錯,請重新登入'
|
||||
}
|
||||
setTimeout(() => router.push('/login'), 3000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -8,82 +8,53 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form @submit.prevent="handleLogin">
|
||||
<el-form-item label="帳號">
|
||||
<el-input
|
||||
v-model="username"
|
||||
placeholder="請輸入 Authentik username / email"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密碼">
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="請輸入密碼"
|
||||
clearable
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="error"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
@click="handleOidcLogin"
|
||||
>
|
||||
前往 Authentik 登入
|
||||
</el-button>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
class="w-full"
|
||||
:loading="loading"
|
||||
:disabled="!username.trim() || !password.trim()"
|
||||
>
|
||||
登入
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="text-xs text-gray-400 text-center mt-2">登入成功後 access token 會存於本機 localStorage</p>
|
||||
<div class="mt-4 text-xs text-gray-400 text-center space-y-1">
|
||||
<p>會跳轉到 Authentik 輸入帳號密碼,成功後自動回來。</p>
|
||||
<p>登入成功後 access token 會存於本機 localStorage。</p>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { loginWithPassword } from '@/api/auth'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getOidcAuthorizeUrl } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value.trim() || !password.value.trim()) return
|
||||
async function handleOidcLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const loginRes = await loginWithPassword(username.value.trim(), password.value)
|
||||
authStore.setToken(loginRes.data.access_token)
|
||||
await authStore.fetchMe()
|
||||
const redirect = route.query.redirect || '/me'
|
||||
router.push(redirect)
|
||||
sessionStorage.setItem('post_login_redirect', typeof redirect === 'string' ? redirect : '/me')
|
||||
const callbackUrl = `${window.location.origin}/auth/callback`
|
||||
const res = await getOidcAuthorizeUrl(callbackUrl)
|
||||
window.location.href = res.data.authorize_url
|
||||
} catch (err) {
|
||||
authStore.logout()
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail === 'invalid_username_or_password') {
|
||||
error.value = '帳號或密碼錯誤'
|
||||
} else if (detail === 'authentik_login_not_configured') {
|
||||
if (detail === 'authentik_login_not_configured') {
|
||||
error.value = '後端尚未設定 Authentik 登入參數'
|
||||
} else {
|
||||
error.value = '登入失敗,請稍後再試'
|
||||
|
||||
96
frontend/src/pages/admin/CompaniesPage.vue
Normal file
96
frontend/src/pages/admin/CompaniesPage.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">公司管理</h2>
|
||||
<el-button type="primary" @click="showDialog = true" :icon="Plus">新增公司</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="companies" stripe border class="w-full shadow-sm">
|
||||
<el-empty v-if="companies.length === 0" slot="empty" description="目前無公司" />
|
||||
<el-table-column prop="company_key" label="Company Key" width="200" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增公司" @close="resetForm">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="Company Key">
|
||||
<el-input v-model="form.company_key" placeholder="company-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱">
|
||||
<el-input v-model="form.name" placeholder="公司名稱" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getCompanies, createCompany } from '@/api/companies'
|
||||
|
||||
const companies = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({ company_key: '', name: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getCompanies()
|
||||
companies.value = res.data || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = '載入失敗,請稍後再試'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { company_key: '', name: '' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.company_key || !form.value.name) {
|
||||
ElMessage.warning('請填寫完整資訊')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createCompany(form.value)
|
||||
ElMessage.success('新增成功')
|
||||
showDialog.value = false
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗,請稍後再試')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
53
frontend/src/pages/admin/MembersPage.vue
Normal file
53
frontend/src/pages/admin/MembersPage.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">會員列表</h2>
|
||||
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="members" stripe border class="w-full shadow-sm">
|
||||
<el-empty v-if="members.length === 0" slot="empty" description="目前無會員" />
|
||||
<el-table-column prop="authentik_sub" label="Authentik Sub" min-width="200" />
|
||||
<el-table-column prop="email" label="Email" min-width="200" />
|
||||
<el-table-column prop="display_name" label="顯示名稱" width="150" />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getMembers } from '@/api/members'
|
||||
|
||||
const members = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getMembers()
|
||||
members.value = res.data || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = '載入失敗,請稍後再試'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
100
frontend/src/pages/admin/ModulesPage.vue
Normal file
100
frontend/src/pages/admin/ModulesPage.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">模組管理</h2>
|
||||
<el-button type="primary" @click="showDialog = true" :icon="Plus">新增模組</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="modules" stripe border class="w-full shadow-sm">
|
||||
<el-empty v-if="modules.length === 0" slot="empty" description="目前無模組" />
|
||||
<el-table-column prop="system_key" label="System Key" width="140" />
|
||||
<el-table-column prop="module_key" label="Module Key" width="160" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增模組" @close="resetForm">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="System Key">
|
||||
<el-input v-model="form.system_key" placeholder="mkt" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Module Key">
|
||||
<el-input v-model="form.module_key" placeholder="campaign" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱">
|
||||
<el-input v-model="form.name" placeholder="行銷活動" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getModules, createModule } from '@/api/modules'
|
||||
|
||||
const modules = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({ system_key: '', module_key: '', name: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getModules()
|
||||
modules.value = res.data || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = '載入失敗,請稍後再試'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { system_key: '', module_key: '', name: '' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.system_key || !form.value.module_key || !form.value.name) {
|
||||
ElMessage.warning('請填寫完整資訊')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createModule(form.value)
|
||||
ElMessage.success('新增成功')
|
||||
showDialog.value = false
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗,請稍後再試')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
296
frontend/src/pages/admin/PermissionGroupsPage.vue
Normal file
296
frontend/src/pages/admin/PermissionGroupsPage.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-6">權限群組管理</h2>
|
||||
|
||||
<!-- 認證 -->
|
||||
<el-card class="mb-6 shadow-sm">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-700">管理員認證</span>
|
||||
<el-tag v-if="credsSaved" type="success" size="small">已儲存(session)</el-tag>
|
||||
<el-tag v-else type="warning" size="small">未設定</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="credsForm" inline>
|
||||
<el-form-item label="X-Client-Key">
|
||||
<el-input v-model="credsForm.clientKey" placeholder="client key" style="width: 220px" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="X-API-Key">
|
||||
<el-input v-model="credsForm.apiKey" placeholder="api key" style="width: 220px" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveCreds">儲存認證</el-button>
|
||||
<el-button v-if="credsSaved" @click="clearCreds" class="ml-2">清除</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm">
|
||||
<!-- Groups Tab -->
|
||||
<el-tab-pane label="群組管理" name="groups">
|
||||
<div class="mt-4">
|
||||
<el-button v-if="credsSaved" type="primary" @click="showCreateGroup = true" :icon="Plus" class="mb-4">
|
||||
新增群組
|
||||
</el-button>
|
||||
<p v-if="!credsSaved" class="text-xs text-yellow-600 mb-4">請先設定管理員認證</p>
|
||||
|
||||
<el-skeleton v-if="loadingGroups" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="groups" stripe border class="w-full">
|
||||
<el-empty v-if="groups.length === 0" slot="empty" description="目前無群組" />
|
||||
<el-table-column prop="group_key" label="Group Key" width="180" />
|
||||
<el-table-column prop="name" label="群組名稱" min-width="200" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Members Tab -->
|
||||
<el-tab-pane label="綁定會員" name="members" :disabled="!credsSaved">
|
||||
<div class="mt-4">
|
||||
<el-form :model="memberForm" label-width="120px" class="max-w-xl mb-4">
|
||||
<el-form-item label="Group Key">
|
||||
<el-select v-model="memberForm.groupKey" placeholder="選擇群組">
|
||||
<el-option v-for="g in groups" :key="g.group_key" :label="`${g.name} (${g.group_key})`" :value="g.group_key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Authentik Sub">
|
||||
<el-input v-model="memberForm.authentikSub" placeholder="authentik-sub-xxx" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="addingMember" @click="handleAddMember" :disabled="!memberForm.groupKey || !memberForm.authentikSub">
|
||||
加入群組
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p v-if="memberError" class="text-red-600 text-sm mb-2">{{ memberError }}</p>
|
||||
<p v-if="memberSuccess" class="text-green-600 text-sm mb-2">{{ memberSuccess }}</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Permissions Tab -->
|
||||
<el-tab-pane label="群組授權" name="permissions" :disabled="!credsSaved">
|
||||
<div class="mt-4">
|
||||
<el-form :model="groupPermForm" label-width="120px" class="max-w-xl mb-4">
|
||||
<el-form-item label="Group Key">
|
||||
<el-select v-model="groupPermForm.groupKey" placeholder="選擇群組">
|
||||
<el-option v-for="g in groups" :key="g.group_key" :label="`${g.name} (${g.group_key})`" :value="g.group_key" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope Type">
|
||||
<el-select v-model="groupPermForm.scope_type" placeholder="company or site">
|
||||
<el-option label="Company" value="company" />
|
||||
<el-option label="Site" value="site" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID">
|
||||
<el-input v-model="groupPermForm.scope_id" placeholder="company_key or site_key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系統">
|
||||
<el-input v-model="groupPermForm.system" placeholder="mkt" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組(選填)">
|
||||
<el-input v-model="groupPermForm.module" placeholder="campaign" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作">
|
||||
<el-input v-model="groupPermForm.action" placeholder="view" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="grantingGroupPerm"
|
||||
@click="handleGroupGrant"
|
||||
:disabled="!groupPermForm.groupKey || !groupPermForm.scope_type || !groupPermForm.scope_id || !groupPermForm.system || !groupPermForm.action"
|
||||
>
|
||||
Grant 授權
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
class="ml-2"
|
||||
:loading="revokingGroupPerm"
|
||||
@click="handleGroupRevoke"
|
||||
:disabled="!groupPermForm.groupKey || !groupPermForm.scope_type || !groupPermForm.scope_id || !groupPermForm.system || !groupPermForm.action"
|
||||
>
|
||||
Revoke 撤銷
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p v-if="groupPermError" class="text-red-600 text-sm mb-2">{{ groupPermError }}</p>
|
||||
<p v-if="groupPermSuccess" class="text-green-600 text-sm mb-2">{{ groupPermSuccess }}</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Create Group Dialog -->
|
||||
<el-dialog v-model="showCreateGroup" title="新增群組" @close="resetCreateForm">
|
||||
<el-form :model="createForm" label-width="120px">
|
||||
<el-form-item label="Group Key">
|
||||
<el-input v-model="createForm.group_key" placeholder="group-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="群組名稱">
|
||||
<el-input v-model="createForm.name" placeholder="群組名稱" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateGroup = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creatingGroup" @click="handleCreateGroup">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import {
|
||||
getPermissionGroups,
|
||||
createPermissionGroup,
|
||||
addMemberToGroup,
|
||||
removeMemberFromGroup,
|
||||
groupGrant,
|
||||
groupRevoke
|
||||
} from '@/api/permission-groups'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
const activeTab = ref('groups')
|
||||
|
||||
// 認證
|
||||
const credsForm = reactive({
|
||||
clientKey: permissionStore.adminClientKey,
|
||||
apiKey: permissionStore.adminApiKey
|
||||
})
|
||||
|
||||
const credsSaved = computed(() => permissionStore.hasAdminCreds())
|
||||
|
||||
function saveCreds() {
|
||||
if (!credsForm.clientKey || !credsForm.apiKey) {
|
||||
ElMessage.warning('請填寫完整認證')
|
||||
return
|
||||
}
|
||||
permissionStore.setAdminCreds(credsForm.clientKey, credsForm.apiKey)
|
||||
ElMessage.success('認證已儲存(session)')
|
||||
}
|
||||
|
||||
function clearCreds() {
|
||||
permissionStore.clearAdminCreds()
|
||||
credsForm.clientKey = ''
|
||||
credsForm.apiKey = ''
|
||||
ElMessage.info('認證已清除')
|
||||
}
|
||||
|
||||
// Groups
|
||||
const groups = ref([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
async function loadGroups() {
|
||||
loadingGroups.value = true
|
||||
try {
|
||||
const res = await getPermissionGroups()
|
||||
groups.value = res.data || []
|
||||
} catch (err) {
|
||||
ElMessage.error('載入群組失敗')
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Create Group
|
||||
const showCreateGroup = ref(false)
|
||||
const creatingGroup = ref(false)
|
||||
const createForm = reactive({ group_key: '', name: '' })
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.group_key = ''
|
||||
createForm.name = ''
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
if (!createForm.group_key || !createForm.name) {
|
||||
ElMessage.warning('請填寫完整資訊')
|
||||
return
|
||||
}
|
||||
creatingGroup.value = true
|
||||
try {
|
||||
await createPermissionGroup(createForm)
|
||||
ElMessage.success('新增成功')
|
||||
showCreateGroup.value = false
|
||||
resetCreateForm()
|
||||
await loadGroups()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗')
|
||||
} finally {
|
||||
creatingGroup.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add Member
|
||||
const memberForm = reactive({ groupKey: '', authentikSub: '' })
|
||||
const addingMember = ref(false)
|
||||
const memberError = ref('')
|
||||
const memberSuccess = ref('')
|
||||
|
||||
async function handleAddMember() {
|
||||
memberError.value = ''
|
||||
memberSuccess.value = ''
|
||||
addingMember.value = true
|
||||
try {
|
||||
await addMemberToGroup(memberForm.groupKey, memberForm.authentikSub)
|
||||
memberSuccess.value = '加入成功'
|
||||
memberForm.groupKey = ''
|
||||
memberForm.authentikSub = ''
|
||||
} catch (err) {
|
||||
memberError.value = '加入失敗,請稍後再試'
|
||||
} finally {
|
||||
addingMember.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Group Grant/Revoke
|
||||
const groupPermForm = reactive({
|
||||
groupKey: '',
|
||||
scope_type: '',
|
||||
scope_id: '',
|
||||
system: '',
|
||||
module: '',
|
||||
action: ''
|
||||
})
|
||||
const grantingGroupPerm = ref(false)
|
||||
const revokingGroupPerm = ref(false)
|
||||
const groupPermError = ref('')
|
||||
const groupPermSuccess = ref('')
|
||||
|
||||
async function handleGroupGrant() {
|
||||
groupPermError.value = ''
|
||||
groupPermSuccess.value = ''
|
||||
grantingGroupPerm.value = true
|
||||
try {
|
||||
const { groupKey, ...permData } = groupPermForm
|
||||
await groupGrant(groupKey, permData)
|
||||
groupPermSuccess.value = 'Grant 成功'
|
||||
} catch (err) {
|
||||
groupPermError.value = 'Grant 失敗'
|
||||
} finally {
|
||||
grantingGroupPerm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGroupRevoke() {
|
||||
groupPermError.value = ''
|
||||
groupPermSuccess.value = ''
|
||||
revokingGroupPerm.value = true
|
||||
try {
|
||||
const { groupKey, ...permData } = groupPermForm
|
||||
await groupRevoke(groupKey, permData)
|
||||
groupPermSuccess.value = 'Revoke 成功'
|
||||
} catch (err) {
|
||||
groupPermError.value = 'Revoke 失敗'
|
||||
} finally {
|
||||
revokingGroupPerm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGroups)
|
||||
</script>
|
||||
100
frontend/src/pages/admin/SitesPage.vue
Normal file
100
frontend/src/pages/admin/SitesPage.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">站台管理</h2>
|
||||
<el-button type="primary" @click="showDialog = true" :icon="Plus">新增站台</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="sites" stripe border class="w-full shadow-sm">
|
||||
<el-empty v-if="sites.length === 0" slot="empty" description="目前無站台" />
|
||||
<el-table-column prop="site_key" label="Site Key" width="160" />
|
||||
<el-table-column prop="company_key" label="Company Key" width="160" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增站台" @close="resetForm">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="Site Key">
|
||||
<el-input v-model="form.site_key" placeholder="site-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Company Key">
|
||||
<el-input v-model="form.company_key" placeholder="company-001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱">
|
||||
<el-input v-model="form.name" placeholder="站台名稱" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getSites, createSite } from '@/api/sites'
|
||||
|
||||
const sites = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({ site_key: '', company_key: '', name: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getSites()
|
||||
sites.value = res.data || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = '載入失敗,請稍後再試'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { site_key: '', company_key: '', name: '' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.site_key || !form.value.company_key || !form.value.name) {
|
||||
ElMessage.warning('請填寫完整資訊')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createSite(form.value)
|
||||
ElMessage.success('新增成功')
|
||||
showDialog.value = false
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗,請稍後再試')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
96
frontend/src/pages/admin/SystemsPage.vue
Normal file
96
frontend/src/pages/admin/SystemsPage.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-bold text-gray-800">系統管理</h2>
|
||||
<el-button type="primary" @click="showDialog = true" :icon="Plus">新增系統</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="errorMsg"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="4" animated />
|
||||
|
||||
<el-table v-else :data="systems" stripe border class="w-full shadow-sm">
|
||||
<el-empty v-if="systems.length === 0" slot="empty" description="目前無系統" />
|
||||
<el-table-column prop="system_key" label="System Key" width="200" />
|
||||
<el-table-column prop="name" label="名稱" min-width="180" />
|
||||
</el-table>
|
||||
|
||||
<!-- 新增 Dialog -->
|
||||
<el-dialog v-model="showDialog" title="新增系統" @close="resetForm">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="System Key">
|
||||
<el-input v-model="form.system_key" placeholder="mkt" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名稱">
|
||||
<el-input v-model="form.name" placeholder="行銷平台" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleCreate">確認</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getSystems, createSystem } from '@/api/systems'
|
||||
|
||||
const systems = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const showDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const form = ref({ system_key: '', name: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const res = await getSystems()
|
||||
systems.value = res.data || []
|
||||
} catch (err) {
|
||||
error.value = true
|
||||
errorMsg.value = '載入失敗,請稍後再試'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = { system_key: '', name: '' }
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.system_key || !form.value.name) {
|
||||
ElMessage.warning('請填寫完整資訊')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createSystem(form.value)
|
||||
ElMessage.success('新增成功')
|
||||
showDialog.value = false
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (err) {
|
||||
ElMessage.error('新增失敗,請稍後再試')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -57,13 +57,19 @@
|
||||
<el-input v-model="grantForm.display_name" placeholder="User Name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope 類型" prop="scope_type">
|
||||
<el-input v-model="grantForm.scope_type" placeholder="site" />
|
||||
<el-select v-model="grantForm.scope_type" placeholder="選擇 Scope 類型">
|
||||
<el-option label="Company" value="company" />
|
||||
<el-option label="Site" value="site" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID" prop="scope_id">
|
||||
<el-input v-model="grantForm.scope_id" placeholder="tw-main" />
|
||||
<el-input v-model="grantForm.scope_id" placeholder="company_key or site_key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組" prop="module">
|
||||
<el-input v-model="grantForm.module" placeholder="campaign" />
|
||||
<el-form-item label="系統" prop="system">
|
||||
<el-input v-model="grantForm.system" placeholder="mkt" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組(選填)" prop="module">
|
||||
<el-input v-model="grantForm.module" placeholder="campaign(空值代表系統層)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作" prop="action">
|
||||
<el-input v-model="grantForm.action" placeholder="view" />
|
||||
@@ -115,13 +121,19 @@
|
||||
<el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope 類型" prop="scope_type">
|
||||
<el-input v-model="revokeForm.scope_type" placeholder="site" />
|
||||
<el-select v-model="revokeForm.scope_type" placeholder="選擇 Scope 類型">
|
||||
<el-option label="Company" value="company" />
|
||||
<el-option label="Site" value="site" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Scope ID" prop="scope_id">
|
||||
<el-input v-model="revokeForm.scope_id" placeholder="tw-main" />
|
||||
<el-input v-model="revokeForm.scope_id" placeholder="company_key or site_key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組" prop="module">
|
||||
<el-input v-model="revokeForm.module" placeholder="campaign" />
|
||||
<el-form-item label="系統" prop="system">
|
||||
<el-input v-model="revokeForm.system" placeholder="mkt" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模組(選填)" prop="module">
|
||||
<el-input v-model="revokeForm.module" placeholder="campaign(空值代表系統層)" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作" prop="action">
|
||||
<el-input v-model="revokeForm.action" placeholder="view" />
|
||||
@@ -207,6 +219,7 @@ const grantForm = reactive({
|
||||
display_name: '',
|
||||
scope_type: '',
|
||||
scope_id: '',
|
||||
system: '',
|
||||
module: '',
|
||||
action: ''
|
||||
})
|
||||
@@ -218,7 +231,7 @@ const grantRules = {
|
||||
display_name: [required],
|
||||
scope_type: [required],
|
||||
scope_id: [required],
|
||||
module: [required],
|
||||
system: [required],
|
||||
action: [required]
|
||||
}
|
||||
|
||||
@@ -255,6 +268,7 @@ const revokeForm = reactive({
|
||||
authentik_sub: '',
|
||||
scope_type: '',
|
||||
scope_id: '',
|
||||
system: '',
|
||||
module: '',
|
||||
action: ''
|
||||
})
|
||||
@@ -263,7 +277,7 @@ const revokeRules = {
|
||||
authentik_sub: [required],
|
||||
scope_type: [required],
|
||||
scope_id: [required],
|
||||
module: [required],
|
||||
system: [required],
|
||||
action: [required]
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@
|
||||
border
|
||||
class="w-full shadow-sm"
|
||||
>
|
||||
<el-table-column prop="scope_type" label="Scope 類型" width="130" />
|
||||
<el-table-column prop="scope_id" label="Scope ID" min-width="160" />
|
||||
<el-table-column prop="module" label="模組" width="140" />
|
||||
<el-table-column prop="scope_type" label="Scope 類型" width="100" />
|
||||
<el-table-column prop="scope_id" label="Scope ID" min-width="140" />
|
||||
<el-table-column prop="system" label="系統" width="120" />
|
||||
<el-table-column prop="module" label="模組" width="120" />
|
||||
<el-table-column prop="action" label="操作" width="100" />
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,11 @@ const routes = [
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/auth/callback',
|
||||
name: 'auth-callback',
|
||||
component: () => import('@/pages/AuthCallbackPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/me',
|
||||
name: 'me',
|
||||
@@ -24,6 +29,36 @@ const routes = [
|
||||
path: '/admin/permissions',
|
||||
name: 'admin-permissions',
|
||||
component: () => import('@/pages/permissions/PermissionAdminPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/systems',
|
||||
name: 'admin-systems',
|
||||
component: () => import('@/pages/admin/SystemsPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/modules',
|
||||
name: 'admin-modules',
|
||||
component: () => import('@/pages/admin/ModulesPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/companies',
|
||||
name: 'admin-companies',
|
||||
component: () => import('@/pages/admin/CompaniesPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/sites',
|
||||
name: 'admin-sites',
|
||||
component: () => import('@/pages/admin/SitesPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/members',
|
||||
name: 'admin-members',
|
||||
component: () => import('@/pages/admin/MembersPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/admin/permission-groups',
|
||||
name: 'admin-permission-groups',
|
||||
component: () => import('@/pages/admin/PermissionGroupsPage.vue')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
39
frontend/src/stores/admin.js
Normal file
39
frontend/src/stores/admin.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getSystems } from '@/api/systems'
|
||||
import { getModules } from '@/api/modules'
|
||||
import { getCompanies } from '@/api/companies'
|
||||
import { getSites } from '@/api/sites'
|
||||
|
||||
export const useAdminStore = defineStore('admin', () => {
|
||||
const systems = ref([])
|
||||
const modules = ref([])
|
||||
const companies = ref([])
|
||||
const sites = ref([])
|
||||
|
||||
async function loadAllData() {
|
||||
try {
|
||||
const [sysRes, modRes, comRes, siteRes] = await Promise.all([
|
||||
getSystems(),
|
||||
getModules(),
|
||||
getCompanies(),
|
||||
getSites()
|
||||
])
|
||||
systems.value = sysRes.data || []
|
||||
modules.value = modRes.data || []
|
||||
companies.value = comRes.data || []
|
||||
sites.value = siteRes.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading admin data:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systems,
|
||||
modules,
|
||||
companies,
|
||||
sites,
|
||||
loadAllData
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user