From ccb99683b86cb8a81c8559dd76b79664c8836ce9 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 30 Mar 2026 22:15:41 +0800 Subject: [PATCH] feat(members): split username/display_name, sync updates to authentik, add password reset API and refresh docs --- backend/app/api/admin.py | 1 + backend/app/api/admin_catalog.py | 65 +++++++++- backend/app/api/internal.py | 11 +- backend/app/api/internal_catalog.py | 17 ++- backend/app/api/me.py | 2 + backend/app/models/user.py | 1 + backend/app/repositories/users_repo.py | 13 +- backend/app/schemas/authentik_admin.py | 3 +- backend/app/schemas/catalog.py | 8 ++ backend/app/schemas/users.py | 1 + .../app/services/authentik_admin_service.py | 103 ++++++++++++++-- backend/scripts/init_schema.sql | 2 + .../scripts/migrate_add_users_username.sql | 16 +++ docs/ARCHITECTURE.md | 43 ++++--- docs/BACKEND_TASKPLAN.md | 25 ++-- docs/DB_SCHEMA.md | 111 +++++------------- docs/FRONTEND_HANDOFF.md | 44 +++---- docs/FRONTEND_TASKPLAN.md | 22 ++-- docs/LOCAL_DEV_RUNBOOK.md | 29 +++-- docs/index.md | 20 ++-- frontend/src/api/members.js | 1 + frontend/src/pages/admin/MembersPage.vue | 30 ++++- 22 files changed, 361 insertions(+), 207 deletions(-) create mode 100644 backend/scripts/migrate_add_users_username.sql diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 5e5b81e..c01fc64 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -72,6 +72,7 @@ def grant_permission( user = users_repo.upsert_by_sub( authentik_sub=payload.authentik_sub, + username=None, email=payload.email, display_name=payload.display_name, is_active=True, diff --git a/backend/app/api/admin_catalog.py b/backend/app/api/admin_catalog.py index 3d32ec7..8823057 100644 --- a/backend/app/api/admin_catalog.py +++ b/backend/app/api/admin_catalog.py @@ -21,6 +21,7 @@ from app.schemas.catalog import ( MemberItem, MemberPermissionGroupsResponse, MemberPermissionGroupsUpdateRequest, + MemberPasswordResetResponse, MemberUpdateRequest, MemberUpsertRequest, ModuleCreateRequest, @@ -100,7 +101,9 @@ def _generate_unique_key(prefix: str, exists_fn) -> str: def _sync_member_to_authentik( *, - authentik_sub: str, + authentik_sub: str | None, + authentik_user_id: int | None, + username: str | None, email: str | None, display_name: str | None, is_active: bool, @@ -112,8 +115,10 @@ def _sync_member_to_authentik( result = service.ensure_user( sub=authentik_sub, email=email, + username=username, display_name=display_name, is_active=is_active, + authentik_user_id=authentik_user_id, ) return { "authentik_user_id": result.user_id, @@ -450,7 +455,22 @@ def list_members( ) -> dict: users_repo = UsersRepository(db) items, total = users_repo.list(keyword=keyword, limit=limit, offset=offset) - return {"items": [MemberItem(id=i.id, authentik_sub=i.authentik_sub, email=i.email, display_name=i.display_name, is_active=i.is_active).model_dump() for i in items], "total": total, "limit": limit, "offset": offset} + return { + "items": [ + MemberItem( + id=i.id, + authentik_sub=i.authentik_sub, + username=i.username, + email=i.email, + display_name=i.display_name, + is_active=i.is_active, + ).model_dump() + for i in items + ], + "total": total, + "limit": limit, + "offset": offset, + } @router.post("/members/upsert", response_model=MemberItem) @@ -460,13 +480,16 @@ def upsert_member( ) -> MemberItem: users_repo = UsersRepository(db) resolved_sub = payload.authentik_sub + resolved_username = payload.username authentik_user_id = None if payload.sync_to_authentik: - seed_sub = payload.authentik_sub or (payload.email or "") + seed_sub = payload.authentik_sub or payload.username if not seed_sub: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="authentik_sub_or_email_required") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="authentik_sub_or_username_required") sync = _sync_member_to_authentik( authentik_sub=seed_sub, + authentik_user_id=authentik_user_id, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, @@ -478,6 +501,7 @@ def upsert_member( raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="authentik_sub_required") row = users_repo.upsert_by_sub( authentik_sub=resolved_sub, + username=resolved_username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, @@ -486,6 +510,7 @@ def upsert_member( return MemberItem( id=row.id, authentik_sub=row.authentik_sub, + username=row.username, email=row.email, display_name=row.display_name, is_active=row.is_active, @@ -504,6 +529,7 @@ def update_member( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found") next_email = payload.email if payload.email is not None else row.email + next_username = payload.username if payload.username is not None else row.username next_display_name = payload.display_name if payload.display_name is not None else row.display_name next_is_active = payload.is_active if payload.is_active is not None else row.is_active @@ -511,6 +537,8 @@ def update_member( if payload.sync_to_authentik: sync = _sync_member_to_authentik( authentik_sub=row.authentik_sub, + authentik_user_id=row.authentik_user_id, + username=next_username, email=next_email, display_name=next_display_name, is_active=next_is_active, @@ -519,6 +547,7 @@ def update_member( row = users_repo.upsert_by_sub( authentik_sub=row.authentik_sub, + username=next_username, email=next_email, display_name=next_display_name, is_active=next_is_active, @@ -527,12 +556,40 @@ def update_member( return MemberItem( id=row.id, authentik_sub=row.authentik_sub, + username=row.username, email=row.email, display_name=row.display_name, is_active=row.is_active, ) +@router.post("/members/{authentik_sub}/password/reset", response_model=MemberPasswordResetResponse) +def reset_member_password( + authentik_sub: str, + db: Session = Depends(get_db), +) -> MemberPasswordResetResponse: + users_repo = UsersRepository(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") + settings = get_settings() + service = AuthentikAdminService(settings=settings) + result = service.reset_password( + authentik_user_id=user.authentik_user_id, + email=user.email, + username=user.username, + ) + user = users_repo.upsert_by_sub( + authentik_sub=user.authentik_sub, + username=user.username, + email=user.email, + display_name=user.display_name, + is_active=user.is_active, + authentik_user_id=result.user_id, + ) + return MemberPasswordResetResponse(authentik_sub=user.authentik_sub, temporary_password=result.temporary_password) + + @router.get("/members/{authentik_sub}/permission-groups", response_model=MemberPermissionGroupsResponse) def get_member_permission_groups( authentik_sub: str, diff --git a/backend/app/api/internal.py b/backend/app/api/internal.py index 8e65066..411f67e 100644 --- a/backend/app/api/internal.py +++ b/backend/app/api/internal.py @@ -31,6 +31,7 @@ def upsert_user_by_sub( repo = UsersRepository(db) user = repo.upsert_by_sub( authentik_sub=payload.sub, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, @@ -39,6 +40,7 @@ def upsert_user_by_sub( "id": user.id, "sub": user.authentik_sub, "authentik_user_id": user.authentik_user_id, + "username": user.username, "email": user.email, "display_name": user.display_name, "is_active": user.is_active, @@ -73,13 +75,20 @@ def ensure_authentik_user( sync_result = authentik_service.ensure_user( sub=payload.sub, email=payload.email, + username=payload.username, display_name=payload.display_name, is_active=payload.is_active, ) users_repo = UsersRepository(db) + resolved_sub = payload.sub or "" + if sync_result.authentik_sub: + resolved_sub = sync_result.authentik_sub + if not resolved_sub: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_sub") users_repo.upsert_by_sub( - authentik_sub=payload.sub, + authentik_sub=resolved_sub, + username=payload.username, email=payload.email, display_name=payload.display_name, is_active=payload.is_active, diff --git a/backend/app/api/internal_catalog.py b/backend/app/api/internal_catalog.py index e8b4dd5..6c05269 100644 --- a/backend/app/api/internal_catalog.py +++ b/backend/app/api/internal_catalog.py @@ -94,4 +94,19 @@ def internal_list_members( ) -> dict: repo = UsersRepository(db) items, total = repo.list(keyword=keyword, limit=limit, offset=offset) - return {"items": [{"id": i.id, "authentik_sub": i.authentik_sub, "email": i.email, "display_name": i.display_name, "is_active": i.is_active} for i in items], "total": total, "limit": limit, "offset": offset} + return { + "items": [ + { + "id": i.id, + "authentik_sub": i.authentik_sub, + "username": i.username, + "email": i.email, + "display_name": i.display_name, + "is_active": i.is_active, + } + for i in items + ], + "total": total, + "limit": limit, + "offset": offset, + } diff --git a/backend/app/api/me.py b/backend/app/api/me.py index 05285fa..1ec711d 100644 --- a/backend/app/api/me.py +++ b/backend/app/api/me.py @@ -22,6 +22,7 @@ def get_me( users_repo = UsersRepository(db) user = users_repo.upsert_by_sub( authentik_sub=principal.sub, + username=principal.preferred_username, email=principal.email, display_name=principal.name or principal.preferred_username, is_active=True, @@ -47,6 +48,7 @@ def get_my_permission_snapshot( user = users_repo.upsert_by_sub( authentik_sub=principal.sub, + username=principal.preferred_username, email=principal.email, display_name=principal.name or principal.preferred_username, is_active=True, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 00cf559..410cead 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -14,6 +14,7 @@ class User(Base): id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4())) authentik_sub: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) authentik_user_id: Mapped[int | None] = mapped_column(Integer) + username: Mapped[str | None] = mapped_column(String(255), unique=True) email: Mapped[str | None] = mapped_column(String(320)) display_name: Mapped[str | None] = mapped_column(String(255)) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/backend/app/repositories/users_repo.py b/backend/app/repositories/users_repo.py index a45c690..1517084 100644 --- a/backend/app/repositories/users_repo.py +++ b/backend/app/repositories/users_repo.py @@ -28,7 +28,12 @@ class UsersRepository: if keyword: pattern = f"%{keyword}%" - cond = or_(User.authentik_sub.ilike(pattern), User.email.ilike(pattern), User.display_name.ilike(pattern)) + cond = or_( + User.authentik_sub.ilike(pattern), + User.username.ilike(pattern), + User.email.ilike(pattern), + User.display_name.ilike(pattern), + ) stmt = stmt.where(cond) count_stmt = count_stmt.where(cond) @@ -44,6 +49,7 @@ class UsersRepository: def upsert_by_sub( self, authentik_sub: str, + username: str | None, email: str | None, display_name: str | None, is_active: bool, @@ -54,6 +60,7 @@ class UsersRepository: user = User( authentik_sub=authentik_sub, authentik_user_id=authentik_user_id, + username=username, email=email, display_name=display_name, is_active=is_active, @@ -62,6 +69,7 @@ class UsersRepository: else: if authentik_user_id is not None: user.authentik_user_id = authentik_user_id + user.username = username user.email = email user.display_name = display_name user.is_active = is_active @@ -74,10 +82,13 @@ class UsersRepository: self, user: User, *, + username: str | None = None, email: str | None = None, display_name: str | None = None, is_active: bool | None = None, ) -> User: + if username is not None: + user.username = username if email is not None: user.email = email if display_name is not None: diff --git a/backend/app/schemas/authentik_admin.py b/backend/app/schemas/authentik_admin.py index 295b154..6d06c0b 100644 --- a/backend/app/schemas/authentik_admin.py +++ b/backend/app/schemas/authentik_admin.py @@ -2,7 +2,8 @@ from pydantic import BaseModel class AuthentikEnsureUserRequest(BaseModel): - sub: str + sub: str | None = None + username: str | None = None email: str display_name: str | None = None is_active: bool = True diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py index 09b2730..70b2000 100644 --- a/backend/app/schemas/catalog.py +++ b/backend/app/schemas/catalog.py @@ -78,6 +78,7 @@ class SiteItem(BaseModel): class MemberItem(BaseModel): id: str authentik_sub: str + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool @@ -85,6 +86,7 @@ class MemberItem(BaseModel): class MemberUpsertRequest(BaseModel): authentik_sub: str | None = None + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool = True @@ -92,12 +94,18 @@ class MemberUpsertRequest(BaseModel): class MemberUpdateRequest(BaseModel): + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool | None = None sync_to_authentik: bool = True +class MemberPasswordResetResponse(BaseModel): + authentik_sub: str + temporary_password: str + + class MemberPermissionGroupsUpdateRequest(BaseModel): group_keys: list[str] diff --git a/backend/app/schemas/users.py b/backend/app/schemas/users.py index b63f6b2..9bfd151 100644 --- a/backend/app/schemas/users.py +++ b/backend/app/schemas/users.py @@ -3,6 +3,7 @@ from pydantic import BaseModel class UserUpsertBySubRequest(BaseModel): sub: str + username: str | None = None email: str | None = None display_name: str | None = None is_active: bool = True diff --git a/backend/app/services/authentik_admin_service.py b/backend/app/services/authentik_admin_service.py index d1dcb9b..20fd938 100644 --- a/backend/app/services/authentik_admin_service.py +++ b/backend/app/services/authentik_admin_service.py @@ -1,6 +1,8 @@ from __future__ import annotations from dataclasses import dataclass +import secrets +import string import httpx from fastapi import HTTPException, status @@ -15,6 +17,12 @@ class AuthentikSyncResult: authentik_sub: str | None = None +@dataclass +class AuthentikPasswordResetResult: + user_id: int + temporary_password: str + + class AuthentikAdminService: def __init__(self, settings: Settings) -> None: self.base_url = settings.authentik_base_url.rstrip("/") @@ -40,27 +48,76 @@ class AuthentikAdminService: ) @staticmethod - def _safe_username(sub: str, email: str) -> str: + def _safe_username(sub: str | None, email: str) -> str: if email and "@" in email: return email.split("@", 1)[0] - return sub.replace("|", "_")[:150] + if sub: + return sub.replace("|", "_")[:150] + return "member-user" - def ensure_user(self, sub: str, email: str, display_name: str | None, is_active: bool = True) -> AuthentikSyncResult: + @staticmethod + def _generate_temporary_password(length: int = 14) -> str: + alphabet = string.ascii_letters + string.digits + "!@#$%^&*" + return "".join(secrets.choice(alphabet) for _ in range(length)) + + @staticmethod + def _extract_first_result(data: dict) -> dict | None: + results = data.get("results") if isinstance(data, dict) else None + return results[0] if isinstance(results, list) and results else None + + def _lookup_user_by_id(self, client: httpx.Client, user_id: int) -> dict | None: + resp = client.get(f"/api/v3/core/users/{user_id}/") + if resp.status_code == 404: + return None + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + return resp.json() + + def _lookup_user_by_email_or_username( + self, client: httpx.Client, *, email: str | None, username: str | None + ) -> dict | None: + if email: + resp = client.get("/api/v3/core/users/", params={"email": email}) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + existing = self._extract_first_result(resp.json()) + if existing: + return existing + + if username: + resp = client.get("/api/v3/core/users/", params={"username": username}) + if resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_lookup_failed") + existing = self._extract_first_result(resp.json()) + if existing: + return existing + + return None + + def ensure_user( + self, + *, + sub: str | None, + email: str, + username: str | None, + display_name: str | None, + is_active: bool = True, + authentik_user_id: int | None = None, + ) -> AuthentikSyncResult: + resolved_username = username or self._safe_username(sub=sub, email=email) payload = { - "username": self._safe_username(sub=sub, email=email), + "username": resolved_username, "name": display_name or email, "email": email, "is_active": is_active, } with self._client() as client: - resp = client.get("/api/v3/core/users/", params={"email": email}) - if resp.status_code >= 400: - raise HTTPException(status_code=502, detail="authentik_lookup_failed") - - data = resp.json() - results = data.get("results") if isinstance(data, dict) else None - existing = results[0] if isinstance(results, list) and results else None + existing = None + if authentik_user_id is not None: + existing = self._lookup_user_by_id(client, authentik_user_id) + if existing is None: + existing = self._lookup_user_by_email_or_username(client, email=email, username=resolved_username) if existing and existing.get("pk") is not None: user_pk = int(existing["pk"]) @@ -78,3 +135,27 @@ class AuthentikAdminService: action="created", authentik_sub=created.get("uid"), ) + + def reset_password( + self, + *, + authentik_user_id: int | None, + email: str | None, + username: str | None, + ) -> AuthentikPasswordResetResult: + with self._client() as client: + existing = None + if authentik_user_id is not None: + existing = self._lookup_user_by_id(client, authentik_user_id) + if existing is None: + existing = self._lookup_user_by_email_or_username(client, email=email, username=username) + if not existing or existing.get("pk") is None: + raise HTTPException(status_code=404, detail="authentik_user_not_found") + + user_pk = int(existing["pk"]) + temp_password = self._generate_temporary_password() + set_pwd_resp = client.post(f"/api/v3/core/users/{user_pk}/set_password/", json={"password": temp_password}) + if set_pwd_resp.status_code >= 400: + raise HTTPException(status_code=502, detail="authentik_set_password_failed") + + return AuthentikPasswordResetResult(user_id=user_pk, temporary_password=temp_password) diff --git a/backend/scripts/init_schema.sql b/backend/scripts/init_schema.sql index 3a74852..07e5258 100644 --- a/backend/scripts/init_schema.sql +++ b/backend/scripts/init_schema.sql @@ -21,6 +21,7 @@ CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), authentik_sub TEXT NOT NULL UNIQUE, authentik_user_id INTEGER, + username TEXT UNIQUE, email TEXT UNIQUE, display_name TEXT, status VARCHAR(16) NOT NULL DEFAULT 'active', @@ -144,6 +145,7 @@ VALUES ('member', 'Member Center', 'active') ON CONFLICT (system_key) DO NOTHING; CREATE INDEX idx_users_authentik_sub ON users(authentik_sub); +CREATE INDEX idx_users_username ON users(username); CREATE INDEX idx_sites_company_id ON sites(company_id); CREATE INDEX idx_usp_user_id ON user_scope_permissions(user_id); CREATE INDEX idx_usp_module_id ON user_scope_permissions(module_id); diff --git a/backend/scripts/migrate_add_users_username.sql b/backend/scripts/migrate_add_users_username.sql new file mode 100644 index 0000000..ee1c0c1 --- /dev/null +++ b/backend/scripts/migrate_add_users_username.sql @@ -0,0 +1,16 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS username TEXT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'uq_users_username' + ) THEN + ALTER TABLE users + ADD CONSTRAINT uq_users_username UNIQUE (username); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f03d491..ec64f26 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,29 +1,26 @@ # member.ose.tw 架構總覽 -## 1) 核心模型 -- 業務層級:`companies -> sites -> users` -- 功能層級:`systems -> modules` -- 權限核心:`permission_groups`(群組整合權限與成員) +## 核心模型 +- 業務層:`companies -> sites -> users` +- 功能層:`systems -> modules` +- 權限層:`permission_groups`(群組中心) -## 2) 權限規則(固定) -- `scope` 只允許:`site` -- `action` 只允許:`view`、`edit` -- `view`、`edit` 可同時勾選(多選) -- 畫面顯示格式:`公司/站台` +## 權限規則 +- `scope_type` 固定 `site` +- `action` 僅 `view` / `edit`(可同時存在) +- 權限透過群組下發給會員,不走細粒度 direct permission 主流程 -## 3) 關聯關係(皆為多對多) -- 群組 ⟷ 站台 -- 群組 ⟷ 系統 -- 群組 ⟷ 模組 -- 群組 ⟷ 會員 +## 後台安全線 +- 所有 `/admin/*` 需 Bearer token +- 後端僅依 `ADMIN_REQUIRED_GROUPS` 判定可否進後台 +- 不在群組就算有網址、有 Authentik 帳號也會 403 -## 4) 管理頁責任 -- 群組管理:群組基本資料 + 站台/系統/模組/會員 + action(view/edit) -- 系統編輯頁:基本資料 + 所屬群組列表 + 涉及會員列表 -- 模組編輯頁:基本資料 + 所屬群組列表 + 涉及會員列表 -- 公司頁:基本資料 + 站台列表 -- 會員編輯頁:基本資料 + 所屬群組列表 +## 會員資料與 Authentik 對齊 +- `username`:登入帳號(可編輯,可同步) +- `display_name`:顯示名稱(可編輯,可同步到 Authentik `name`) +- `authentik_sub`:由 Authentik UID 回寫 +- `authentik_user_id`:保留 Authentik user id,供更新/密碼重設 -## 5) 本輪範圍 -- 會員細粒度直接授權先暫停 -- 最終規格書(正式對外版)延後到專案完成後再整理 +## 密碼流程 +- 目前:後台可觸發重設密碼(產生臨時密碼) +- SMTP 開通後:可再補「發送密碼設定/重設通知」自動化 diff --git a/docs/BACKEND_TASKPLAN.md b/docs/BACKEND_TASKPLAN.md index 0b3a5ae..e88fe75 100644 --- a/docs/BACKEND_TASKPLAN.md +++ b/docs/BACKEND_TASKPLAN.md @@ -1,22 +1,17 @@ # Backend TaskPlan ## 待辦 -- [ ] 重寫 `backend/scripts/init_schema.sql` 為乾淨重建版(drop/recreate) -- [ ] 刪除非必要舊表與舊權限模型(只留群組中心模型) -- [ ] 新增/調整查詢 API: - - [ ] 系統明細關聯(群組、會員) - - [ ] 模組明細關聯(群組、會員) - - [ ] 公司底下站台列表 - - [ ] 會員所屬群組列表 -- [ ] action 驗證改為只允許 `view/edit` -- [ ] scope 驗證改為只允許 `site` -- [ ] 補齊 API 錯誤碼一致性(400/404/409) +- [ ] 補 Authentik SMTP 通知流程(密碼設定/重設寄信) +- [ ] 補 `/admin/members` 關鍵操作審計日誌 +- [ ] 補更多 API 測試(members username/password reset 路徑) ## 進行中 -- [ ] 新 schema 與 API 契約對齊設計(以群組整合權限為中心) +- [ ] 文件與程式持續對齊(避免規格漂移) ## 已完成 -- [x] 後端已具備 systems/modules/companies/sites/members/permission-groups 基礎 CRUD 能力 -- [x] 本地開發環境(`.env.development`)可啟動並連線 DB -- [x] 管理 API 認證統一使用 `X-Client-Key` + `X-API-Key` -- [x] Authentik 會員同步流程已能在 upsert/update 路徑運作 +- [x] `/admin/*` 改為 Bearer + admin 群組管控(`ADMIN_REQUIRED_GROUPS`) +- [x] 管理 API 完成 systems/modules/companies/sites/members/permission-groups CRUD +- [x] 會員 upsert/update 可同步 Authentik +- [x] 會員資料新增 `username` 欄位,與 `display_name` 分離 +- [x] 新增 `POST /admin/members/{authentik_sub}/password/reset` +- [x] DB 新增 `users.username`(含 migration 腳本) diff --git a/docs/DB_SCHEMA.md b/docs/DB_SCHEMA.md index 1b156de..8bdd40f 100644 --- a/docs/DB_SCHEMA.md +++ b/docs/DB_SCHEMA.md @@ -1,91 +1,42 @@ -# DB Schema(新架構) +# DB Schema(現行) -## 1) 設計原則 -- 權限以群組為中心,不使用會員直接細粒度授權流程 -- `scope` 固定為 `site` -- `action` 只允許 `view`、`edit`(可同時存在) -- DB 真實執行來源:`backend/scripts/init_schema.sql` +## 真實來源 +- `backend/scripts/init_schema.sql` +- 線上增量:`backend/scripts/migrate_add_users_username.sql` -## 2) 核心實體 -- `companies` - - `id` (PK) - - `company_key` (UNIQUE) - - `name`, `status`, `created_at`, `updated_at` -- `sites` - - `id` (PK) - - `site_key` (UNIQUE) - - `company_id` (FK -> companies.id) - - `name`, `status`, `created_at`, `updated_at` +## 主要表 - `users` - - `id` (PK) - - `authentik_sub` (UNIQUE) - - `authentik_user_id`, `email` (UNIQUE), `display_name`, `is_active` + - `authentik_sub` UNIQUE + - `authentik_user_id` INTEGER + - `username` UNIQUE + - `email` UNIQUE + - `display_name` + - `is_active`, `status`, timestamps +- `companies` +- `sites`(`company_id -> companies.id`) - `systems` - - `id` (PK) - - `system_key` (UNIQUE) - - `name`, `status` -- `modules` - - `id` (PK) - - `module_key` (UNIQUE) - - `system_id` (FK -> systems.id) - - `name`, `status` +- `modules`(`system_key -> systems.system_key`) - `permission_groups` - - `id` (PK) - - `group_key` (UNIQUE) - - `name`, `status` +- `permission_group_members`(group + authentik_sub) +- `permission_group_permissions`(group + site/system/module/action) +- `user_scope_permissions`(相容保留) +- `api_clients`(保留給機器對機器用途) -## 3) 群組關聯(多對多) -- `permission_group_members` - - `group_id` (FK -> permission_groups.id) - - `user_id` (FK -> users.id) - - UNIQUE (`group_id`, `user_id`) -- `permission_group_sites` - - `group_id` (FK -> permission_groups.id) - - `site_id` (FK -> sites.id) - - UNIQUE (`group_id`, `site_id`) -- `permission_group_systems` - - `group_id` (FK -> permission_groups.id) - - `system_id` (FK -> systems.id) - - UNIQUE (`group_id`, `system_id`) -- `permission_group_modules` - - `group_id` (FK -> permission_groups.id) - - `module_id` (FK -> modules.id) - - UNIQUE (`group_id`, `module_id`) -- `permission_group_actions` - - `group_id` (FK -> permission_groups.id) - - `action` (`view` | `edit`) - - UNIQUE (`group_id`, `action`) +## 權限規則 +- `scope_type='site'` +- `action in ('view','edit')` -## 4) 查詢預期 -- 系統頁關聯: - - 查 `permission_group_systems` 取群組 - - 經 `permission_group_members` 推導涉及會員 -- 模組頁關聯: - - 查 `permission_group_modules` 取群組 - - 經 `permission_group_members` 推導涉及會員 -- 公司頁站台: - - 查 `sites` by `company_id` -- 會員頁群組: - - 查 `permission_group_members` by `user_id` +## 會員與 Authentik 對齊 +- `users.authentik_sub` 對應 Authentik `uid` +- `users.username` 對應 Authentik `username` +- `users.display_name` 對應 Authentik `name` -## 5) 驗收查核(SQL) +## 快速檢查 SQL ```sql --- 1) 檢查主表是否存在 -SELECT tablename -FROM pg_tables -WHERE schemaname = 'public' - AND tablename IN ( - 'companies','sites','users','systems','modules','permission_groups', - 'permission_group_members','permission_group_sites', - 'permission_group_systems','permission_group_modules','permission_group_actions' - ) -ORDER BY tablename; +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name='users' +ORDER BY ordinal_position; --- 2) 檢查 action 值域 -SELECT DISTINCT action FROM permission_group_actions ORDER BY action; - --- 3) 檢查群組可同時有 view/edit -SELECT group_id, array_agg(action ORDER BY action) AS actions -FROM permission_group_actions -GROUP BY group_id; +SELECT COUNT(*) FROM users WHERE username IS NULL; ``` diff --git a/docs/FRONTEND_HANDOFF.md b/docs/FRONTEND_HANDOFF.md index ad84fb9..c9fcbc4 100644 --- a/docs/FRONTEND_HANDOFF.md +++ b/docs/FRONTEND_HANDOFF.md @@ -1,33 +1,17 @@ -# Frontend Handoff(交給前端 AI) +# Frontend Handoff -## 目標 -把後台管理改為「群組中心權限模型」,並符合以下固定規則: -- `scope` 只用 `site` -- 顯示為 `公司/站台` -- `action` 只允許 `view/edit`,且可多選 +## 目前後端契約重點 +- 後台登入:只吃 Bearer + admin 群組檢查 +- 會員模型:`authentik_sub`, `username`, `email`, `display_name`, `is_active` +- 會員密碼:支援重設 API(回傳臨時密碼) -## 交辦項目 -1. 群組管理頁 -- 群組基本資料 CRUD -- 同頁整合:站台、系統、模組、會員、action -- action 使用多選(`view`、`edit`) +## 會員頁必做 +1. 新增會員表單欄位:`username`、`email`、`display_name` +2. 編輯會員表單欄位:`username`、`email`、`display_name`、`is_active` +3. 表格欄位要顯示:`authentik_sub`、`username`、`email`、`display_name` +4. 操作欄新增「重設密碼」按鈕,串 `POST /admin/members/{authentik_sub}/password/reset` +5. 重設成功後顯示臨時密碼,並提醒管理員安全轉交 -2. 系統編輯頁 -- 顯示該系統被哪些群組使用 -- 顯示該系統涉及哪些會員(由群組關聯推導) - -3. 模組編輯頁 -- 顯示該模組被哪些群組使用 -- 顯示該模組涉及哪些會員(由群組關聯推導) - -4. 公司頁 -- 顯示公司底下站台列表 - -5. 會員編輯頁 -- 顯示/編輯所屬群組 -- 會員直接授權先不做 - -## 串接約束 -- 只串接新版群組中心 API -- 不新增最終規格表頁面 -- UI/UX 可另外優化,但資料與流程規則不可改 +## 其他頁面 +- 仍維持群組中心模型:site/system/module/member + action(view/edit) +- 系統/模組/公司/會員關聯頁面沿用目前 API diff --git a/docs/FRONTEND_TASKPLAN.md b/docs/FRONTEND_TASKPLAN.md index 334ceec..371b9d0 100644 --- a/docs/FRONTEND_TASKPLAN.md +++ b/docs/FRONTEND_TASKPLAN.md @@ -1,20 +1,16 @@ # Frontend TaskPlan ## 待辦 -- [ ] 重構群組管理頁為單一中心(整合權限設定,不再拆分心智) -- [ ] 新增系統編輯頁關聯區塊:所屬群組、涉及會員 -- [ ] 新增模組編輯頁關聯區塊:所屬群組、涉及會員 -- [ ] 公司頁新增站台列表區塊 -- [ ] 會員編輯頁強化群組列表與編輯體驗 -- [ ] 所有 action UI 改為多選但僅 `view/edit` -- [ ] 所有 scope UI 固定為 `site`(顯示 `公司/站台`) -- [ ] 隱藏/移除會員細粒度直接授權主流程入口 +- [ ] SMTP 通知開通後,補上「發送重設通知」UX 文案 +- [ ] 會員頁重設密碼流程加上二次確認 Dialog +- [ ] 針對大量資料頁面補分頁/搜尋體驗優化 ## 進行中 -- [ ] 與新版後端 API 契約對齊(群組中心) +- [ ] 與最新後端契約持續對齊(members username/password reset) ## 已完成 -- [x] 前端框架採用 Vue3 + JS + Vite + Element Plus + Tailwind -- [x] 已有 admin 基礎頁面:systems/modules/companies/sites/members/permission-groups -- [x] 已有會員建立/編輯與群組指派基本能力 -- [x] 已有 OIDC 登入與 `/me`、`/me/permissions/snapshot` 基礎流程 +- [x] Vue3 + JS + Vite + Element Plus + Tailwind 基礎架構 +- [x] admin 基礎頁面:systems/modules/companies/sites/members/permission-groups +- [x] 會員頁新增 `username` 欄位(新增/編輯/列表) +- [x] 會員頁新增「重設密碼」操作按鈕 +- [x] OIDC 登入 + `/me`、`/me/permissions/snapshot` 流程 diff --git a/docs/LOCAL_DEV_RUNBOOK.md b/docs/LOCAL_DEV_RUNBOOK.md index c72c2b5..26acc91 100644 --- a/docs/LOCAL_DEV_RUNBOOK.md +++ b/docs/LOCAL_DEV_RUNBOOK.md @@ -3,7 +3,7 @@ ## 1) 啟動後端 ```bash cd backend -.venv/bin/uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 +./scripts/start_dev.sh ``` ## 2) 啟動前端 @@ -13,19 +13,18 @@ npm install npm run dev ``` -## 3) 基本檢查 -- Backend health: `GET http://127.0.0.1:8000/healthz` -- Frontend: `http://localhost:5173` 或 `http://127.0.0.1:5173` -- 檢查 admin API 是否有自動帶 `X-Client-Key`、`X-API-Key` +## 3) 重要環境變數 +- `backend/.env.development` + - `ADMIN_REQUIRED_GROUPS=member-admin` + - `AUTHENTIK_*` 需可連到 Authentik -## 4) 驗收順序(本地) -1. 建立公司、站台 -2. 建立系統、模組 -3. 建立會員 -4. 建立群組 -5. 在群組配置:站台/系統/模組/action(view/edit)/會員 -6. 到系統/模組/公司/會員頁確認關聯列表是否正確 +## 4) 基本檢查 +- `GET http://127.0.0.1:8000/healthz` +- 登入後打 `GET /admin/members` 應可回資料 +- 非 admin 群組帳號打 `/admin/*` 應回 `403` -## 5) 注意事項 -- 本輪不產最終規格表 -- DB 真實來源僅 `backend/scripts/init_schema.sql` +## 5) 會員流程驗收 +1. 新增會員(username/email/display_name,開啟 sync_to_authentik) +2. 確認列表可看到新會員與 `authentik_sub` +3. 點「重設密碼」,取得臨時密碼 +4. 到 Authentik 驗證該會員可用新密碼登入 diff --git a/docs/index.md b/docs/index.md index 08f2a88..d0794c0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # member.ose.tw 文件入口 -## 閱讀順序(先看) +## 閱讀順序 1. `docs/ARCHITECTURE.md` 2. `docs/DB_SCHEMA.md` 3. `docs/BACKEND_TASKPLAN.md` @@ -8,16 +8,14 @@ 5. `docs/FRONTEND_HANDOFF.md` 6. `docs/LOCAL_DEV_RUNBOOK.md` -## 交辦順序(執行) -1. 後端先完成新 schema 與 API 契約 -2. 前端依 handoff 完成頁面與串接 -3. 本地驗收跑 Runbook - ## 目前狀態 -- 架構方向:已定版(群組中心、site scope、action view/edit 多選) -- 文件重整:已完成(舊文件已清除) -- 程式重構:待後續依 TaskPlan 實作 +- 架構:公司/站台/會員 + 系統/模組 + 群組整合權限(已定版) +- 後台安全:Auth token + admin 群組檢查(`ADMIN_REQUIRED_GROUPS`) +- 會員流程:member 新增/更新可同步 Authentik,並支援重設密碼 + +## 單一真實來源 +- DB SQL:`backend/scripts/init_schema.sql` +- DB 線上補丁:`backend/scripts/migrate_add_users_username.sql` ## 備註 -- 本輪不產最終規格表/最終規範矩陣 -- DB 文檔以 `docs/DB_SCHEMA.md` 為說明入口,實際 SQL 以 `backend/scripts/init_schema.sql` 為準 +- 本輪先維持可開發/可交辦文件,不產最終規格總表。 diff --git a/frontend/src/api/members.js b/frontend/src/api/members.js index 917e3b4..5105673 100644 --- a/frontend/src/api/members.js +++ b/frontend/src/api/members.js @@ -3,6 +3,7 @@ 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 resetMemberPassword = (authentikSub) => adminHttp.post(`/admin/members/${authentikSub}/password/reset`) 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/pages/admin/MembersPage.vue b/frontend/src/pages/admin/MembersPage.vue index 7108b69..a7d20da 100644 --- a/frontend/src/pages/admin/MembersPage.vue +++ b/frontend/src/pages/admin/MembersPage.vue @@ -14,20 +14,23 @@ + - + + @@ -47,6 +50,7 @@ + @@ -73,6 +77,7 @@ import { getMembers, upsertMember, updateMember, + resetMemberPassword, getMemberPermissionGroups, setMemberPermissionGroups } from '@/api/members' @@ -88,6 +93,7 @@ const showCreateDialog = ref(false) const createFormRef = ref() const creating = ref(false) const createForm = ref({ + username: '', email: '', display_name: '', group_keys: [], @@ -95,6 +101,7 @@ const createForm = ref({ sync_to_authentik: true }) const createRules = { + username: [{ required: true, message: '請輸入 Username', trigger: 'blur' }], email: [{ required: true, message: '請輸入 Email', trigger: 'blur' }] } @@ -102,6 +109,7 @@ const showEditDialog = ref(false) const saving = ref(false) const editForm = ref({ authentik_sub: '', + username: '', email: '', display_name: '', group_keys: [], @@ -126,6 +134,7 @@ async function load() { function resetCreateForm() { createForm.value = { + username: '', email: '', display_name: '', group_keys: [], @@ -137,6 +146,7 @@ function resetCreateForm() { async function openEdit(row) { editForm.value = { authentik_sub: row.authentik_sub, + username: row.username || '', email: row.email || '', display_name: row.display_name || '', group_keys: [], @@ -155,6 +165,7 @@ async function openEdit(row) { function resetEditForm() { editForm.value = { authentik_sub: '', + username: '', email: '', display_name: '', group_keys: [], @@ -189,6 +200,7 @@ async function handleEdit() { saving.value = true try { await updateMember(editForm.value.authentik_sub, { + username: editForm.value.username || null, email: editForm.value.email || null, display_name: editForm.value.display_name || null, is_active: editForm.value.is_active, @@ -206,5 +218,21 @@ async function handleEdit() { } } +async function handleResetPassword(row) { + try { + const res = await resetMemberPassword(row.authentik_sub) + const pwd = res.data?.temporary_password || '' + if (!pwd) { + ElMessage.success('密碼已重設') + return + } + await navigator.clipboard.writeText(pwd) + ElMessage.success(`已重設密碼,臨時密碼已複製:${pwd}`) + } catch (err) { + const detail = err.response?.data?.detail + ElMessage.error(detail || '重設密碼失敗') + } +} + onMounted(load)