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 sqlalchemy.orm import Session
from app.db.session import get_db 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.sites_repo import SitesRepository
from app.repositories.systems_repo import SystemsRepository from app.repositories.systems_repo import SystemsRepository
from app.repositories.users_repo import UsersRepository 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 from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -98,3 +105,42 @@ def revoke_permission(
site_id=site_id, site_id=site_id,
) )
return {"deleted": deleted, "result": "revoked"} 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, CompanyItem,
CompanyUpdateRequest, CompanyUpdateRequest,
MemberItem, MemberItem,
MemberPermissionGroupsResponse,
MemberPermissionGroupsUpdateRequest,
MemberUpdateRequest, MemberUpdateRequest,
MemberUpsertRequest, MemberUpsertRequest,
ModuleCreateRequest, ModuleCreateRequest,
@@ -22,6 +24,7 @@ from app.schemas.catalog import (
ModuleUpdateRequest, ModuleUpdateRequest,
PermissionGroupCreateRequest, PermissionGroupCreateRequest,
PermissionGroupItem, PermissionGroupItem,
PermissionGroupPermissionItem,
PermissionGroupUpdateRequest, PermissionGroupUpdateRequest,
SiteCreateRequest, SiteCreateRequest,
SiteItem, 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") @router.get("/permission-groups")
def list_permission_groups( def list_permission_groups(
_: ApiClient = Depends(require_api_client), _: 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} 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) @router.post("/permission-groups", response_model=PermissionGroupItem)
def create_permission_group( def create_permission_group(
payload: PermissionGroupCreateRequest, payload: PermissionGroupCreateRequest,

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,6 +18,12 @@ class PermissionGroupsRepository:
def get_by_id(self, group_id: str) -> PermissionGroup | None: def get_by_id(self, group_id: str) -> PermissionGroup | None:
return self.db.scalar(select(PermissionGroup).where(PermissionGroup.id == group_id)) 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]: 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) stmt = select(PermissionGroup).order_by(PermissionGroup.created_at.desc()).limit(limit).offset(offset)
count_stmt = select(func.count()).select_from(PermissionGroup) count_stmt = select(func.count()).select_from(PermissionGroup)
@@ -60,6 +68,22 @@ class PermissionGroupsRepository:
self.db.commit() self.db.commit()
return int(result.rowcount or 0) 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( def grant_group_permission(
self, self,
group_id: str, group_id: str,
@@ -93,6 +117,14 @@ class PermissionGroupsRepository:
self.db.refresh(row) self.db.refresh(row)
return 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( def revoke_group_permission(
self, self,
group_id: str, 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 sqlalchemy.orm import Session
from app.models.company import Company 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_member import PermissionGroupMember
from app.models.permission_group_permission import PermissionGroupPermission from app.models.permission_group_permission import PermissionGroupPermission
from app.models.site import Site from app.models.site import Site
from app.models.user import User
from app.models.user_scope_permission import UserScopePermission from app.models.user_scope_permission import UserScopePermission
@@ -119,3 +120,101 @@ class PermissionsRepository:
result = self.db.execute(stmt) result = self.db.execute(stmt)
self.db.commit() self.db.commit()
return int(result.rowcount or 0) 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 sync_to_authentik: bool = True
class MemberPermissionGroupsUpdateRequest(BaseModel):
group_keys: list[str]
class ListResponse(BaseModel): class ListResponse(BaseModel):
items: list items: list
total: int total: int
@@ -124,3 +128,17 @@ class PermissionGroupItem(BaseModel):
group_key: str group_key: str
name: str name: str
status: 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 from pydantic import BaseModel
@@ -32,3 +34,23 @@ class PermissionItem(BaseModel):
class PermissionSnapshotResponse(BaseModel): class PermissionSnapshotResponse(BaseModel):
authentik_sub: str authentik_sub: str
permissions: list[PermissionItem] 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 -> users`
- `companies`:公司 - 功能層級:`systems -> modules`
- `sites`:品牌站台(隸屬 company - 授權掛載點:
- `systems`系統層member/mkt/... - Scope`company``site`
- `modules`:模組(使用 `system.module` key - 能力:`system` 必填,`module` 選填(空值代表系統層
## 權限模型 ## 權限模型
- 直接權`user_scope_permissions` - 直接權:`user_scope_permissions`
- 群組權`permission_groups` + `permission_group_members` + `permission_group_permissions` - 群組權:`permission_groups` + `permission_group_members` + `permission_group_permissions`
- Snapshot 回傳合併「user 直接 + group」去重 - 權限快照:`/me/permissions/snapshot` 會合併「直接 + 群組」並去重
## 授權層級 ## 目前後端 API管理面
- `system` 必填 - 主資料:
- `module` 選填 - `GET|POST|PATCH /admin/systems`
- 有值:`{system}.{module}`(例:`mkt.campaign` - `GET|POST|PATCH /admin/modules`
- 無值:系統層權限,使用 `system.__system__` - `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 ## 驗證與查詢 API
- `GET /me` - 使用者端:
- `GET /me/permissions/snapshot` - `GET /me`
- `POST /admin/permissions/grant|revoke` - `GET /me/permissions/snapshot`
- `GET|POST /admin/systems` - OIDC
- `GET|POST /admin/modules` - `GET /auth/oidc/url`
- `GET|POST /admin/companies` - `POST /auth/oidc/exchange`
- `GET|POST /admin/sites` - Internal跨系統查詢
- `GET /admin/members` - `GET /internal/systems|modules|companies|sites|members`
- `GET|POST /admin/permission-groups` - `GET /internal/permissions/{authentik_sub}/snapshot`
- `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`
## DB Migration ## DB 與初始化
- 初始化`backend/scripts/init_schema.sql` - 統一 schema`backend/scripts/init_schema.sql`
- 舊庫補齊:`backend/scripts/migrate_align_company_site_member_system.sql` - schema 快照:`docs/DB_SCHEMA_SNAPSHOT.md`

View File

@@ -61,26 +61,48 @@ Headers:
- `X-Client-Key` - `X-Client-Key`
- `X-API-Key` - `X-API-Key`
- `GET/POST /admin/systems` - `GET/POST/PATCH /admin/systems`
- `GET/POST /admin/modules` - `GET/POST/PATCH /admin/modules`
- `GET/POST /admin/companies` - `GET/POST/PATCH /admin/companies`
- `GET/POST /admin/sites` - `GET/POST/PATCH /admin/sites`
- `GET /admin/members` - `GET /admin/members`
- `POST /admin/members/upsert`
- `PATCH /admin/members/{authentik_sub}`
## 4. 權限群組(一組權限綁多個 user ## 4. 會員與群組關聯(由會員頁管理
Headers: Headers:
- `X-Client-Key` - `X-Client-Key`
- `X-API-Key` - `X-API-Key`
- `GET/POST /admin/permission-groups` - `GET /admin/members/{authentik_sub}/permission-groups`
- `POST /admin/permission-groups/{group_key}/members/{authentik_sub}` - `PUT /admin/members/{authentik_sub}/permission-groups`
- `DELETE /admin/permission-groups/{group_key}/members/{authentik_sub}` ```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/grant`
- `POST /admin/permission-groups/{group_key}/permissions/revoke` - `POST /admin/permission-groups/{group_key}/permissions/revoke`
群組授權 payload 與 user 授權 payload 相同(用 `system/module/scope/action`)。 群組授權 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: Headers:
- `X-Internal-Secret` - `X-Internal-Secret`
@@ -91,10 +113,11 @@ Headers:
- `GET /internal/members` - `GET /internal/members`
- `GET /internal/permissions/{authentik_sub}/snapshot` - `GET /internal/permissions/{authentik_sub}/snapshot`
## 6. 常見錯誤 ## 8. 常見錯誤
- `401 invalid_client` - `401 invalid_client`
- `401 invalid_api_key` - `401 invalid_api_key`
- `401 invalid_internal_secret` - `401 invalid_internal_secret`
- `404 system_not_found` - `404 system_not_found`
- `404 company_not_found` - `404 company_not_found`
- `404 site_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] 表格顯示三個欄位
- [x] Dialog 表單新增站台 - [x] Dialog 表單新增站台
### 7) 會員列表 `/admin/members` ✅ ### 7) 會員管理 `/admin/members` ✅
- [x] 列表:`GET /admin/members` - [x] 列表:`GET /admin/members`
- [x] 表格顯示 authentik_sub、email、display_name - [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` ✅ ### 8) 權限群組 `/admin/permission-groups` ✅
- [x] 群組管理 Tab - [x] 群組管理 Tab
- [x] 列表:`GET /admin/permission-groups` - [x] 列表:`GET /admin/permission-groups`
- [x] 新增:`POST /admin/permission-groups` - [x] 新增:`POST /admin/permission-groups`
- [x] Dialog 表單新增群組 - [x] Dialog 表單新增群組
- [x] 綁定會員 Tab
- [x] `POST /admin/permission-groups/{group_key}/members/{authentik_sub}`
- [x] UI 支援群組選擇 + authentik_sub 輸入 + 加入按鈕
- [x] 群組授權 Tab - [x] 群組授權 Tab
- [x] `POST /admin/permission-groups/{group_key}/permissions/grant` - [x] `POST /admin/permission-groups/{group_key}/permissions/grant`
- [x] `POST /admin/permission-groups/{group_key}/permissions/revoke` - [x] `POST /admin/permission-groups/{group_key}/permissions/revoke`
- [x] UI 支援選擇群組、輸入權限資訊、grant/revoke 按鈕 - [x] UI 支援選擇群組、輸入權限資訊、grant/revoke 按鈕
- [x] 群組權限列表:
- [x] `GET /admin/permission-groups/{group_key}/permissions`
- [x] 可查看群組目前有哪些系統/模組/操作權限
## 共用資料管理 ✅ ## 共用資料管理 ✅
- [x] admin.js store 實現: - [x] admin.js store 實現:
@@ -88,11 +92,19 @@
- [x] `X-Client-Key` - [x] `X-Client-Key`
- [x] `X-API-Key` - [x] `X-API-Key`
- [x] axios adminHttp client 自動注入 headers - [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] 可以新增 system/module/company/site
- [x] 可以做 user 直接 grant/revoke新 payload - [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 - [x] `/me/permissions/snapshot` 能看到所有權限欄位scope_type/scope_id/system/module/action
## 完成日期 ## 完成日期

View File

@@ -35,8 +35,8 @@
- [x] `/admin/modules`(模組 CRUD - [x] `/admin/modules`(模組 CRUD
- [x] `/admin/companies`(公司 CRUD - [x] `/admin/companies`(公司 CRUD
- [x] `/admin/sites`(站台 CRUD - [x] `/admin/sites`(站台 CRUD
- [x] `/admin/members`(會員列表 - [x] `/admin/members`(會員 CRUD + 指派群組
- [x] `/admin/permission-groups`(群組 CRUD + 綁會員 + 群組權) - [x] `/admin/permission-groups`(群組 CRUD + 群組授權 + 群組權限列表
- [x] 導覽列加入管理員群組下拉菜單 - [x] 導覽列加入管理員群組下拉菜單
## 進行中(下一階段) ## 進行中(下一階段)
@@ -55,6 +55,6 @@
- [x] 登入後可穩定讀取 `/me` 與快照 ✅ - [x] 登入後可穩定讀取 `/me` 與快照 ✅
- [x] 可新增 system/module/company/site ✅ - [x] 可新增 system/module/company/site ✅
- [x] 可做用戶直接 grant/revoke新 payload - [x] 可做用戶直接 grant/revoke新 payload
- [x] 可建立 permission-group、加會員、群組 grant/revoke ✅ - [x] 可建立 permission-group,並從會員頁指派群組,做群組 grant/revoke ✅
- [x] `/me/permissions/snapshot` 表格可顯示 system + module + action ✅ - [x] `/me/permissions/snapshot` 表格可顯示 system + module + action ✅
- [x] 與後端契約文件一致 ✅ - [x] 與後端契約文件一致 ✅

View File

@@ -1,24 +1,38 @@
# member docs index # member docs index
## 先看這三份 ## 0. 先看這兩份(入口)
1. `docs/FRONTEND_ARCHITECTURE.md` 1. `docs/ARCHITECTURE_AND_CONFIG.md`
2. `docs/FRONTEND_API_CONTRACT.md` 2. `docs/BACKEND_ARCHITECTURE.md`
3. `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
## 系統架構與後端 ## 1. 架構核心(你現在的實際模型)
- `docs/ARCHITECTURE_AND_CONFIG.md` - 業務層級:`公司 companies -> 品牌站台 sites -> 會員 users`
- `docs/BACKEND_ARCHITECTURE.md` - 功能層級:`系統 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/BACKEND_BOOTSTRAP.md`
## 任務管理
- `docs/TASKPLAN_FRONTEND.md`
- `docs/TASKPLAN_BACKEND.md` - `docs/TASKPLAN_BACKEND.md`
- `docs/ORG_MEMBER_MANAGEMENT_PLAN.md`(公司組織/會員管理規劃 - `backend/.env.development`(本機開發
- `docs/FRONTEND_HANDOFF_SCHEMA_V2.md`(前端交辦清單,直接給另一隻 AI
## SQL 與配置 ## 4. DB單一來源
- `backend/scripts/init_schema.sql` - `backend/scripts/init_schema.sql`(完整 schema
- `docs/DB_SCHEMA_SNAPSHOT.md` - `docs/DB_SCHEMA_SNAPSHOT.md`(目前資料庫結構快照)
## 給前端 AI 的一句話交接 ## 5. 管理流程(建議操作順序)
請先完成 `/me``/me/permissions/snapshot``/admin/permissions/grant|revoke` 三組 API 對接,並依 `FRONTEND_IMPLEMENTATION_CHECKLIST.md` 逐項完成。 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 getMembers = () => adminHttp.get('/admin/members')
export const upsertMember = (data) => adminHttp.post('/admin/members/upsert', data) export const upsertMember = (data) => adminHttp.post('/admin/members/upsert', data)
export const updateMember = (authentikSub, data) => adminHttp.patch(`/admin/members/${authentikSub}`, 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 grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data)
export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', 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 getPermissionGroups = () => adminHttp.get('/admin/permission-groups')
export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data) export const createPermissionGroup = (data) => adminHttp.post('/admin/permission-groups', data)
export const updatePermissionGroup = (groupKey, data) => adminHttp.patch(`/admin/permission-groups/${groupKey}`, 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) => export const addMemberToGroup = (groupKey, authentikSub) =>
adminHttp.post(`/admin/permission-groups/${groupKey}/members/${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 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="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="顯示名稱" 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="啟用"><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-item label="同步 Authentik"><el-switch v-model="createForm.sync_to_authentik" /></el-form-item>
</el-form> </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="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="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-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="啟用"><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-item label="同步 Authentik"><el-switch v-model="editForm.sync_to_authentik" /></el-form-item>
</el-form> </el-form>
@@ -59,9 +69,17 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue' 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 members = ref([])
const groups = ref([])
const loading = ref(false) const loading = ref(false)
const error = ref(false) const error = ref(false)
const errorMsg = ref('') const errorMsg = ref('')
@@ -72,6 +90,7 @@ const creating = ref(false)
const createForm = ref({ const createForm = ref({
email: '', email: '',
display_name: '', display_name: '',
group_keys: [],
is_active: true, is_active: true,
sync_to_authentik: true sync_to_authentik: true
}) })
@@ -85,6 +104,7 @@ const editForm = ref({
authentik_sub: '', authentik_sub: '',
email: '', email: '',
display_name: '', display_name: '',
group_keys: [],
is_active: true, is_active: true,
sync_to_authentik: true sync_to_authentik: true
}) })
@@ -93,8 +113,9 @@ async function load() {
loading.value = true loading.value = true
error.value = false error.value = false
try { try {
const res = await getMembers() const [membersRes, groupsRes] = await Promise.all([getMembers(), getPermissionGroups()])
members.value = res.data?.items || [] members.value = membersRes.data?.items || []
groups.value = groupsRes.data?.items || []
} catch (err) { } catch (err) {
error.value = true error.value = true
errorMsg.value = err.response?.data?.detail || '載入失敗,請稍後再試' errorMsg.value = err.response?.data?.detail || '載入失敗,請稍後再試'
@@ -107,19 +128,27 @@ function resetCreateForm() {
createForm.value = { createForm.value = {
email: '', email: '',
display_name: '', display_name: '',
group_keys: [],
is_active: true, is_active: true,
sync_to_authentik: true sync_to_authentik: true
} }
} }
function openEdit(row) { async function openEdit(row) {
editForm.value = { editForm.value = {
authentik_sub: row.authentik_sub, authentik_sub: row.authentik_sub,
email: row.email || '', email: row.email || '',
display_name: row.display_name || '', display_name: row.display_name || '',
group_keys: [],
is_active: !!row.is_active, is_active: !!row.is_active,
sync_to_authentik: true 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 showEditDialog.value = true
} }
@@ -128,6 +157,7 @@ function resetEditForm() {
authentik_sub: '', authentik_sub: '',
email: '', email: '',
display_name: '', display_name: '',
group_keys: [],
is_active: true, is_active: true,
sync_to_authentik: true sync_to_authentik: true
} }
@@ -138,7 +168,11 @@ async function handleCreate() {
if (!valid) return if (!valid) return
creating.value = true creating.value = true
try { 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('新增會員成功') ElMessage.success('新增會員成功')
showCreateDialog.value = false showCreateDialog.value = false
resetCreateForm() resetCreateForm()
@@ -160,6 +194,7 @@ async function handleEdit() {
is_active: editForm.value.is_active, is_active: editForm.value.is_active,
sync_to_authentik: editForm.value.sync_to_authentik sync_to_authentik: editForm.value.sync_to_authentik
}) })
await setMemberPermissionGroups(editForm.value.authentik_sub, editForm.value.group_keys || [])
ElMessage.success('更新會員成功') ElMessage.success('更新會員成功')
showEditDialog.value = false showEditDialog.value = false
await load() await load()

View File

@@ -20,43 +20,13 @@
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="openEditGroup(row)">編輯</el-button> <el-button size="small" @click="openEditGroup(row)">編輯</el-button>
<el-button size="small" class="ml-2" @click="openPermissionsDialog(row)">權限</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
</el-tab-pane> </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 --> <!-- Permissions Tab -->
<el-tab-pane label="群組授權" name="permissions"> <el-tab-pane label="群組授權" name="permissions">
<div class="mt-4"> <div class="mt-4">
@@ -165,6 +135,23 @@
<el-button type="primary" :loading="savingGroup" @click="handleEditGroup">儲存</el-button> <el-button type="primary" :loading="savingGroup" @click="handleEditGroup">儲存</el-button>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
@@ -176,7 +163,7 @@ import {
getPermissionGroups, getPermissionGroups,
createPermissionGroup, createPermissionGroup,
updatePermissionGroup, updatePermissionGroup,
addMemberToGroup, getPermissionGroupPermissions,
groupGrant, groupGrant,
groupRevoke groupRevoke
} from '@/api/permission-groups' } from '@/api/permission-groups'
@@ -184,14 +171,12 @@ import { getSystems } from '@/api/systems'
import { getModules } from '@/api/modules' import { getModules } from '@/api/modules'
import { getCompanies } from '@/api/companies' import { getCompanies } from '@/api/companies'
import { getSites } from '@/api/sites' import { getSites } from '@/api/sites'
import { getMembers } from '@/api/members'
const activeTab = ref('groups') const activeTab = ref('groups')
const systems = ref([]) const systems = ref([])
const modules = ref([]) const modules = ref([])
const companies = ref([]) const companies = ref([])
const sites = ref([]) const sites = ref([])
const members = ref([])
const actionOptions = ['view', 'edit', 'manage', 'admin'] const actionOptions = ['view', 'edit', 'manage', 'admin']
const filteredModuleOptions = computed(() => { const filteredModuleOptions = computed(() => {
@@ -237,18 +222,16 @@ async function loadGroups() {
} }
async function loadCatalogs() { async function loadCatalogs() {
const [systemsRes, modulesRes, companiesRes, sitesRes, membersRes] = await Promise.all([ const [systemsRes, modulesRes, companiesRes, sitesRes] = await Promise.all([
getSystems(), getSystems(),
getModules(), getModules(),
getCompanies(), getCompanies(),
getSites(), getSites()
getMembers()
]) ])
systems.value = systemsRes.data?.items || [] systems.value = systemsRes.data?.items || []
modules.value = modulesRes.data?.items || [] modules.value = modulesRes.data?.items || []
companies.value = companiesRes.data?.items || [] companies.value = companiesRes.data?.items || []
sites.value = sitesRes.data?.items || [] sites.value = sitesRes.data?.items || []
members.value = membersRes.data?.items || []
} }
// Create Group // Create Group
@@ -313,25 +296,22 @@ async function handleEditGroup() {
} }
} }
// Add Member const showPermissionsDialog = ref(false)
const memberForm = reactive({ groupKey: '', authentikSub: '' }) const loadingGroupPermissions = ref(false)
const addingMember = ref(false) const selectedGroupPermissions = ref([])
const memberError = ref('') const selectedGroupKey = ref('')
const memberSuccess = ref('')
async function handleAddMember() { async function openPermissionsDialog(row) {
memberError.value = '' selectedGroupKey.value = row.group_key
memberSuccess.value = '' showPermissionsDialog.value = true
addingMember.value = true loadingGroupPermissions.value = true
try { try {
await addMemberToGroup(memberForm.groupKey, memberForm.authentikSub) const res = await getPermissionGroupPermissions(row.group_key)
memberSuccess.value = '加入成功' selectedGroupPermissions.value = res.data?.items || []
memberForm.groupKey = ''
memberForm.authentikSub = ''
} catch (err) { } catch (err) {
memberError.value = '加入失敗,請稍後再試' ElMessage.error('載入群組權限失敗')
} finally { } finally {
addingMember.value = false loadingGroupPermissions.value = false
} }
} }

View File

@@ -154,6 +154,40 @@
</el-form> </el-form>
</el-tab-pane> </el-tab-pane>
</el-tabs> </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> </div>
</template> </template>
@@ -166,6 +200,7 @@ import { getModules } from '@/api/modules'
import { getCompanies } from '@/api/companies' import { getCompanies } from '@/api/companies'
import { getSites } from '@/api/sites' import { getSites } from '@/api/sites'
import { getMembers } from '@/api/members' import { getMembers } from '@/api/members'
import { listDirectPermissions, revokeDirectPermissionById } from '@/api/permission-admin'
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore()
@@ -176,6 +211,10 @@ const companies = ref([])
const sites = ref([]) const sites = ref([])
const members = ref([]) const members = ref([])
const actionOptions = ['view', 'edit', 'manage', 'admin'] const actionOptions = ['view', 'edit', 'manage', 'admin']
const listFilters = reactive({ keyword: '', scope_type: '' })
const listLoading = ref(false)
const directPermissions = ref([])
const revokeRowLoadingId = ref('')
// Grant // Grant
const grantFormRef = ref() const grantFormRef = ref()
@@ -237,6 +276,7 @@ async function handleGrant() {
const result = await permissionStore.grant({ ...grantForm }) const result = await permissionStore.grant({ ...grantForm })
grantSuccess.value = `授權成功ID: ${result.permission_id}` grantSuccess.value = `授權成功ID: ${result.permission_id}`
ElMessage.success('Grant 成功') ElMessage.success('Grant 成功')
await loadDirectPermissionList()
} catch (err) { } catch (err) {
grantError.value = formatAdminError(err) grantError.value = formatAdminError(err)
} finally { } finally {
@@ -305,6 +345,7 @@ async function handleRevoke() {
const result = await permissionStore.revoke({ ...revokeForm }) const result = await permissionStore.revoke({ ...revokeForm })
revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)` revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)`
ElMessage.success('Revoke 成功') ElMessage.success('Revoke 成功')
await loadDirectPermissionList()
} catch (err) { } catch (err) {
revokeError.value = formatAdminError(err) revokeError.value = formatAdminError(err)
} finally { } finally {
@@ -356,6 +397,39 @@ async function loadCatalogs() {
members.value = membersRes.data?.items || [] 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.scope_type, () => { grantForm.scope_id = '' })
watch(() => grantForm.system, () => { grantForm.module = '' }) watch(() => grantForm.system, () => { grantForm.module = '' })
watch(() => revokeForm.scope_type, () => { revokeForm.scope_id = '' }) watch(() => revokeForm.scope_type, () => { revokeForm.scope_id = '' })
@@ -368,5 +442,7 @@ watch(() => grantForm.authentik_sub, (sub) => {
grantForm.display_name = user.display_name || '' grantForm.display_name = user.display_name || ''
}) })
onMounted(loadCatalogs) onMounted(async () => {
await Promise.all([loadCatalogs(), loadDirectPermissionList()])
})
</script> </script>