From f884f1043df93cbfbe74932c502eeb360986d977 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 03:54:22 +0800 Subject: [PATCH] feat(flow): unify member-group-permission admin workflow and docs --- backend/app/api/admin.py | 50 ++++++++- backend/app/api/admin_catalog.py | 68 ++++++++++++ .../repositories/permission_groups_repo.py | 32 ++++++ backend/app/repositories/permissions_repo.py | 101 +++++++++++++++++- backend/app/schemas/catalog.py | 18 ++++ backend/app/schemas/permissions.py | 22 ++++ docs/BACKEND_ARCHITECTURE.md | 77 +++++++------ docs/FRONTEND_API_CONTRACT.md | 43 ++++++-- docs/FRONTEND_HANDOFF_SCHEMA_V2.md | 26 +++-- docs/TASKPLAN_FRONTEND.md | 6 +- docs/index.md | 48 ++++++--- frontend/src/api/members.js | 3 + frontend/src/api/permission-admin.js | 2 + frontend/src/api/permission-groups.js | 1 + frontend/src/pages/admin/MembersPage.vue | 45 +++++++- .../src/pages/admin/PermissionGroupsPage.vue | 86 ++++++--------- .../pages/permissions/PermissionAdminPage.vue | 78 +++++++++++++- 17 files changed, 576 insertions(+), 130 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 5be9951..b8bdd61 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException, status +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from app.db.session import get_db @@ -9,7 +11,12 @@ 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.schemas.permissions import ( + DirectPermissionListResponse, + DirectPermissionRow, + PermissionGrantRequest, + PermissionRevokeRequest, +) from app.security.api_client_auth import require_api_client router = APIRouter(prefix="/admin", tags=["admin"]) @@ -98,3 +105,42 @@ def revoke_permission( site_id=site_id, ) return {"deleted": deleted, "result": "revoked"} + + +@router.get("/permissions/direct", response_model=DirectPermissionListResponse) +def list_direct_permissions( + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), + keyword: str | None = Query(default=None), + scope_type: str | None = Query(default=None), + limit: int = Query(default=200, ge=1, le=500), + offset: int = Query(default=0, ge=0), +) -> DirectPermissionListResponse: + perms_repo = PermissionsRepository(db) + items, total = perms_repo.list_direct_permissions( + keyword=keyword, + scope_type=scope_type, + limit=limit, + offset=offset, + ) + return DirectPermissionListResponse( + items=[DirectPermissionRow(**item) for item in items], + total=total, + limit=limit, + offset=offset, + ) + + +@router.delete("/permissions/direct/{permission_id}") +def delete_direct_permission( + permission_id: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, int | str]: + try: + normalized_permission_id = str(UUID(permission_id)) + except ValueError: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid_permission_id") + perms_repo = PermissionsRepository(db) + deleted = perms_repo.revoke_by_permission_id(normalized_permission_id) + return {"deleted": deleted, "result": "revoked"} diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py index 3ee0d84..818a775 100644 --- a/backend/app/api/admin_catalog.py +++ b/backend/app/api/admin_catalog.py @@ -15,6 +15,8 @@ from app.schemas.catalog import ( CompanyItem, CompanyUpdateRequest, MemberItem, + MemberPermissionGroupsResponse, + MemberPermissionGroupsUpdateRequest, MemberUpdateRequest, MemberUpsertRequest, ModuleCreateRequest, @@ -22,6 +24,7 @@ from app.schemas.catalog import ( ModuleUpdateRequest, PermissionGroupCreateRequest, PermissionGroupItem, + PermissionGroupPermissionItem, PermissionGroupUpdateRequest, SiteCreateRequest, SiteItem, @@ -411,6 +414,45 @@ def update_member( ) +@router.get("/members/{authentik_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) +def get_member_permission_groups( + authentik_sub: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberPermissionGroupsResponse: + users_repo = UsersRepository(db) + groups_repo = PermissionGroupsRepository(db) + user = users_repo.get_by_sub(authentik_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + group_keys = groups_repo.list_group_keys_by_member_sub(authentik_sub) + return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=group_keys) + + +@router.put("/members/{authentik_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) +def set_member_permission_groups( + authentik_sub: str, + payload: MemberPermissionGroupsUpdateRequest, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> MemberPermissionGroupsResponse: + users_repo = UsersRepository(db) + groups_repo = PermissionGroupsRepository(db) + user = users_repo.get_by_sub(authentik_sub) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") + + unique_group_keys = list(dict.fromkeys(payload.group_keys)) + groups = groups_repo.get_by_keys(unique_group_keys) + found_keys = {g.group_key for g in groups} + missing = [k for k in unique_group_keys if k not in found_keys] + if missing: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"group_not_found:{','.join(missing)}") + + groups_repo.replace_member_groups(authentik_sub, [g.id for g in groups]) + return MemberPermissionGroupsResponse(authentik_sub=authentik_sub, group_keys=unique_group_keys) + + @router.get("/permission-groups") def list_permission_groups( _: ApiClient = Depends(require_api_client), @@ -423,6 +465,32 @@ def list_permission_groups( 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.get("/permission-groups/{group_key}/permissions") +def list_permission_group_permissions( + group_key: str, + _: ApiClient = Depends(require_api_client), + db: Session = Depends(get_db), +) -> dict[str, list[dict]]: + repo = PermissionGroupsRepository(db) + group = repo.get_by_key(group_key) + if not group: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="group_not_found") + rows = repo.list_group_permissions(group.id) + return { + "items": [ + PermissionGroupPermissionItem( + id=r.id, + system=r.system, + module=r.module, + action=r.action, + scope_type=r.scope_type, + scope_id=r.scope_id, + ).model_dump() + for r in rows + ] + } + + @router.post("/permission-groups", response_model=PermissionGroupItem) def create_permission_group( payload: PermissionGroupCreateRequest, diff --git a/backend/app/repositories/permission_groups_repo.py b/backend/app/repositories/permission_groups_repo.py index b67dd8c..ff2477f 100644 --- a/backend/app/repositories/permission_groups_repo.py +++ b/backend/app/repositories/permission_groups_repo.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from sqlalchemy import delete, func, select from sqlalchemy.orm import Session @@ -16,6 +18,12 @@ class PermissionGroupsRepository: def get_by_id(self, group_id: str) -> PermissionGroup | None: return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id)) + def get_by_keys(self, group_keys: list[str]) -> list[PermissionGroup]: + if not group_keys: + return [] + stmt = select(PermissionGroup).where(PermissionGroup.group_key.in_(group_keys)) + return list(self.db.scalars(stmt).all()) + 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) @@ -60,6 +68,22 @@ class PermissionGroupsRepository: self.db.commit() return int(result.rowcount or 0) + def list_group_keys_by_member_sub(self, authentik_sub: str) -> list[str]: + stmt = ( + select(PermissionGroup.group_key) + .select_from(PermissionGroupMember) + .join(PermissionGroup, PermissionGroup.id == PermissionGroupMember.group_id) + .where(PermissionGroupMember.authentik_sub == authentik_sub) + .order_by(PermissionGroup.group_key.asc()) + ) + return [row[0] for row in self.db.execute(stmt).all()] + + def replace_member_groups(self, authentik_sub: str, group_ids: list[str]) -> None: + self.db.execute(delete(PermissionGroupMember).where(PermissionGroupMember.authentik_sub == authentik_sub)) + for group_id in group_ids: + self.db.add(PermissionGroupMember(group_id=group_id, authentik_sub=authentik_sub)) + self.db.commit() + def grant_group_permission( self, group_id: str, @@ -93,6 +117,14 @@ class PermissionGroupsRepository: self.db.refresh(row) return row + def list_group_permissions(self, group_id: str) -> list[PermissionGroupPermission]: + stmt = ( + select(PermissionGroupPermission) + .where(PermissionGroupPermission.group_id == group_id) + .order_by(PermissionGroupPermission.scope_type.asc(), PermissionGroupPermission.scope_id.asc(), PermissionGroupPermission.system.asc(), PermissionGroupPermission.module.asc(), PermissionGroupPermission.action.asc()) + ) + return list(self.db.scalars(stmt).all()) + def revoke_group_permission( self, group_id: str, diff --git a/backend/app/repositories/permissions_repo.py b/backend/app/repositories/permissions_repo.py index 393b58a..014cd56 100644 --- a/backend/app/repositories/permissions_repo.py +++ b/backend/app/repositories/permissions_repo.py @@ -1,4 +1,4 @@ -from sqlalchemy import and_, delete, literal, or_, select +from sqlalchemy import and_, delete, func, literal, or_, select from sqlalchemy.orm import Session from app.models.company import Company @@ -6,6 +6,7 @@ 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 import User from app.models.user_scope_permission import UserScopePermission @@ -119,3 +120,101 @@ class PermissionsRepository: result = self.db.execute(stmt) self.db.commit() return int(result.rowcount or 0) + + def list_direct_permissions( + self, + *, + keyword: str | None = None, + scope_type: str | None = None, + limit: int = 200, + offset: int = 0, + ) -> tuple[list[dict], int]: + stmt = ( + select( + UserScopePermission.id, + User.authentik_sub, + User.email, + User.display_name, + UserScopePermission.scope_type, + Company.company_key, + Site.site_key, + Module.module_key, + UserScopePermission.action, + UserScopePermission.created_at, + ) + .select_from(UserScopePermission) + .join(User, User.id == UserScopePermission.user_id) + .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) + ) + count_stmt = ( + select(func.count()) + .select_from(UserScopePermission) + .join(User, User.id == UserScopePermission.user_id) + .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) + ) + + if scope_type in {"company", "site"}: + stmt = stmt.where(UserScopePermission.scope_type == scope_type) + count_stmt = count_stmt.where(UserScopePermission.scope_type == scope_type) + + if keyword: + pattern = f"%{keyword}%" + cond = or_( + User.authentik_sub.ilike(pattern), + User.email.ilike(pattern), + User.display_name.ilike(pattern), + Module.module_key.ilike(pattern), + Company.company_key.ilike(pattern), + Site.site_key.ilike(pattern), + UserScopePermission.action.ilike(pattern), + ) + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + + stmt = stmt.order_by(UserScopePermission.created_at.desc()).limit(limit).offset(offset) + rows = self.db.execute(stmt).all() + total = int(self.db.scalar(count_stmt) or 0) + items: list[dict] = [] + for row in rows: + ( + permission_id, + authentik_sub, + email, + display_name, + row_scope_type, + company_key, + site_key, + module_key, + action, + created_at, + ) = row + scope_id = company_key if row_scope_type == "company" else site_key + system_key = module_key.split(".", 1)[0] if isinstance(module_key, str) and "." in module_key else None + module_name = module_key.split(".", 1)[1] if isinstance(module_key, str) and "." in module_key else module_key + if module_name == "__system__": + module_name = None + items.append( + { + "permission_id": permission_id, + "authentik_sub": authentik_sub, + "email": email, + "display_name": display_name, + "scope_type": row_scope_type, + "scope_id": scope_id, + "system": system_key, + "module": module_name, + "action": action, + "created_at": created_at, + } + ) + return items, total + + def revoke_by_permission_id(self, permission_id: str) -> int: + stmt = delete(UserScopePermission).where(UserScopePermission.id == permission_id) + result = self.db.execute(stmt) + self.db.commit() + return int(result.rowcount or 0) diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py index 1242447..ad58ad8 100644 --- a/backend/app/schemas/catalog.py +++ b/backend/app/schemas/catalog.py @@ -101,6 +101,10 @@ class MemberUpdateRequest(BaseModel): sync_to_authentik: bool = True +class MemberPermissionGroupsUpdateRequest(BaseModel): + group_keys: list[str] + + class ListResponse(BaseModel): items: list total: int @@ -124,3 +128,17 @@ class PermissionGroupItem(BaseModel): group_key: str name: str status: str + + +class PermissionGroupPermissionItem(BaseModel): + id: str + system: str + module: str + action: str + scope_type: str + scope_id: str + + +class MemberPermissionGroupsResponse(BaseModel): + authentik_sub: str + group_keys: list[str] diff --git a/backend/app/schemas/permissions.py b/backend/app/schemas/permissions.py index 489b9bf..a6ba358 100644 --- a/backend/app/schemas/permissions.py +++ b/backend/app/schemas/permissions.py @@ -1,3 +1,5 @@ +from datetime import datetime + from pydantic import BaseModel @@ -32,3 +34,23 @@ class PermissionItem(BaseModel): class PermissionSnapshotResponse(BaseModel): authentik_sub: str permissions: list[PermissionItem] + + +class DirectPermissionRow(BaseModel): + permission_id: str + authentik_sub: str + email: str | None = None + display_name: str | None = None + scope_type: str + scope_id: str + system: str | None = None + module: str | None = None + action: str + created_at: datetime + + +class DirectPermissionListResponse(BaseModel): + items: list[DirectPermissionRow] + total: int + limit: int + offset: int diff --git a/docs/BACKEND_ARCHITECTURE.md b/docs/BACKEND_ARCHITECTURE.md index de6eae3..997a9d7 100644 --- a/docs/BACKEND_ARCHITECTURE.md +++ b/docs/BACKEND_ARCHITECTURE.md @@ -1,37 +1,52 @@ -# memberapi.ose.tw 後端架構(公司/品牌站台/會員) +# memberapi.ose.tw 後端架構(公司/品牌站台/會員 + 系統/模組權限) -## 核心主檔(對齊 DB Schema) -- `users`:會員 -- `companies`:公司 -- `sites`:品牌站台(隸屬 company) -- `systems`:系統層(member/mkt/...) -- `modules`:模組(使用 `system.module` key) +## 資料層級 +- 業務層級:`companies -> sites -> users` +- 功能層級:`systems -> modules` +- 授權掛載點: + - Scope:`company` 或 `site` + - 能力:`system` 必填,`module` 選填(空值代表系統層) ## 權限模型 -- 直接權限:`user_scope_permissions` -- 群組權限:`permission_groups` + `permission_group_members` + `permission_group_permissions` -- Snapshot 回傳:合併「user 直接 + group」去重 +- 直接授權:`user_scope_permissions` +- 群組授權:`permission_groups` + `permission_group_members` + `permission_group_permissions` +- 權限快照:`/me/permissions/snapshot` 會合併「直接 + 群組」並去重 -## 授權層級 -- `system` 必填 -- `module` 選填 - - 有值:`{system}.{module}`(例:`mkt.campaign`) - - 無值:系統層權限,使用 `system.__system__` +## 目前後端 API(管理面) +- 主資料: + - `GET|POST|PATCH /admin/systems` + - `GET|POST|PATCH /admin/modules` + - `GET|POST|PATCH /admin/companies` + - `GET|POST|PATCH /admin/sites` +- 會員: + - `GET /admin/members` + - `POST /admin/members/upsert` + - `PATCH /admin/members/{authentik_sub}` +- 會員群組(改由會員頁管理): + - `GET /admin/members/{authentik_sub}/permission-groups` + - `PUT /admin/members/{authentik_sub}/permission-groups` +- 群組: + - `GET|POST|PATCH /admin/permission-groups` + - `GET /admin/permission-groups/{group_key}/permissions` + - `POST /admin/permission-groups/{group_key}/permissions/grant` + - `POST /admin/permission-groups/{group_key}/permissions/revoke` +- 直接授權: + - `POST /admin/permissions/grant` + - `POST /admin/permissions/revoke` + - `GET /admin/permissions/direct` + - `DELETE /admin/permissions/direct/{permission_id}` -## 主要 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` +## 驗證與查詢 API +- 使用者端: + - `GET /me` + - `GET /me/permissions/snapshot` +- OIDC: + - `GET /auth/oidc/url` + - `POST /auth/oidc/exchange` +- Internal(跨系統查詢): + - `GET /internal/systems|modules|companies|sites|members` + - `GET /internal/permissions/{authentik_sub}/snapshot` -## DB Migration -- 初始化:`backend/scripts/init_schema.sql` -- 舊庫補齊:`backend/scripts/migrate_align_company_site_member_system.sql` +## DB 與初始化 +- 統一 schema:`backend/scripts/init_schema.sql` +- schema 快照:`docs/DB_SCHEMA_SNAPSHOT.md` diff --git a/docs/FRONTEND_API_CONTRACT.md b/docs/FRONTEND_API_CONTRACT.md index 6aee9a3..3194c72 100644 --- a/docs/FRONTEND_API_CONTRACT.md +++ b/docs/FRONTEND_API_CONTRACT.md @@ -61,26 +61,48 @@ Headers: - `X-Client-Key` - `X-API-Key` -- `GET/POST /admin/systems` -- `GET/POST /admin/modules` -- `GET/POST /admin/companies` -- `GET/POST /admin/sites` +- `GET/POST/PATCH /admin/systems` +- `GET/POST/PATCH /admin/modules` +- `GET/POST/PATCH /admin/companies` +- `GET/POST/PATCH /admin/sites` - `GET /admin/members` +- `POST /admin/members/upsert` +- `PATCH /admin/members/{authentik_sub}` -## 4. 權限群組(一組權限綁多個 user) +## 4. 會員與群組關聯(由會員頁管理) 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}` +- `GET /admin/members/{authentik_sub}/permission-groups` +- `PUT /admin/members/{authentik_sub}/permission-groups` +```json +{ + "group_keys": ["site-ops", "mkt-admin"] +} +``` + +## 5. 權限群組(一組權限可綁多個 user) +Headers: +- `X-Client-Key` +- `X-API-Key` + +- `GET/POST/PATCH /admin/permission-groups` +- `GET /admin/permission-groups/{group_key}/permissions` - `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(其他系統) +## 6. 直接授權列表(權限管理頁) +Headers: +- `X-Client-Key` +- `X-API-Key` + +- `GET /admin/permissions/direct?keyword=&scope_type=&limit=&offset=` +- `DELETE /admin/permissions/direct/{permission_id}` + +## 7. Internal 查詢 API(其他系統) Headers: - `X-Internal-Secret` @@ -91,10 +113,11 @@ Headers: - `GET /internal/members` - `GET /internal/permissions/{authentik_sub}/snapshot` -## 6. 常見錯誤 +## 8. 常見錯誤 - `401 invalid_client` - `401 invalid_api_key` - `401 invalid_internal_secret` - `404 system_not_found` - `404 company_not_found` - `404 site_not_found` +- `400 invalid_permission_id` diff --git a/docs/FRONTEND_HANDOFF_SCHEMA_V2.md b/docs/FRONTEND_HANDOFF_SCHEMA_V2.md index 08328dd..3946e3d 100644 --- a/docs/FRONTEND_HANDOFF_SCHEMA_V2.md +++ b/docs/FRONTEND_HANDOFF_SCHEMA_V2.md @@ -1,4 +1,4 @@ -# Frontend 交辦清單(Schema v2)✅ 已完成 +# Frontend 交辦清單(Schema v2) ## 目標 前端實現對應後端新模型: @@ -60,23 +60,27 @@ - [x] 表格顯示三個欄位 - [x] Dialog 表單新增站台 -### 7) 會員列表 `/admin/members` ✅ +### 7) 會員管理 `/admin/members` ✅ - [x] 列表:`GET /admin/members` - [x] 表格顯示 authentik_sub、email、display_name -- [x] 可重新整理 +- [x] 新增會員:`POST /admin/members/upsert` +- [x] 編輯會員:`PATCH /admin/members/{authentik_sub}` +- [x] 會員頁可直接設定「權限群組」(multi-select): + - [x] `GET /admin/members/{authentik_sub}/permission-groups` + - [x] `PUT /admin/members/{authentik_sub}/permission-groups` ### 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] 群組權限列表: + - [x] `GET /admin/permission-groups/{group_key}/permissions` + - [x] 可查看群組目前有哪些系統/模組/操作權限 ## 共用資料管理 ✅ - [x] admin.js store 實現: @@ -88,11 +92,19 @@ - [x] `X-Client-Key` - [x] `X-API-Key` - [x] axios adminHttp client 自動注入 headers +- [x] 管理頁不需手動輸入金鑰(改由環境變數與攔截器帶入) + +## 權限管理頁強化 ✅ +- [x] 直接授權列表: + - [x] `GET /admin/permissions/direct` + - [x] 支援關鍵字與 scope 篩選 +- [x] 列表逐筆撤銷: + - [x] `DELETE /admin/permissions/direct/{permission_id}` ## 驗收條件 ✅ - [x] 可以新增 system/module/company/site - [x] 可以做 user 直接 grant/revoke(新 payload) -- [x] 可以建立 permission-group、加會員、做群組 grant/revoke +- [x] 可以建立 permission-group,並在會員頁指派群組,做群組 grant/revoke - [x] `/me/permissions/snapshot` 能看到所有權限欄位(scope_type/scope_id/system/module/action) ## 完成日期 diff --git a/docs/TASKPLAN_FRONTEND.md b/docs/TASKPLAN_FRONTEND.md index 7406003..1740f75 100644 --- a/docs/TASKPLAN_FRONTEND.md +++ b/docs/TASKPLAN_FRONTEND.md @@ -35,8 +35,8 @@ - [x] `/admin/modules`(模組 CRUD) - [x] `/admin/companies`(公司 CRUD) - [x] `/admin/sites`(站台 CRUD) - - [x] `/admin/members`(會員列表) - - [x] `/admin/permission-groups`(群組 CRUD + 綁會員 + 群組授權) + - [x] `/admin/members`(會員 CRUD + 指派群組) + - [x] `/admin/permission-groups`(群組 CRUD + 群組授權 + 群組權限列表) - [x] 導覽列加入管理員群組下拉菜單 ## 進行中(下一階段) @@ -55,6 +55,6 @@ - [x] 登入後可穩定讀取 `/me` 與快照 ✅ - [x] 可新增 system/module/company/site ✅ - [x] 可做用戶直接 grant/revoke(新 payload) ✅ -- [x] 可建立 permission-group、加會員、群組 grant/revoke ✅ +- [x] 可建立 permission-group,並從會員頁指派群組,做群組 grant/revoke ✅ - [x] `/me/permissions/snapshot` 表格可顯示 system + module + action ✅ - [x] 與後端契約文件一致 ✅ diff --git a/docs/index.md b/docs/index.md index 574bbaf..0ca57fa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,24 +1,38 @@ # member docs index -## 先看這三份 -1. `docs/FRONTEND_ARCHITECTURE.md` -2. `docs/FRONTEND_API_CONTRACT.md` -3. `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md` +## 0. 先看這兩份(入口) +1. `docs/ARCHITECTURE_AND_CONFIG.md` +2. `docs/BACKEND_ARCHITECTURE.md` -## 系統架構與後端 -- `docs/ARCHITECTURE_AND_CONFIG.md` -- `docs/BACKEND_ARCHITECTURE.md` +## 1. 架構核心(你現在的實際模型) +- 業務層級:`公司 companies -> 品牌站台 sites -> 會員 users` +- 功能層級:`系統 systems -> 模組 modules` +- 授權層級:`scope(company/site) + system + module(可空) + action` +- 權限來源:`直接授權 + 群組授權` + +## 2. 前端交辦(直接丟給另一隻 AI) +1. `docs/FRONTEND_API_CONTRACT.md` +2. `docs/FRONTEND_HANDOFF_SCHEMA_V2.md` +3. `docs/FRONTEND_ARCHITECTURE.md` + +## 3. 後端與環境 - `docs/BACKEND_BOOTSTRAP.md` - -## 任務管理 -- `docs/TASKPLAN_FRONTEND.md` - `docs/TASKPLAN_BACKEND.md` -- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃) -- `docs/FRONTEND_HANDOFF_SCHEMA_V2.md`(前端交辦清單,直接給另一隻 AI) +- `backend/.env.development`(本機開發) -## SQL 與配置 -- `backend/scripts/init_schema.sql` -- `docs/DB_SCHEMA_SNAPSHOT.md` +## 4. DB(單一來源) +- `backend/scripts/init_schema.sql`(完整 schema) +- `docs/DB_SCHEMA_SNAPSHOT.md`(目前資料庫結構快照) -## 給前端 AI 的一句話交接 -請先完成 `/me`、`/me/permissions/snapshot`、`/admin/permissions/grant|revoke` 三組 API 對接,並依 `FRONTEND_IMPLEMENTATION_CHECKLIST.md` 逐項完成。 +## 5. 管理流程(建議操作順序) +1. 建立 `systems`、`modules` +2. 建立 `companies`、`sites` +3. 建立/同步 `members`(可同步 Authentik) +4. 建立 `permission-groups` +5. 在會員頁指定會員所屬群組 +6. 在權限頁做直接授權,或在群組頁做群組授權 + +## 6. 前端頁面責任切分 +- 會員頁:會員基本資料 + 群組指派 +- 群組頁:群組 CRUD + 群組權限列表 + 群組授權/撤銷 +- 權限管理頁:直接授權/撤銷 + 直接授權列表(可逐筆撤銷) diff --git a/frontend/src/api/members.js b/frontend/src/api/members.js index 99c1c2f..917e3b4 100644 --- a/frontend/src/api/members.js +++ b/frontend/src/api/members.js @@ -3,3 +3,6 @@ import { adminHttp } from './http' export const getMembers = () => adminHttp.get('/admin/members') export const upsertMember = (data) => adminHttp.post('/admin/members/upsert', data) export const updateMember = (authentikSub, data) => adminHttp.patch(`/admin/members/${authentikSub}`, data) +export const getMemberPermissionGroups = (authentikSub) => adminHttp.get(`/admin/members/${authentikSub}/permission-groups`) +export const setMemberPermissionGroups = (authentikSub, groupKeys) => + adminHttp.put(`/admin/members/${authentikSub}/permission-groups`, { group_keys: groupKeys }) diff --git a/frontend/src/api/permission-admin.js b/frontend/src/api/permission-admin.js index 01062fe..cc4d23f 100644 --- a/frontend/src/api/permission-admin.js +++ b/frontend/src/api/permission-admin.js @@ -2,3 +2,5 @@ import { adminHttp } from './http' export const grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data) export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', data) +export const listDirectPermissions = (params) => adminHttp.get('/admin/permissions/direct', { params }) +export const revokeDirectPermissionById = (permissionId) => adminHttp.delete(`/admin/permissions/direct/${permissionId}`) diff --git a/frontend/src/api/permission-groups.js b/frontend/src/api/permission-groups.js index a78e317..2845f97 100644 --- a/frontend/src/api/permission-groups.js +++ b/frontend/src/api/permission-groups.js @@ -3,6 +3,7 @@ import { adminHttp } from './http' export const getPermissionGroups = () => adminHttp.get('/admin/permission-groups') export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data) export const updatePermissionGroup = (groupKey, data) => adminHttp.patch(`/admin/permission-groups/${groupKey}`, data) +export const getPermissionGroupPermissions = (groupKey) => adminHttp.get(`/admin/permission-groups/${groupKey}/permissions`) export const addMemberToGroup = (groupKey, authentikSub) => adminHttp.post(`/admin/permission-groups/${groupKey}/members/${authentikSub}`) diff --git a/frontend/src/pages/admin/MembersPage.vue b/frontend/src/pages/admin/MembersPage.vue index 79e2ca4..7108b69 100644 --- a/frontend/src/pages/admin/MembersPage.vue +++ b/frontend/src/pages/admin/MembersPage.vue @@ -30,6 +30,11 @@ + + + + + @@ -44,6 +49,11 @@ + + + + + @@ -59,9 +69,17 @@ import { ref, onMounted } from 'vue' import { ElMessage } from 'element-plus' import { Refresh } from '@element-plus/icons-vue' -import { getMembers, upsertMember, updateMember } from '@/api/members' +import { + getMembers, + upsertMember, + updateMember, + getMemberPermissionGroups, + setMemberPermissionGroups +} from '@/api/members' +import { getPermissionGroups } from '@/api/permission-groups' const members = ref([]) +const groups = ref([]) const loading = ref(false) const error = ref(false) const errorMsg = ref('') @@ -72,6 +90,7 @@ const creating = ref(false) const createForm = ref({ email: '', display_name: '', + group_keys: [], is_active: true, sync_to_authentik: true }) @@ -85,6 +104,7 @@ const editForm = ref({ authentik_sub: '', email: '', display_name: '', + group_keys: [], is_active: true, sync_to_authentik: true }) @@ -93,8 +113,9 @@ async function load() { loading.value = true error.value = false try { - const res = await getMembers() - members.value = res.data?.items || [] + const [membersRes, groupsRes] = await Promise.all([getMembers(), getPermissionGroups()]) + members.value = membersRes.data?.items || [] + groups.value = groupsRes.data?.items || [] } catch (err) { error.value = true errorMsg.value = err.response?.data?.detail || '載入失敗,請稍後再試' @@ -107,19 +128,27 @@ function resetCreateForm() { createForm.value = { email: '', display_name: '', + group_keys: [], is_active: true, sync_to_authentik: true } } -function openEdit(row) { +async function openEdit(row) { editForm.value = { authentik_sub: row.authentik_sub, email: row.email || '', display_name: row.display_name || '', + group_keys: [], is_active: !!row.is_active, sync_to_authentik: true } + try { + const res = await getMemberPermissionGroups(row.authentik_sub) + editForm.value.group_keys = res.data?.group_keys || [] + } catch (err) { + ElMessage.warning('載入會員群組失敗,仍可先編輯基本資料') + } showEditDialog.value = true } @@ -128,6 +157,7 @@ function resetEditForm() { authentik_sub: '', email: '', display_name: '', + group_keys: [], is_active: true, sync_to_authentik: true } @@ -138,7 +168,11 @@ async function handleCreate() { if (!valid) return creating.value = true try { - await upsertMember({ ...createForm.value }) + const created = await upsertMember({ ...createForm.value }) + const createdSub = created.data?.authentik_sub + if (createdSub && createForm.value.group_keys.length > 0) { + await setMemberPermissionGroups(createdSub, createForm.value.group_keys) + } ElMessage.success('新增會員成功') showCreateDialog.value = false resetCreateForm() @@ -160,6 +194,7 @@ async function handleEdit() { is_active: editForm.value.is_active, sync_to_authentik: editForm.value.sync_to_authentik }) + await setMemberPermissionGroups(editForm.value.authentik_sub, editForm.value.group_keys || []) ElMessage.success('更新會員成功') showEditDialog.value = false await load() diff --git a/frontend/src/pages/admin/PermissionGroupsPage.vue b/frontend/src/pages/admin/PermissionGroupsPage.vue index 51cf4df..954faa8 100644 --- a/frontend/src/pages/admin/PermissionGroupsPage.vue +++ b/frontend/src/pages/admin/PermissionGroupsPage.vue @@ -20,43 +20,13 @@ - - -
- - - - - - - - - - - - - - 加入群組 - - - - - - -
-
-
@@ -165,6 +135,23 @@ 儲存 + + +
+ Group: {{ selectedGroupKey }} +
+ + + + + + + + + +
@@ -176,7 +163,7 @@ import { getPermissionGroups, createPermissionGroup, updatePermissionGroup, - addMemberToGroup, + getPermissionGroupPermissions, groupGrant, groupRevoke } from '@/api/permission-groups' @@ -184,14 +171,12 @@ import { getSystems } from '@/api/systems' import { getModules } from '@/api/modules' import { getCompanies } from '@/api/companies' import { getSites } from '@/api/sites' -import { getMembers } from '@/api/members' const activeTab = ref('groups') const systems = ref([]) const modules = ref([]) const companies = ref([]) const sites = ref([]) -const members = ref([]) const actionOptions = ['view', 'edit', 'manage', 'admin'] const filteredModuleOptions = computed(() => { @@ -237,18 +222,16 @@ async function loadGroups() { } async function loadCatalogs() { - const [systemsRes, modulesRes, companiesRes, sitesRes, membersRes] = await Promise.all([ + const [systemsRes, modulesRes, companiesRes, sitesRes] = await Promise.all([ getSystems(), getModules(), getCompanies(), - getSites(), - getMembers() + getSites() ]) systems.value = systemsRes.data?.items || [] modules.value = modulesRes.data?.items || [] companies.value = companiesRes.data?.items || [] sites.value = sitesRes.data?.items || [] - members.value = membersRes.data?.items || [] } // Create Group @@ -313,25 +296,22 @@ async function handleEditGroup() { } } -// Add Member -const memberForm = reactive({ groupKey: '', authentikSub: '' }) -const addingMember = ref(false) -const memberError = ref('') -const memberSuccess = ref('') +const showPermissionsDialog = ref(false) +const loadingGroupPermissions = ref(false) +const selectedGroupPermissions = ref([]) +const selectedGroupKey = ref('') -async function handleAddMember() { - memberError.value = '' - memberSuccess.value = '' - addingMember.value = true +async function openPermissionsDialog(row) { + selectedGroupKey.value = row.group_key + showPermissionsDialog.value = true + loadingGroupPermissions.value = true try { - await addMemberToGroup(memberForm.groupKey, memberForm.authentikSub) - memberSuccess.value = '加入成功' - memberForm.groupKey = '' - memberForm.authentikSub = '' + const res = await getPermissionGroupPermissions(row.group_key) + selectedGroupPermissions.value = res.data?.items || [] } catch (err) { - memberError.value = '加入失敗,請稍後再試' + ElMessage.error('載入群組權限失敗') } finally { - addingMember.value = false + loadingGroupPermissions.value = false } } diff --git a/frontend/src/pages/permissions/PermissionAdminPage.vue b/frontend/src/pages/permissions/PermissionAdminPage.vue index 0dbc4ac..f8cca8e 100644 --- a/frontend/src/pages/permissions/PermissionAdminPage.vue +++ b/frontend/src/pages/permissions/PermissionAdminPage.vue @@ -154,6 +154,40 @@
+ + + + + + + + + + + + + + + + + + + + @@ -166,6 +200,7 @@ import { getModules } from '@/api/modules' import { getCompanies } from '@/api/companies' import { getSites } from '@/api/sites' import { getMembers } from '@/api/members' +import { listDirectPermissions, revokeDirectPermissionById } from '@/api/permission-admin' const permissionStore = usePermissionStore() @@ -176,6 +211,10 @@ const companies = ref([]) const sites = ref([]) const members = ref([]) const actionOptions = ['view', 'edit', 'manage', 'admin'] +const listFilters = reactive({ keyword: '', scope_type: '' }) +const listLoading = ref(false) +const directPermissions = ref([]) +const revokeRowLoadingId = ref('') // Grant const grantFormRef = ref() @@ -237,6 +276,7 @@ async function handleGrant() { const result = await permissionStore.grant({ ...grantForm }) grantSuccess.value = `授權成功(ID: ${result.permission_id})` ElMessage.success('Grant 成功') + await loadDirectPermissionList() } catch (err) { grantError.value = formatAdminError(err) } finally { @@ -305,6 +345,7 @@ async function handleRevoke() { const result = await permissionStore.revoke({ ...revokeForm }) revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)` ElMessage.success('Revoke 成功') + await loadDirectPermissionList() } catch (err) { revokeError.value = formatAdminError(err) } finally { @@ -356,6 +397,39 @@ async function loadCatalogs() { members.value = membersRes.data?.items || [] } +async function loadDirectPermissionList() { + listLoading.value = true + try { + const res = await listDirectPermissions({ + keyword: listFilters.keyword || undefined, + scope_type: listFilters.scope_type || undefined, + limit: 200, + offset: 0 + }) + directPermissions.value = (res.data?.items || []).map(row => ({ + ...row, + created_at: row.created_at ? new Date(row.created_at).toLocaleString() : '' + })) + } catch (err) { + ElMessage.error('載入權限列表失敗') + } finally { + listLoading.value = false + } +} + +async function handleRevokeByRow(row) { + revokeRowLoadingId.value = row.permission_id + try { + await revokeDirectPermissionById(row.permission_id) + ElMessage.success('已撤銷該筆授權') + await loadDirectPermissionList() + } catch (err) { + ElMessage.error('撤銷失敗') + } finally { + revokeRowLoadingId.value = '' + } +} + watch(() => grantForm.scope_type, () => { grantForm.scope_id = '' }) watch(() => grantForm.system, () => { grantForm.module = '' }) watch(() => revokeForm.scope_type, () => { revokeForm.scope_id = '' }) @@ -368,5 +442,7 @@ watch(() => grantForm.authentik_sub, (sub) => { grantForm.display_name = user.display_name || '' }) -onMounted(loadCatalogs) +onMounted(async () => { + await Promise.all([loadCatalogs(), loadDirectPermissionList()]) +})