feat(flow): unify member-group-permission admin workflow and docs

This commit is contained in:
Chris
2026-03-30 03:54:22 +08:00
parent 31fff92e19
commit f884f1043d
17 changed files with 576 additions and 130 deletions

View File

@@ -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"}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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]

View File

@@ -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

View File

@@ -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`

View File

@@ -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`

View File

@@ -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
## 完成日期

View File

@@ -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] 與後端契約文件一致 ✅

View File

@@ -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 + 群組權限列表 + 群組授權/撤銷
- 權限管理頁:直接授權/撤銷 + 直接授權列表(可逐筆撤銷)

View File

@@ -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 })

View File

@@ -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}`)

View File

@@ -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}`)

View File

@@ -30,6 +30,11 @@
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
<el-form-item label="Email" prop="email"><el-input v-model="createForm.email" /></el-form-item>
<el-form-item label="顯示名稱" prop="display_name"><el-input v-model="createForm.display_name" /></el-form-item>
<el-form-item label="權限群組">
<el-select v-model="createForm.group_keys" multiple filterable clearable style="width: 100%" 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="啟用"><el-switch v-model="createForm.is_active" /></el-form-item>
<el-form-item label="同步 Authentik"><el-switch v-model="createForm.sync_to_authentik" /></el-form-item>
</el-form>
@@ -44,6 +49,11 @@
<el-form-item label="Authentik Sub"><el-input :model-value="editForm.authentik_sub" disabled /></el-form-item>
<el-form-item label="Email"><el-input v-model="editForm.email" /></el-form-item>
<el-form-item label="顯示名稱"><el-input v-model="editForm.display_name" /></el-form-item>
<el-form-item label="權限群組">
<el-select v-model="editForm.group_keys" multiple filterable clearable style="width: 100%" 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="啟用"><el-switch v-model="editForm.is_active" /></el-form-item>
<el-form-item label="同步 Authentik"><el-switch v-model="editForm.sync_to_authentik" /></el-form-item>
</el-form>
@@ -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()

View File

@@ -20,43 +20,13 @@
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" @click="openEditGroup(row)">編輯</el-button>
<el-button size="small" class="ml-2" @click="openPermissionsDialog(row)">權限</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<!-- Members Tab -->
<el-tab-pane label="綁定會員" name="members">
<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-select v-model="memberForm.authentikSub" placeholder="選擇會員" filterable allow-create default-first-option style="width: 100%">
<el-option
v-for="m in members"
:key="m.authentik_sub"
:label="`${m.display_name || m.email || '(no-name)'} (${m.authentik_sub})`"
:value="m.authentik_sub"
/>
</el-select>
</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>
<el-alert v-if="memberError" :title="memberError" type="error" show-icon :closable="false" class="mt-3" />
<el-alert v-if="memberSuccess" :title="memberSuccess" type="success" show-icon :closable="false" class="mt-3" />
</div>
</el-tab-pane>
<!-- Permissions Tab -->
<el-tab-pane label="群組授權" name="permissions">
<div class="mt-4">
@@ -165,6 +135,23 @@
<el-button type="primary" :loading="savingGroup" @click="handleEditGroup">儲存</el-button>
</template>
</el-dialog>
<el-dialog v-model="showPermissionsDialog" title="群組權限列表" width="900px">
<div class="mb-3 text-sm text-gray-600">
Group: <span class="font-medium">{{ selectedGroupKey }}</span>
</div>
<el-table :data="selectedGroupPermissions" border stripe v-loading="loadingGroupPermissions">
<template #empty><el-empty description="此群組目前沒有權限" /></template>
<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="180" />
<el-table-column prop="action" label="操作" width="120" />
</el-table>
<template #footer>
<el-button @click="showPermissionsDialog = false">關閉</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -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
}
}

View File

@@ -154,6 +154,40 @@
</el-form>
</el-tab-pane>
</el-tabs>
<el-card class="mt-6 shadow-sm">
<template #header>
<div class="flex items-center justify-between gap-3">
<span class="font-medium text-gray-700">已授權列表直接授權</span>
<div class="flex items-center gap-2">
<el-input v-model="listFilters.keyword" placeholder="搜尋 email/sub/module/action" clearable style="width: 280px" @keyup.enter="loadDirectPermissionList" />
<el-select v-model="listFilters.scope_type" clearable placeholder="Scope" style="width: 140px">
<el-option label="Company" value="company" />
<el-option label="Site" value="site" />
</el-select>
<el-button :loading="listLoading" @click="loadDirectPermissionList">查詢</el-button>
</div>
</div>
</template>
<el-table :data="directPermissions" stripe border class="w-full" v-loading="listLoading">
<template #empty><el-empty description="目前沒有直接授權資料" /></template>
<el-table-column prop="display_name" label="名稱" min-width="140" />
<el-table-column prop="email" label="Email" min-width="200" />
<el-table-column prop="authentik_sub" label="Sub" min-width="200" />
<el-table-column prop="scope_type" label="Scope" width="90" />
<el-table-column prop="scope_id" label="Scope ID" min-width="120" />
<el-table-column prop="system" label="系統" width="100" />
<el-table-column prop="module" label="模組" width="130" />
<el-table-column prop="action" label="操作" width="100" />
<el-table-column prop="created_at" label="建立時間" min-width="180" />
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button type="danger" size="small" @click="handleRevokeByRow(row)" :loading="revokeRowLoadingId === row.permission_id">撤銷</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
@@ -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()])
})
</script>