Compare commits

..

105 Commits

Author SHA1 Message Date
Chris
4fd81ffbf2 chore(directus): rename *_key to key and skip system key autogen 2026-04-04 17:24:54 +08:00
Chris
cf39ea2b0c chore(directus): switch ids to uuid and add key fields only 2026-04-04 16:59:38 +08:00
Chris
560f40ae8a chore(directus): convert ids to uuid and ensure *_key fields 2026-04-04 16:55:09 +08:00
Chris
838c0afc0b chore(directus): add users.user_key to base directus schema 2026-04-04 16:51:23 +08:00
Chris
0666b8683e chore(directus): add import schemas and key auto-generation sql 2026-04-04 16:48:04 +08:00
Chris
428b6292ea chore: bump frontend submodule 2026-04-03 16:02:24 +08:00
Chris
cd7feec38a docs: update env workflow and role_code contracts 2026-04-03 15:50:19 +08:00
Chris
01a4580faf chore(scripts): add helper scripts for submodule push flow 2026-04-03 15:04:32 +08:00
Chris
649af715e2 docs: add workspace guardrails and submodule workflow 2026-04-03 14:51:14 +08:00
Chris
3571cdf2ee docs(env): standardize development and production env workflow 2026-04-03 14:43:40 +08:00
Chris
099ed03be7 chore: bump backend submodule 2026-04-03 06:03:17 +08:00
Chris
f62ed97e2b chore: bump backend submodule for env example 2026-04-03 05:57:13 +08:00
Chris
760902f53c chore: bump frontend submodule to latest 2026-04-03 05:40:12 +08:00
Chris
998771bc11 chore: bump frontend submodule for oidc url guard 2026-04-03 05:27:25 +08:00
Chris
576ba9b2fe chore: bump frontend submodule for dev env 2026-04-03 05:10:26 +08:00
Chris
b7b312e69a fix: remove invalid condition from compose example 2026-04-03 05:04:00 +08:00
Chris
865be73d06 update 2026-04-03 05:02:41 +08:00
Chris
ed4b22a564 chore: bump backend submodule after rebase 2026-04-03 04:59:40 +08:00
Chris
2da43cf027 update 2026-04-03 04:57:16 +08:00
Chris
200c86c924 chore: bump backend submodule for env selection 2026-04-03 04:51:12 +08:00
Chris
e8058d1240 Use single backend env template in deploy docs 2026-04-03 04:33:47 +08:00
Chris
6dabc2eab6 Document single backend .env workflow 2026-04-03 04:31:59 +08:00
Chris
8609d61f82 Use example compose and ignore local deploy env files 2026-04-03 04:26:05 +08:00
Chris
f01a228026 Add docker-compose template for VPS deployment 2026-04-03 04:18:46 +08:00
Chris
a6e5fbbb24 Bump backend submodule to latest ignore rules 2026-04-03 04:01:15 +08:00
Chris
21dc3ea56f Update backend submodule after gitignore hardening 2026-04-03 03:58:07 +08:00
Chris
fdf17ecf85 Update docs and submodule after backend cleanup 2026-04-03 03:55:04 +08:00
Chris
a45aa5a6c7 Add VPS deployment runbook 2026-04-03 03:40:10 +08:00
Chris
c394e9153e Rename integration workspace to member-platform 2026-04-03 03:32:22 +08:00
Chris
0e248db1bf Update submodule URLs to Gitea remotes 2026-04-03 03:28:03 +08:00
Chris
1d2a57fada Split frontend and backend into separate submodule repos 2026-04-03 03:19:48 +08:00
Chris
528b988207 Switch backend Docker image to Alpine multi-stage 2026-04-03 03:12:19 +08:00
Chris
40d9fb8dcf Add production Dockerfile for backend deployment 2026-04-03 03:11:28 +08:00
Chris
fc81696abf Switch access control from groups to realm roles 2026-04-03 03:03:43 +08:00
Chris
daa21e81a9 Remove duplicate internal permissions snapshot API 2026-04-03 02:46:55 +08:00
Chris
955019e8d7 Add Redis-backed cache backend with env switch 2026-04-03 02:38:54 +08:00
Chris
ed413ce39d Add in-memory read cache with CUD-based invalidation 2026-04-03 02:32:38 +08:00
Chris
fa624127c8 Speed up auth verification with JWKS/admin token caching 2026-04-03 02:20:54 +08:00
Chris
418a7b7099 Sync site-role assignments to Keycloak group role mappings 2026-04-03 02:14:01 +08:00
Chris
223edd49b5 fix: respect admin site list limit when loading role site options 2026-04-03 01:58:08 +08:00
Chris
d59407d04c feat: allow assigning sites directly from role page 2026-04-03 01:56:22 +08:00
Chris
2004203758 chore: silence introspection fallback warning noise 2026-04-03 01:53:35 +08:00
Chris
1ff0589b29 refactor: simplify schema names and remove provider id columns 2026-04-03 01:49:36 +08:00
Chris
6e43a3b2c8 feat: add global manual sync button in admin header 2026-04-03 01:25:34 +08:00
Chris
21167659f8 perf: disable read-time sync and keep provider sync manual 2026-04-03 01:23:42 +08:00
Chris
f351fe6454 fix: sync role CRUD with provider client roles 2026-04-03 01:17:13 +08:00
Chris
6adca8c229 fix: add provider column migration script for existing db 2026-04-03 01:10:13 +08:00
Chris
ef27162903 refactor: rename idp fields to provider naming 2026-04-03 01:05:01 +08:00
Chris
467f2b4867 refactor(idp-groups): use display name as keycloak group name 2026-04-03 00:53:31 +08:00
Chris
7660c662a5 feat(sync): keycloak as source-of-truth with auto catalog sync and token refresh 2026-04-03 00:46:46 +08:00
Chris
7986160d9e fix(auth): resolve admin groups via keycloak admin API when token lacks groups 2026-04-03 00:28:32 +08:00
Chris
6ae907d649 fix(auth): accept keycloak group path variants for admin guard 2026-04-03 00:24:32 +08:00
Chris
2ce9630a5e fix(backend): postpone annotations to avoid list() type shadowing crash 2026-04-03 00:22:00 +08:00
Chris
5837582c0f feat(frontend): migrate admin UI to role-site model and clean legacy pages 2026-04-03 00:18:39 +08:00
Chris
1e1d913103 refactor: rebuild backend around role-site authorization model 2026-04-02 23:58:13 +08:00
Chris
e2dd3ce106 docs: add clickable links for file paths in markdown 2026-04-02 23:38:17 +08:00
Chris
16bbfdba24 docs: rebuild architecture and taskplans for role-site model 2026-04-02 23:35:05 +08:00
Chris
7cdf2b5a51 refactor(keycloak): remove authentik naming and switch to keycloak-only paths 2026-04-01 02:01:41 +08:00
Chris
a9c7cb5f39 fix(auth): relax keycloak audience check and auto-redirect logged-in user 2026-04-01 01:48:06 +08:00
Chris
f0fd5d6e68 fix(auth-callback): redirect to login after successful oidc callback 2026-04-01 01:46:33 +08:00
Chris
b0de6ad94a fix(oidc): add PKCE support for keycloak login flow 2026-04-01 01:43:53 +08:00
Chris
a1eb7ef41b feat(login): simplify to single keycloak redirect button 2026-04-01 01:35:46 +08:00
Chris
07195e7efc fix(login): unify auth entry to single keycloak login page 2026-04-01 01:33:27 +08:00
Chris
dc2811ec61 chore(env): use member-frontend oidc client and keep member-backend admin client 2026-04-01 01:30:44 +08:00
Chris
0b61975c81 chore(env): configure keycloak master client for local backend 2026-04-01 01:20:46 +08:00
Chris
34ba57034d feat(idp): add keycloak-first support with authentik fallback 2026-04-01 00:41:38 +08:00
Chris
febfafc55c fix(login): switch frontend account login to oidc flow 2026-03-31 23:43:57 +08:00
Chris
80a571d227 feat(login): support both password and Google SSO entry on login page 2026-03-31 23:18:28 +08:00
Chris
fe6453f6f8 refactor(identity): rename authentik_sub to user_sub and authentik_user_id to idp_user_id 2026-03-31 22:32:48 +08:00
Chris
316d17027b docs(api): add internal API contract and expose response schemas in swagger 2026-03-31 22:20:24 +08:00
Chris
15da8a5341 fix(internal): return correct system_key in modules list 2026-03-31 22:02:56 +08:00
Chris
671e27447b refactor(internal): switch auth to api-client headers 2026-03-31 21:09:18 +08:00
Chris
322db6ee1a fix(member): delete authentik user when removing member 2026-03-31 21:01:15 +08:00
Chris
f6f86d4bfb feat(admin): add delete APIs and UI actions for all admin resources 2026-03-31 20:58:20 +08:00
Chris
c4492a3072 fix(api-clients): fallback api-key hashing without argon2; show site/module parent display names 2026-03-31 20:35:04 +08:00
Chris
1d9bdb7daa feat(admin): add api client management UI and backend CRUD/rotate endpoints 2026-03-30 23:28:27 +08:00
Chris
ccb99683b8 feat(members): split username/display_name, sync updates to authentik, add password reset API and refresh docs 2026-03-30 22:15:41 +08:00
Chris
e1a6bbd844 refactor(auth): use group-only admin access and remove admin api-key flow from frontend/admin routes 2026-03-30 21:39:43 +08:00
Chris
15eee2fc9a feat(security): enforce admin allowlist guard on admin APIs and attach bearer for admin client 2026-03-30 21:25:57 +08:00
Chris
fb515c6c44 fix(module-key): make module keys standalone MD format with system_key relation 2026-03-30 20:02:17 +08:00
Chris
b4c02835bd feat(keys): auto-generate entity keys and remove manual key input from admin create forms 2026-03-30 19:52:00 +08:00
Chris
62776ac27e chore(db): rebuild init schema with drop-recreate and group-centric constraints 2026-03-30 19:42:05 +08:00
Chris
ea5285501a feat(admin): implement group-centric relations and system/module/company linkage views 2026-03-30 19:38:49 +08:00
Chris
37a69081e3 docs: rebuild documentation set for new architecture and add DB schema guide 2026-03-30 19:21:54 +08:00
Chris
f884f1043d feat(flow): unify member-group-permission admin workflow and docs 2026-03-30 03:54:22 +08:00
Chris
31fff92e19 feat(flow): auto-resolve authentik sub and improve admin dropdown UX 2026-03-30 03:33:50 +08:00
Chris
f85d3de5c5 feat(admin): add edit flows for all catalogs and member authentik sync 2026-03-30 03:25:53 +08:00
Chris
2dd70dceff fix(auth): correct userinfo endpoint fallback for authentik profile enrichment 2026-03-30 03:13:29 +08:00
Chris
94441a4037 refactor(frontend): remove manual admin credential cards from permission pages 2026-03-30 03:06:31 +08:00
Chris
76fd22826b fix(frontend): auto-attach admin api keys and normalize admin list payloads 2026-03-30 03:03:17 +08:00
Chris
f33134ff53 refactor: Redesign navbar to single-row tab layout
- 單列 header(高度 56px),sticky top
- 左:logo 區(固定寬度,方便之後換圖)
- 中:tab 列,active 用藍色底線 + 淡藍底色
- 分隔用細豎線 | 區隔用戶與管理員 tab
- 右:輕量文字登出按鈕,不搶焦點
- NavTab 用行內 defineComponent 封裝,乾淨不額外建檔

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 02:58:38 +08:00
Chris
c85109e09b refactor: Replace admin dropdown with flat tab navigation
導覽列重新設計:
- 上方 bar:品牌名 + 登出按鈕
- 下方 tab 列:我的資料、我的權限 | 權限管理、系統、模組、公司、站台、會員、群組
- 用戶 tab(藍色底線)與管理員 tab(靛色底線)視覺分組
- 支持 overflow-x scroll,小螢幕也可橫滑
- 移除 el-dropdown 依賴,改用純 router-link + button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 02:56:19 +08:00
Chris
4bb6ecf887 refactor: Fix UI/UX issues across admin pages
- App.vue: max-w-4xl → max-w-6xl(讓表格不被截斷)
- 新增 AdminCredsCard.vue 共用元件,消除兩個頁面的重複認證卡片
- PermissionAdminPage / PermissionGroupsPage 改用 AdminCredsCard
- 所有 el-table 的 slot="empty" 換成 <template #empty>(Vue 3 正確用法)
- 4 個管理頁 Dialog 補 el-form rules + formRef.validate()(取代手動 if 檢查)
- MembersPage: authentik_sub / email 欄位加 show-overflow-tooltip
- PermissionGroupsPage: 成功/失敗訊息由 <p> 改為 el-alert(統一樣式)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 02:53:54 +08:00
Chris
c3f6293c83 fix(frontend): validate oidc state in callback flow 2026-03-30 02:47:16 +08:00
Chris
70b5f34a74 fix(frontend): harden auth routing and callback error handling 2026-03-30 02:46:35 +08:00
Chris
23baceed71 docs: Update TASKPLAN_FRONTEND and FRONTEND_HANDOFF_SCHEMA_V2 - mark Schema v2 as complete 2026-03-30 02:39:58 +08:00
Chris
c4b9789df7 Upgrade frontend to Schema V2: Admin management pages
新增功能:
- OIDC 登入流程完整實現(LoginPage → AuthCallbackPage)
- 6 個管理頁面:系統、模組、公司、站台、會員、權限群組
- 權限群組管理:群組 CRUD + 綁定會員 + 群組授權/撤銷
- 新 API 層:systems、modules、companies、sites、members、permission-groups
- admin store:統一管理公共清單資料

調整既有頁面:
- PermissionSnapshotPage:表格新增 system 欄位
- PermissionAdminPage:
  - 新增 system 必填欄位
  - scope_type 改為 company/site 下拉選單
  - module 改為選填(空值代表系統層權限)
- Router:補 6 條新管理路由
- App.vue:導覽列新增管理員群組下拉菜單

驗收條件達成:
✓ 可新增 system/module/company/site
✓ 可做用戶直接 grant/revoke(新 payload)
✓ 可建立 permission-group、加會員、群組 grant/revoke
✓ /me/permissions/snapshot 表格可顯示 system + module + action

Build:成功(0 errors)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-30 02:37:46 +08:00
Chris
d79ed7c6fc fix: finalize unified schema and correct permission snapshot mapping 2026-03-30 02:22:27 +08:00
Chris
42f9124f77 chore: consolidate full database schema into single init_schema.sql 2026-03-30 02:14:26 +08:00
Chris
f9ad9417ba refactor: align backend with company-site-member schema and system-level RBAC groups 2026-03-30 01:59:50 +08:00
Chris
f5848a360f feat: add organization and member management APIs for admin and internal use 2026-03-30 01:23:02 +08:00
Chris
c6cb9d6818 fix: enrich me profile via userinfo and add org-member management plan 2026-03-30 01:14:02 +08:00
Chris
1ec132184f fix: use stable callback redirect_uri for oidc login 2026-03-30 01:08:08 +08:00
Chris
42f04ef961 fix: switch frontend login to authentik auth-code flow 2026-03-30 01:04:28 +08:00
Chris
096136e9d5 fix: allow login by email via authentik username resolution 2026-03-30 00:54:15 +08:00
98 changed files with 11935 additions and 5840 deletions

4
.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist/
*.log
npm-debug.log*
yarn-error.log*
# Local deployment files
docker-compose.yml
docker-compose.override.yml

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "backend"]
path = backend
url = http://127.0.0.1:8888/member/member-backend.git
[submodule "frontend"]
path = frontend
url = http://127.0.0.1:8888/member/member-frontend.git

1
backend Submodule

Submodule backend added at 405000ded5

View File

@@ -1,19 +0,0 @@
# memberapi.ose.tw backend env (development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
AUTHENTIK_BASE_URL=
AUTHENTIK_ADMIN_TOKEN=
AUTHENTIK_VERIFY_TLS=false
AUTHENTIK_ISSUER=
AUTHENTIK_JWKS_URL=
AUTHENTIK_AUDIENCE=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,22 +0,0 @@
# memberapi.ose.tw backend env (local development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member.ose.tw
DB_USER=member_ose
DB_PASSWORD=Dmrax5bKDf
AUTHENTIK_BASE_URL=https://auth.ose.tw
AUTHENTIK_ADMIN_TOKEN=L7RspewJSjm3i7Y3eovYb49vr8jvEJ6oZzCm3X79spGNapbo3RqWilBrTDz3
AUTHENTIK_VERIFY_TLS=true
AUTHENTIK_ISSUER=https://auth.ose.tw/application/o/member-ose-frontend/
AUTHENTIK_JWKS_URL=https://auth.ose.tw/application/o/member-ose-frontend/jwks/
AUTHENTIK_AUDIENCE=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
AUTHENTIK_CLIENT_ID=gKtjk5ExsITK74I1WG9RkHbylBjoZO83xab7YHiN
AUTHENTIK_CLIENT_SECRET=MHTv0SHkIuic9Quk8Br9jB9gzT2bERvRfhHU4ogPlUtY3eBEXJj80RTEp3zpFBUXQ8PAwYrihWfNqKawWUOmKpQd8SwuyiAuVwLJTS7vB3LGvx1XtXqgMhR76EL2mLnP
AUTHENTIK_TOKEN_ENDPOINT=https://auth.ose.tw/application/o/token/
PUBLIC_FRONTEND_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,22 +0,0 @@
# memberapi.ose.tw backend env (development)
APP_ENV=development
PORT=8000
DB_HOST=127.0.0.1
DB_PORT=54321
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
AUTHENTIK_BASE_URL=
AUTHENTIK_ADMIN_TOKEN=
AUTHENTIK_VERIFY_TLS=false
AUTHENTIK_ISSUER=
AUTHENTIK_JWKS_URL=
AUTHENTIK_AUDIENCE=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,22 +0,0 @@
# memberapi.ose.tw backend env (production)
APP_ENV=production
PORT=8000
DB_HOST=postgresql
DB_PORT=5432
DB_NAME=member_center
DB_USER=member_ose
DB_PASSWORD=CHANGE_ME
AUTHENTIK_BASE_URL=
AUTHENTIK_ADMIN_TOKEN=
AUTHENTIK_VERIFY_TLS=false
AUTHENTIK_ISSUER=
AUTHENTIK_JWKS_URL=
AUTHENTIK_AUDIENCE=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
AUTHENTIK_TOKEN_ENDPOINT=
PUBLIC_FRONTEND_ORIGINS=https://member.ose.tw,https://mkt.ose.tw,https://admin.ose.tw
INTERNAL_SHARED_SECRET=CHANGE_ME

View File

@@ -1,52 +0,0 @@
# memberapi.ose.tw backend
## Quick start
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e .
cp .env.example .env
./scripts/start_dev.sh
```
## Required DB setup
1. Initialize API client whitelist table with `docs/API_CLIENTS_SQL.sql`.
2. Initialize core tables with `backend/scripts/init_schema.sql`.
3. Generate `api_key_hash` and update `api_clients` records, e.g.:
```bash
python scripts/generate_api_key_hash.py 'YOUR_PLAIN_KEY'
```
## Authentik JWT setup
- Configure at least one of:
- `AUTHENTIK_JWKS_URL`
- `AUTHENTIK_ISSUER` (the service infers `<issuer>/jwks/`)
- Optional:
- `AUTHENTIK_AUDIENCE` (enables audience claim validation)
- `AUTHENTIK_CLIENT_ID` (used by `/auth/login`, fallback to `AUTHENTIK_AUDIENCE`)
- `AUTHENTIK_CLIENT_SECRET` (required if your access/id token uses HS256 signing)
- `AUTHENTIK_TOKEN_ENDPOINT` (default: `<AUTHENTIK_BASE_URL>/application/o/token/`)
## Authentik Admin API setup
- Required for `/internal/authentik/users/ensure`:
- `AUTHENTIK_BASE_URL`
- `AUTHENTIK_ADMIN_TOKEN`
- `AUTHENTIK_VERIFY_TLS`
## Main APIs
- `GET /healthz`
- `POST /auth/login`
- `GET /me` (Bearer token required)
- `GET /me/permissions/snapshot` (Bearer token required)
- `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{authentik_sub}/snapshot`
- `POST /internal/authentik/users/ensure`
- `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke`

View File

@@ -1 +0,0 @@
"""memberapi backend package."""

View File

@@ -1 +0,0 @@
"""API routers."""

View File

@@ -1,60 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.permissions import PermissionGrantRequest, PermissionRevokeRequest
from app.security.api_client_auth import require_api_client
router = APIRouter(prefix="/admin", tags=["admin"])
@router.post("/permissions/grant")
def grant_permission(
payload: PermissionGrantRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, str]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=payload.authentik_sub,
email=payload.email,
display_name=payload.display_name,
is_active=True,
)
permission = perms_repo.create_if_not_exists(
user_id=user.id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
module=payload.module,
action=payload.action,
)
return {"permission_id": permission.id, "result": "granted"}
@router.post("/permissions/revoke")
def revoke_permission(
payload: PermissionRevokeRequest,
_: ApiClient = Depends(require_api_client),
db: Session = Depends(get_db),
) -> dict[str, int | str]:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.get_by_sub(payload.authentik_sub)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user_not_found")
deleted = perms_repo.revoke(
user_id=user.id,
scope_type=payload.scope_type,
scope_id=payload.scope_id,
module=payload.module,
action=payload.action,
)
return {"deleted": deleted, "result": "revoked"}

View File

@@ -1,57 +0,0 @@
from urllib.parse import urljoin
import httpx
from fastapi import APIRouter, HTTPException, status
from app.core.config import get_settings
from app.schemas.login import LoginRequest, LoginResponse
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest) -> LoginResponse:
settings = get_settings()
client_id = settings.authentik_client_id or settings.authentik_audience
if not settings.authentik_base_url or not client_id or not settings.authentik_client_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="authentik_login_not_configured")
token_endpoint = settings.authentik_token_endpoint or urljoin(
settings.authentik_base_url.rstrip("/") + "/", "application/o/token/"
)
form = {
"grant_type": "password",
"client_id": client_id,
"client_secret": settings.authentik_client_secret,
"username": payload.username,
"password": payload.password,
"scope": "openid profile email",
}
try:
resp = httpx.post(
token_endpoint,
data=form,
timeout=10,
verify=settings.authentik_verify_tls,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_unreachable") from exc
if resp.status_code >= 400:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_username_or_password")
data = resp.json()
token = data.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="authentik_missing_access_token")
return LoginResponse(
access_token=token,
token_type=data.get("token_type", "Bearer"),
expires_in=data.get("expires_in"),
scope=data.get("scope"),
)

View File

@@ -1,89 +0,0 @@
from fastapi import APIRouter, Depends, Header, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.authentik_admin import AuthentikEnsureUserRequest, AuthentikEnsureUserResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.schemas.users import UserUpsertBySubRequest
from app.services.authentik_admin_service import AuthentikAdminService
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/internal", tags=["internal"])
def verify_internal_secret(x_internal_secret: str = Header(alias="X-Internal-Secret")) -> None:
settings = get_settings()
if not settings.internal_shared_secret:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="internal_secret_not_configured")
if x_internal_secret != settings.internal_shared_secret:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_internal_secret")
@router.post("/users/upsert-by-sub")
def upsert_user_by_sub(
payload: UserUpsertBySubRequest,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> dict[str, str | bool | None]:
repo = UsersRepository(db)
user = repo.upsert_by_sub(
authentik_sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
)
return {
"id": user.id,
"sub": user.authentik_sub,
"authentik_user_id": user.authentik_user_id,
"email": user.email,
"display_name": user.display_name,
"is_active": user.is_active,
}
@router.get("/permissions/{authentik_sub}/snapshot", response_model=PermissionSnapshotResponse)
def get_permission_snapshot(
authentik_sub: str,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.get_by_sub(authentik_sub)
if user is None:
return PermissionSnapshotResponse(authentik_sub=authentik_sub, permissions=[])
permissions = perms_repo.list_by_user_id(user.id)
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
return PermissionService.build_snapshot(authentik_sub=authentik_sub, permissions=tuples)
@router.post("/authentik/users/ensure", response_model=AuthentikEnsureUserResponse)
def ensure_authentik_user(
payload: AuthentikEnsureUserRequest,
_: None = Depends(verify_internal_secret),
db: Session = Depends(get_db),
) -> AuthentikEnsureUserResponse:
settings = get_settings()
authentik_service = AuthentikAdminService(settings=settings)
sync_result = authentik_service.ensure_user(
sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
)
users_repo = UsersRepository(db)
users_repo.upsert_by_sub(
authentik_sub=payload.sub,
email=payload.email,
display_name=payload.display_name,
is_active=payload.is_active,
authentik_user_id=sync_result.user_id,
)
return AuthentikEnsureUserResponse(authentik_user_id=sync_result.user_id, action=sync_result.action)

View File

@@ -1,58 +0,0 @@
from fastapi import APIRouter, Depends
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.repositories.permissions_repo import PermissionsRepository
from app.repositories.users_repo import UsersRepository
from app.schemas.auth import AuthentikPrincipal, MeSummaryResponse
from app.schemas.permissions import PermissionSnapshotResponse
from app.security.authentik_jwt import require_authenticated_principal
from app.services.permission_service import PermissionService
router = APIRouter(prefix="/me", tags=["me"])
@router.get("", response_model=MeSummaryResponse)
def get_me(
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> MeSummaryResponse:
try:
users_repo = UsersRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
)
return MeSummaryResponse(sub=user.authentik_sub, email=user.email, display_name=user.display_name)
except SQLAlchemyError:
# DB schema compatibility fallback for local bring-up.
return MeSummaryResponse(
sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
)
@router.get("/permissions/snapshot", response_model=PermissionSnapshotResponse)
def get_my_permission_snapshot(
principal: AuthentikPrincipal = Depends(require_authenticated_principal),
db: Session = Depends(get_db),
) -> PermissionSnapshotResponse:
try:
users_repo = UsersRepository(db)
perms_repo = PermissionsRepository(db)
user = users_repo.upsert_by_sub(
authentik_sub=principal.sub,
email=principal.email,
display_name=principal.name or principal.preferred_username,
is_active=True,
)
permissions = perms_repo.list_by_user_id(user.id)
tuples = [(p.scope_type, p.scope_id, p.module, p.action) for p in permissions]
return PermissionService.build_snapshot(authentik_sub=principal.sub, permissions=tuples)
except SQLAlchemyError:
return PermissionSnapshotResponse(authentik_sub=principal.sub, permissions=[])

View File

@@ -1 +0,0 @@
"""Core settings and constants."""

View File

@@ -1,52 +0,0 @@
from functools import lru_cache
from typing import Annotated
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_env: str = "development"
port: int = 8000
db_host: str = "127.0.0.1"
db_port: int = 54321
db_name: str = "member_center"
db_user: str = "member_ose"
db_password: str = ""
authentik_base_url: str = ""
authentik_admin_token: str = ""
authentik_verify_tls: bool = False
authentik_issuer: str = ""
authentik_jwks_url: str = ""
authentik_audience: str = ""
authentik_client_id: str = ""
authentik_client_secret: str = ""
authentik_token_endpoint: str = ""
public_frontend_origins: Annotated[list[str], NoDecode] = ["https://member.ose.tw"]
internal_shared_secret: str = ""
@field_validator("public_frontend_origins", mode="before")
@classmethod
def parse_origins(cls, value: str | list[str]) -> list[str]:
if isinstance(value, list):
return value
if not value:
return []
return [origin.strip() for origin in value.split(",") if origin.strip()]
@property
def database_url(self) -> str:
return (
"postgresql+psycopg://"
f"{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"
)
@lru_cache
def get_settings() -> Settings:
return Settings()

View File

@@ -1 +0,0 @@
"""Database wiring."""

View File

@@ -1,5 +0,0 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@@ -1,18 +0,0 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -1,30 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.admin import router as admin_router
from app.api.auth import router as auth_router
from app.api.internal import router as internal_router
from app.api.me import router as me_router
from app.core.config import get_settings
app = FastAPI(title="memberapi.ose.tw", version="0.1.0")
settings = get_settings()
app.add_middleware(
CORSMiddleware,
allow_origins=settings.public_frontend_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/healthz", tags=["health"])
def healthz() -> dict[str, str]:
return {"status": "ok"}
app.include_router(internal_router)
app.include_router(admin_router)
app.include_router(me_router)
app.include_router(auth_router)

View File

@@ -1,5 +0,0 @@
from app.models.api_client import ApiClient
from app.models.permission import Permission
from app.models.user import User
__all__ = ["ApiClient", "Permission", "User"]

View File

@@ -1,31 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class ApiClient(Base):
__tablename__ = "api_clients"
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
client_key: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
api_key_hash: Mapped[str] = mapped_column(Text, nullable=False)
allowed_origins: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
allowed_ips: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
allowed_paths: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
rate_limit_per_min: Mapped[int | None] = mapped_column(Integer)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -1,31 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Permission(Base):
__tablename__ = "permissions"
__table_args__ = (
UniqueConstraint(
"user_id",
"scope_type",
"scope_id",
"module",
"action",
name="uq_permissions_user_scope_module_action",
),
)
id: Mapped[str] = mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(UUID(as_uuid=False), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
scope_type: Mapped[str] = mapped_column(String(32), nullable=False)
scope_id: Mapped[str] = mapped_column(String(128), nullable=False)
module: Mapped[str] = mapped_column(String(128), nullable=False)
action: Mapped[str] = mapped_column(String(32), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View File

@@ -1,24 +0,0 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class User(Base):
__tablename__ = "users"
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)
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

View File

@@ -1 +0,0 @@
"""Repository layer."""

View File

@@ -1,63 +0,0 @@
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models.permission import Permission
class PermissionsRepository:
def __init__(self, db: Session) -> None:
self.db = db
def list_by_user_id(self, user_id: str) -> list[Permission]:
stmt = select(Permission).where(Permission.user_id == user_id)
return list(self.db.scalars(stmt).all())
def create_if_not_exists(
self,
user_id: str,
scope_type: str,
scope_id: str,
module: str,
action: str,
) -> Permission:
stmt = select(Permission).where(
Permission.user_id == user_id,
Permission.scope_type == scope_type,
Permission.scope_id == scope_id,
Permission.module == module,
Permission.action == action,
)
existing = self.db.scalar(stmt)
if existing:
return existing
item = Permission(
user_id=user_id,
scope_type=scope_type,
scope_id=scope_id,
module=module,
action=action,
)
self.db.add(item)
self.db.commit()
self.db.refresh(item)
return item
def revoke(
self,
user_id: str,
scope_type: str,
scope_id: str,
module: str,
action: str,
) -> int:
stmt = delete(Permission).where(
Permission.user_id == user_id,
Permission.scope_type == scope_type,
Permission.scope_id == scope_id,
Permission.module == module,
Permission.action == action,
)
result = self.db.execute(stmt)
self.db.commit()
return int(result.rowcount or 0)

View File

@@ -1,42 +0,0 @@
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.user import User
class UsersRepository:
def __init__(self, db: Session) -> None:
self.db = db
def get_by_sub(self, authentik_sub: str) -> User | None:
stmt = select(User).where(User.authentik_sub == authentik_sub)
return self.db.scalar(stmt)
def upsert_by_sub(
self,
authentik_sub: str,
email: str | None,
display_name: str | None,
is_active: bool,
authentik_user_id: int | None = None,
) -> User:
user = self.get_by_sub(authentik_sub)
if user is None:
user = User(
authentik_sub=authentik_sub,
authentik_user_id=authentik_user_id,
email=email,
display_name=display_name,
is_active=is_active,
)
self.db.add(user)
else:
if authentik_user_id is not None:
user.authentik_user_id = authentik_user_id
user.email = email
user.display_name = display_name
user.is_active = is_active
self.db.commit()
self.db.refresh(user)
return user

View File

@@ -1 +0,0 @@
"""Pydantic schemas."""

View File

@@ -1,14 +0,0 @@
from pydantic import BaseModel
class AuthentikPrincipal(BaseModel):
sub: str
email: str | None = None
name: str | None = None
preferred_username: str | None = None
class MeSummaryResponse(BaseModel):
sub: str
email: str | None = None
display_name: str | None = None

View File

@@ -1,13 +0,0 @@
from pydantic import BaseModel
class AuthentikEnsureUserRequest(BaseModel):
sub: str
email: str
display_name: str | None = None
is_active: bool = True
class AuthentikEnsureUserResponse(BaseModel):
authentik_user_id: int
action: str

View File

@@ -1,13 +0,0 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "Bearer"
expires_in: int | None = None
scope: str | None = None

View File

@@ -1,31 +0,0 @@
from pydantic import BaseModel
class PermissionGrantRequest(BaseModel):
authentik_sub: str
email: str | None = None
display_name: str | None = None
scope_type: str
scope_id: str
module: str
action: str
class PermissionRevokeRequest(BaseModel):
authentik_sub: str
scope_type: str
scope_id: str
module: str
action: str
class PermissionItem(BaseModel):
scope_type: str
scope_id: str
module: str
action: str
class PermissionSnapshotResponse(BaseModel):
authentik_sub: str
permissions: list[PermissionItem]

View File

@@ -1,8 +0,0 @@
from pydantic import BaseModel
class UserUpsertBySubRequest(BaseModel):
sub: str
email: str | None = None
display_name: str | None = None
is_active: bool = True

View File

@@ -1 +0,0 @@
"""Security dependencies and guards."""

View File

@@ -1,75 +0,0 @@
import hashlib
import hmac
from datetime import datetime, timezone
from fastapi import Depends, Header, HTTPException, Request, status
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.api_client import ApiClient
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
def _verify_api_key(plain_key: str, stored_hash: str) -> bool:
# Support sha256:<hex> for bootstrap, and bcrypt/argon2 for production.
if stored_hash.startswith("sha256:"):
hex_hash = hashlib.sha256(plain_key.encode("utf-8")).hexdigest()
return hmac.compare_digest(stored_hash.removeprefix("sha256:"), hex_hash)
try:
return pwd_context.verify(plain_key, stored_hash)
except Exception:
return False
def _is_expired(expires_at: datetime | None) -> bool:
if expires_at is None:
return False
now = datetime.now(timezone.utc)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return expires_at <= now
def _check_request_whitelist(client: ApiClient, request: Request) -> None:
origin = request.headers.get("origin")
client_ip = request.client.host if request.client else None
path = request.url.path
if client.allowed_origins and origin and origin not in client.allowed_origins:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="origin_not_allowed")
if client.allowed_ips and client_ip and client_ip not in client.allowed_ips:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ip_not_allowed")
if client.allowed_paths and not any(path.startswith(prefix) for prefix in client.allowed_paths):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="path_not_allowed")
def require_api_client(
request: Request,
x_client_key: str = Header(alias="X-Client-Key"),
x_api_key: str = Header(alias="X-API-Key"),
db: Session = Depends(get_db),
) -> ApiClient:
stmt = select(ApiClient).where(ApiClient.client_key == x_client_key)
client = db.scalar(stmt)
if client is None or client.status != "active":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_client")
if _is_expired(client.expires_at):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="client_expired")
if not _verify_api_key(x_api_key, client.api_key_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_api_key")
_check_request_whitelist(client, request)
client.last_used_at = datetime.now(timezone.utc)
db.commit()
return client

View File

@@ -1,107 +0,0 @@
from __future__ import annotations
from functools import lru_cache
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import get_settings
from app.schemas.auth import AuthentikPrincipal
bearer_scheme = HTTPBearer(auto_error=False)
class AuthentikTokenVerifier:
def __init__(
self,
issuer: str | None,
jwks_url: str | None,
audience: str | None,
client_secret: str | None,
) -> None:
self.issuer = issuer.strip() if issuer else None
self.jwks_url = jwks_url.strip() if jwks_url else self._infer_jwks_url(self.issuer)
self.audience = audience.strip() if audience else None
self.client_secret = client_secret.strip() if client_secret else None
if not self.jwks_url:
raise ValueError("AUTHENTIK_JWKS_URL or AUTHENTIK_ISSUER is required")
self._jwk_client = jwt.PyJWKClient(self.jwks_url)
@staticmethod
def _infer_jwks_url(issuer: str | None) -> str | None:
if not issuer:
return None
normalized = issuer.rstrip("/") + "/"
if normalized.endswith("/jwks/"):
return normalized
return normalized + "jwks/"
def verify_access_token(self, token: str) -> AuthentikPrincipal:
try:
header = jwt.get_unverified_header(token)
algorithm = str(header.get("alg", "")).upper()
options = {
"verify_signature": True,
"verify_exp": True,
"verify_aud": bool(self.audience),
"verify_iss": bool(self.issuer),
}
if algorithm.startswith("HS"):
if not self.client_secret:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="missing_authentik_client_secret",
)
key = self.client_secret
allowed_algorithms = ["HS256", "HS384", "HS512"]
else:
signing_key = self._jwk_client.get_signing_key_from_jwt(token)
key = signing_key.key
allowed_algorithms = ["RS256", "RS384", "RS512"]
claims = jwt.decode(
token,
key,
algorithms=allowed_algorithms,
audience=self.audience,
issuer=self.issuer,
options=options,
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid_bearer_token") from exc
sub = claims.get("sub")
if not sub:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token_missing_sub")
return AuthentikPrincipal(
sub=sub,
email=claims.get("email"),
name=claims.get("name"),
preferred_username=claims.get("preferred_username"),
)
@lru_cache
def _get_verifier() -> AuthentikTokenVerifier:
settings = get_settings()
return AuthentikTokenVerifier(
issuer=settings.authentik_issuer,
jwks_url=settings.authentik_jwks_url,
audience=settings.authentik_audience,
client_secret=settings.authentik_client_secret,
)
def require_authenticated_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
) -> AuthentikPrincipal:
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="missing_bearer_token")
verifier = _get_verifier()
return verifier.verify_access_token(credentials.credentials)

View File

@@ -1 +0,0 @@
"""Service layer."""

View File

@@ -1,75 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
import httpx
from fastapi import HTTPException, status
from app.core.config import Settings
@dataclass
class AuthentikSyncResult:
user_id: int
action: str
class AuthentikAdminService:
def __init__(self, settings: Settings) -> None:
self.base_url = settings.authentik_base_url.rstrip("/")
self.admin_token = settings.authentik_admin_token
self.verify_tls = settings.authentik_verify_tls
if not self.base_url or not self.admin_token:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="authentik_admin_not_configured",
)
def _client(self) -> httpx.Client:
return httpx.Client(
base_url=self.base_url,
headers={
"Authorization": f"Bearer {self.admin_token}",
"Accept": "application/json",
"Content-Type": "application/json",
},
timeout=10,
verify=self.verify_tls,
)
@staticmethod
def _safe_username(sub: str, email: str) -> str:
if email and "@" in email:
return email.split("@", 1)[0]
return sub.replace("|", "_")[:150]
def ensure_user(self, sub: str, email: str, display_name: str | None, is_active: bool = True) -> AuthentikSyncResult:
payload = {
"username": self._safe_username(sub=sub, email=email),
"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
if existing and existing.get("pk") is not None:
user_pk = int(existing["pk"])
patch_resp = client.patch(f"/api/v3/core/users/{user_pk}/", json=payload)
if patch_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_update_failed")
return AuthentikSyncResult(user_id=user_pk, action="updated")
create_resp = client.post("/api/v3/core/users/", json=payload)
if create_resp.status_code >= 400:
raise HTTPException(status_code=502, detail="authentik_create_failed")
created = create_resp.json()
return AuthentikSyncResult(user_id=int(created["pk"]), action="created")

View File

@@ -1,13 +0,0 @@
from app.schemas.permissions import PermissionItem, PermissionSnapshotResponse
class PermissionService:
@staticmethod
def build_snapshot(authentik_sub: str, permissions: list[tuple[str, str, str, str]]) -> PermissionSnapshotResponse:
return PermissionSnapshotResponse(
authentik_sub=authentik_sub,
permissions=[
PermissionItem(scope_type=s_type, scope_id=s_id, module=module, action=action)
for s_type, s_id, module, action in permissions
],
)

View File

@@ -1,30 +0,0 @@
[project]
name = "memberapi-backend"
version = "0.1.0"
description = "memberapi.ose.tw backend"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.116.0",
"uvicorn[standard]>=0.35.0",
"sqlalchemy>=2.0.44",
"psycopg[binary]>=3.2.9",
"pydantic-settings>=2.11.0",
"python-dotenv>=1.1.1",
"passlib[bcrypt]>=1.7.4",
"pyjwt[crypto]>=2.10.1",
"httpx>=0.28.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.4.2",
"ruff>=0.13.0",
]
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = "-q"
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env python3
import argparse
import hashlib
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
def main() -> None:
parser = argparse.ArgumentParser(description="Generate API key hash for api_clients table")
parser.add_argument("api_key", help="Plain API key")
parser.add_argument(
"--algo",
choices=["argon2", "bcrypt", "sha256"],
default="argon2",
help="Hash algorithm (default: argon2)",
)
args = parser.parse_args()
if args.algo == "sha256":
print("sha256:" + hashlib.sha256(args.api_key.encode("utf-8")).hexdigest())
return
print(pwd_context.hash(args.api_key, scheme=args.algo))
if __name__ == "__main__":
main()

View File

@@ -1,31 +0,0 @@
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
authentik_sub VARCHAR(255) NOT NULL UNIQUE,
authentik_user_id INTEGER,
email VARCHAR(320),
display_name VARCHAR(255),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scope_type VARCHAR(32) NOT NULL,
scope_id VARCHAR(128) NOT NULL,
module VARCHAR(128) NOT NULL,
action VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_permissions_user_scope_module_action
UNIQUE (user_id, scope_type, scope_id, module, action)
);
CREATE INDEX IF NOT EXISTS idx_users_authentik_sub ON users(authentik_sub);
CREATE INDEX IF NOT EXISTS idx_permissions_user_id ON permissions(user_id);
COMMIT;

View File

@@ -1,2 +0,0 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS authentik_user_id INTEGER;

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
source .venv/bin/activate
exec uvicorn app.main:app --env-file .env.development --host 127.0.0.1 --port 8000 --reload

View File

@@ -1,17 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
from app.security.authentik_jwt import AuthentikTokenVerifier
def test_infer_jwks_url() -> None:
assert AuthentikTokenVerifier._infer_jwks_url("https://auth.ose.tw/application/o/member/") == (
"https://auth.ose.tw/application/o/member/jwks/"
)
def test_me_requires_bearer_token() -> None:
client = TestClient(app)
resp = client.get("/me")
assert resp.status_code == 401
assert resp.json()["detail"] == "missing_bearer_token"

View File

@@ -1,10 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
def test_healthz() -> None:
client = TestClient(app)
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"

View File

@@ -1,19 +0,0 @@
from fastapi.testclient import TestClient
from app.main import app
def test_internal_authentik_ensure_requires_config() -> None:
client = TestClient(app)
resp = client.post(
"/internal/authentik/users/ensure",
headers={"X-Internal-Secret": "CHANGE_ME"},
json={
"sub": "authentik-sub-1",
"email": "user@example.com",
"display_name": "User Example",
"is_active": True,
},
)
assert resp.status_code == 503
assert resp.json()["detail"] == "authentik_admin_not_configured"

View File

@@ -0,0 +1,29 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: memberapi_ose_tw
restart: unless-stopped
env_file:
- ./backend/.env
ports:
- "127.0.0.1:8000:8000"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8000/healthz >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
networks:
- postgres
- ose-cache
- nginx
networks:
postgres:
external: true
ose-cache:
external: true
nginx:
external: true

View File

@@ -1,106 +0,0 @@
-- member_center: API 呼叫方白名單表
-- 位置: public schema
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'client_status') THEN
CREATE TYPE client_status AS ENUM ('active', 'inactive');
END IF;
END $$;
CREATE TABLE IF NOT EXISTS api_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status client_status NOT NULL DEFAULT 'active',
-- 只存 hash不存明文 key
api_key_hash TEXT NOT NULL,
-- 可先留空,之後再嚴格化
allowed_origins JSONB NOT NULL DEFAULT '[]'::jsonb,
allowed_ips JSONB NOT NULL DEFAULT '[]'::jsonb,
allowed_paths JSONB NOT NULL DEFAULT '[]'::jsonb,
rate_limit_per_min INTEGER,
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_api_clients_status ON api_clients(status);
CREATE INDEX IF NOT EXISTS idx_api_clients_expires_at ON api_clients(expires_at);
CREATE OR REPLACE FUNCTION set_updated_at_api_clients()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_api_clients_set_updated_at'
) THEN
CREATE TRIGGER trg_api_clients_set_updated_at
BEFORE UPDATE ON api_clients
FOR EACH ROW EXECUTE FUNCTION set_updated_at_api_clients();
END IF;
END $$;
-- 建議初始化 2~3 個 clientapi_key_hash 先放占位,後續再更新)
INSERT INTO api_clients (
client_key,
name,
status,
api_key_hash,
allowed_origins,
allowed_ips,
allowed_paths,
rate_limit_per_min
)
VALUES
(
'mkt-backend',
'MKT Backend Service',
'active',
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
'[]'::jsonb,
'[]'::jsonb,
'["/internal/users/upsert-by-sub", "/internal/permissions"]'::jsonb,
600
),
(
'admin-frontend',
'Admin Frontend',
'active',
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
'["https://admin.ose.tw", "https://member.ose.tw"]'::jsonb,
'[]'::jsonb,
'["/admin"]'::jsonb,
300
),
(
'ops-local',
'Ops Local Tooling',
'inactive',
'REPLACE_WITH_BCRYPT_OR_ARGON2_HASH',
'[]'::jsonb,
'["127.0.0.1"]'::jsonb,
'["/internal", "/admin"]'::jsonb,
120
)
ON CONFLICT (client_key) DO NOTHING;
COMMIT;
-- 快速檢查
-- SELECT client_key, status, expires_at, created_at FROM api_clients ORDER BY client_key;

44
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,44 @@
# member-platform 架構總覽Keycloak 版)
## 核心模型
- 業務層:`companies -> sites`
- 身分層:`users <-> sites`(多對多,透過 `user_sites`
- 能力層:`systems -> roles`
- 授權層:`sites <-> roles`(多對多,透過 `site_roles`
## 權限模型(已定版)
- `permission` 正式改名為 `role`
- `role` 僅能指派給 `site`,不可直接指派給 `user`
- `system` / `role` 以 Keycloak 為唯一建立來源member 後台只做同步顯示與關聯。
- `user` 的有效角色由以下關聯推導:
- `user_sites`(使用者屬於哪些 site
- `site_roles`site 擁有哪些 role
- 不再使用舊的 `permission_groups` 主流程。
## Key 規則
- `system_key`: `SYyyyyMMddX####`
- `company_key`: `CPyyyyMMddX####`
- `site_key`: `STyyyyMMddX####`
- `role_key`: `RLyyyyMMddX####`
## Keycloak 同步策略
- Keycloak 為唯一 IdP。
- 群組階層:`Company Group -> Site SubGroup`
- 系統角色:以 Keycloak client role 表示,對應 DB `roles`
- `site_roles` 代表某 Site 擁有的 Keycloak role 集合。
- 同步策略改為手動觸發:不在列表讀取 (`R`) 時自動同步。
- 補齊策略:僅在手動同步按鈕(`POST /admin/sync/from-provider`)或 CUD 流程時同步。
- 站台角色指派(`PUT /admin/sites/{site_key}/roles``PUT /admin/roles/{role_key}/sites`)會即時同步到 Keycloak Group Role Mapping。
- 使用者加入 Site 時,透過同步邏輯使其在 IdP 端取得對應角色能力。
- 讀取效能:後端採用 memory cache後續可換 Redis`GET` 先讀快取;`POST/PUT/PATCH/DELETE` 成功後自動失效快取。
- 快取後端可由 `.env` 切換:`CACHE_BACKEND=memory|redis`(無需改程式)。
## 後台安全線
- `/admin/*` 必須 Bearer token。
- 後端以 Keycloak realm role 判定是否可進站與後台。
- 未具備 `MEMBER_REQUIRED_REALM_ROLES` 的帳號,`/me``/admin/*` 皆拒絕。
- 未具備 `ADMIN_REQUIRED_REALM_ROLES` 的帳號,`/admin/*` 拒絕。
## API 白名單
- 保留 `api_clients` 做系統對系統呼叫控管。
- 管理後台登入控管與 API client 白名單是兩條獨立安全線。

View File

@@ -1,36 +0,0 @@
# member 系統文件入口Architecture & Config
## 入口說明
這份文件是入口索引。若你只要快速開始,先看:
1. `docs/BACKEND_BOOTSTRAP.md`
2. `docs/BACKEND_ARCHITECTURE.md`
3. `docs/FRONTEND_ARCHITECTURE.md`
## 文件地圖
- `docs/index.md`
- 前端開工入口(給前端 AI 的第一份)
- `docs/BACKEND_BOOTSTRAP.md`
- 後端啟動步驟(環境、安裝、建表、啟動)
- `docs/BACKEND_ARCHITECTURE.md`
- memberapi 後端模組、資料流、API、安全策略
- `docs/FRONTEND_ARCHITECTURE.md`
- member 前端架構(可直接開工版)
- `docs/FRONTEND_API_CONTRACT.md`
- 前端 API request/response 契約
- `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
- 前端實作與交付核對清單
- `docs/TASKPLAN_FRONTEND.md`
- 前端任務進度與驗收條件
- `docs/TASKPLAN_BACKEND.md`
- 後端任務進度與驗收條件
- `docs/API_CLIENTS_SQL.sql`
- `api_clients` 白名單表與初始資料 SQL
- `docs/DB_SCHEMA_SNAPSHOT.md`
- 目前資料庫 schema 快照(欄位/索引/約束)
## 目前狀態2026-03-29
- 後端骨架已建立FastAPI + SQLAlchemy
- 核心 API已建立health/internal/admin
- API key 驗證:已建立(`X-Client-Key` + `X-API-Key`
- Authentik JWT 驗證:已建立(`/me` 路由 + JWKS 驗簽)
- Authentik Admin API建立/更新使用者):已建立(`/internal/authentik/users/ensure`

View File

@@ -1,93 +0,0 @@
# memberapi.ose.tw 後端架構FastAPI
## 1. 目標與邊界
- 網域:`memberapi.ose.tw`
- 角色會員中心後端真相來源User + Permission
- 範圍:
- user upsert`authentik_sub` 為跨系統主鍵)
- permission grant/revoke
- permission snapshot 提供給其他系統
- 不在本服務處理:
- Authentik OIDC 流程頁與 UI
- 前端互動邏輯
## 2. 技術棧
- Python 3.12
- FastAPI
- SQLAlchemy 2.0
- PostgreSQLpsycopg
- Pydantic Settings
## 3. 後端目錄(已建立)
- `backend/app/main.py`
- `backend/app/api/`
- `internal.py`
- `admin.py`
- `backend/app/core/config.py`
- `backend/app/db/session.py`
- `backend/app/models/`
- `user.py`
- `permission.py`
- `api_client.py`
- `backend/app/repositories/`
- `users_repo.py`
- `permissions_repo.py`
- `backend/app/security/api_client_auth.py`
- `backend/scripts/init_schema.sql`
- `backend/.env.example`
- `backend/.env.production.example`
## 4. 資料模型
- `users`
- `id`, `authentik_sub`(unique), `email`, `display_name`, `is_active`, timestamps
- `permissions`
- `id`, `user_id`, `scope_type`, `scope_id`, `module`, `action`, `created_at`
- unique constraint: `(user_id, scope_type, scope_id, module, action)`
- `api_clients`(由 `docs/API_CLIENTS_SQL.sql` 建立)
- `client_key`, `api_key_hash`, `status`, allowlist, expires/rate-limit 欄位
## 5. API 設計MVP
- 健康檢查
- `GET /healthz`
- 使用者路由Bearer token
- `GET /me`
- `GET /me/permissions/snapshot`
- Bearer token 由 Authentik JWT + JWKS 驗證,並以 `sub` 自動 upsert user
- 內部路由(系統對系統)
- `POST /internal/users/upsert-by-sub`
- `GET /internal/permissions/{authentik_sub}/snapshot`
- `POST /internal/authentik/users/ensure`
- header: `X-Internal-Secret`
- 管理路由(後台/API client
- `POST /admin/permissions/grant`
- `POST /admin/permissions/revoke`
- headers: `X-Client-Key`, `X-API-Key`
## 6. 安全策略
- `admin` 路由強制 API client 驗證:
- client 必須存在且 `status=active`
- `expires_at` 未過期
- `api_key_hash` 驗證(支援 `sha256:<hex>` 與 bcrypt/argon2
- allowlist 驗證origin/ip/path
- `internal` 路由使用 `X-Internal-Secret` 做服務間驗證
- `me` 路由使用 Authentik Access Token 驗證:
- 使用 `AUTHENTIK_JWKS_URL``AUTHENTIK_ISSUER` 推導 JWKS
- 可選 `AUTHENTIK_AUDIENCE` 驗證 aud claim
- Authentik Admin 整合:
- 使用 `AUTHENTIK_BASE_URL + AUTHENTIK_ADMIN_TOKEN`
- 可透過 `/internal/authentik/users/ensure` 建立或更新 Authentik user
- 建議上線前:
-`.env` 範本中的明文密碼改為部署平台 secret
- API key 全部改為 argon2/bcrypt hash
## 7. 與其他系統資料流
1. mkt/admin 後端登入後,以 token `sub` 呼叫 `/internal/users/upsert-by-sub`
2. 權限調整走 `/admin/permissions/grant|revoke`
3. 需要授權判斷時,呼叫 `/internal/permissions/{sub}/snapshot`
4. mkt 系統可本地快取 snapshot並做定時補償
## 8. 下一階段(建議)
- 加入 Alembic migration
- 為 permission/action 加 enum 與驗證規則
- 增加 audit log誰在何時授權/撤銷)
- 加入 rate-limit 與可觀測性metrics + request id

View File

@@ -1,38 +0,0 @@
# Backend Bootstrapmemberapi
## 1. 環境準備
```bash
cd member.ose.tw/backend
python -m venv .venv
source .venv/bin/activate
pip install -e .
cp .env.example .env
```
## 2. 建立資料表
1. 先執行 `member.ose.tw/docs/API_CLIENTS_SQL.sql`
2. 再執行 `member.ose.tw/backend/scripts/init_schema.sql`
3. 若是舊資料庫,補跑 `member.ose.tw/backend/scripts/migrate_add_authentik_user_id.sql`
## 3. 啟動服務
```bash
cd member.ose.tw/backend
./scripts/start_dev.sh
```
說明:
- `start_dev.sh` 會用 `--env-file .env.development` 啟動,不需要每次手改 `.env`
## 4. Authentik JWT 最小設定
`.env` 至少填一種:
- `AUTHENTIK_JWKS_URL=<jwks endpoint>`
- `AUTHENTIK_ISSUER=<issuer url>`(系統會推導 `<issuer>/jwks/`
可選:
- `AUTHENTIK_AUDIENCE=<audience>`
- `AUTHENTIK_CLIENT_SECRET=<client_secret>`token 為 HS256 時需要)
## 5. 快速驗證
```bash
curl -sS http://127.0.0.1:8000/healthz
```

27
docs/BACKEND_TASKPLAN.md Normal file
View File

@@ -0,0 +1,27 @@
# Backend TaskPlan
## 待辦
- [ ] Keycloak 同步器優化Company/Site group 同步、System client role 同步、Site 角色套用同步(含效能與重複同步抑制)。
- [ ] 補齊 pytest API 測試CRUD、關聯、同步、刪除、錯誤碼
- [ ] 補一支「一鍵重建 schema」腳本`init_schema.sql`)。
## 進行中
- [ ] Swagger response model 與前端實際畫面持續對齊。
## 已完成
- [x] schema 重建:清除舊表,改為 `roles``site_roles``user_sites` 主流程。
- [x] 移除舊後端程式:`module/permission_group/user_scope_permissions` 相關 model/repo/api。
- [x] Admin API 改版:`Company/Site/System/Role/User` CRUD 與關聯 API。
- [x] Role 指派只允許綁 Site不提供 user direct role API。
- [x] `/me/permissions/snapshot` 改為 role 聚合格式。
- [x] Internal API 改版:可取 `users/{user_sub}/roles` 聚合結果。
- [x] 保留 `api_clients` 白名單管理 API。
- [x] Keycloak OIDC 登入主流程。
- [x] `/admin/*` Bearer + admin 群組白名單安全線。
- [x] 公司/站台 CRUD 同步 Keycloak Groupcreate/update/delete
- [x] Keycloak -> DB 補齊同步(公司/站台/系統/角色/使用者)。
- [x] 系統改為 Keycloak 唯一來源(後台停用 system CRUD
- [x] Role CRUD 同步 Provider Client Role新增/修改/刪除會同步到 Provider
- [x] Site/Role 關聯指派同步 Keycloak Group Role Mapping雙向指派入口皆同步
- [x] 後端讀取快取memory與 CUD 自動失效機制(可後續切 Redis
- [x] 快取後端抽象完成:`.env` 可切換 `memory` / `redis`

104
docs/DB_SCHEMA.md Normal file
View File

@@ -0,0 +1,104 @@
# DB Schema新架構目標版
> 本文件是新架構的目標資料模型,供後端 schema 重建與 migration 依據。
> DB 真實來源仍以 [backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql) 為準。
## 1) companies
- `id` UUID PK default `gen_random_uuid()`
- `company_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `provider_group_id` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 2) sites
- `id` UUID PK default `gen_random_uuid()`
- `site_key` TEXT NOT NULL UNIQUE
- `company_id` UUID NOT NULL FK -> `companies(id)` ON DELETE CASCADE
- `display_name` TEXT NOT NULL
- `domain` TEXT
- `provider_group_id` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 3) systems
- `id` UUID PK default `gen_random_uuid()`
- `system_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 4) roles
- `id` UUID PK default `gen_random_uuid()`
- `role_key` TEXT NOT NULL UNIQUE
- `role_code` TEXT NOT NULL語意代碼建議格式`<system>:<module>:<action>`,例如 `mkt:marketing_card:edit`
- `system_id` UUID NOT NULL FK -> `systems(id)` ON DELETE CASCADE
- `name` TEXT NOT NULL
- `description` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`system_id`, `name`)
- UNIQUE(`system_id`, `role_code`)
## 5) site_roles
- `id` UUID PK default `gen_random_uuid()`
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
- `role_id` UUID NOT NULL FK -> `roles(id)` ON DELETE CASCADE
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`site_id`, `role_id`)
## 6) users
- `id` UUID PK default `gen_random_uuid()`
- `user_sub` TEXT NOT NULL UNIQUE
- `provider_user_id` TEXT UNIQUE
- `username` TEXT UNIQUE
- `email` TEXT UNIQUE
- `display_name` TEXT
- `status` VARCHAR(16) NOT NULL default `'active'`
- `is_active` BOOLEAN NOT NULL default `true`
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 7) user_sites
- `id` UUID PK default `gen_random_uuid()`
- `user_id` UUID NOT NULL FK -> `users(id)` ON DELETE CASCADE
- `site_id` UUID NOT NULL FK -> `sites(id)` ON DELETE CASCADE
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`user_id`, `site_id`)
## 8) auth_sync_state
- `id` UUID PK default `gen_random_uuid()`
- `entity_type` VARCHAR(32) NOT NULL
- `entity_id` UUID NOT NULL
- `last_synced_at` TIMESTAMPTZ
- `source_version` TEXT
- `last_error` TEXT
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
- UNIQUE(`entity_type`, `entity_id`)
## 9) api_clients
- `id` UUID PK default `gen_random_uuid()`
- `client_key` TEXT NOT NULL UNIQUE
- `name` TEXT NOT NULL
- `status` VARCHAR(16) NOT NULL default `'active'`
- `api_key_hash` TEXT NOT NULL
- `allowed_origins` JSONB NOT NULL default `'[]'::jsonb`
- `allowed_ips` JSONB NOT NULL default `'[]'::jsonb`
- `allowed_paths` JSONB NOT NULL default `'[]'::jsonb`
- `rate_limit_per_min` INTEGER
- `expires_at` TIMESTAMPTZ
- `last_used_at` TIMESTAMPTZ
- `created_at` TIMESTAMPTZ NOT NULL default `now()`
- `updated_at` TIMESTAMPTZ NOT NULL default `now()`
## 關聯總結
- Company 1:N Site
- System 1:N Role
- Site M:N Role`site_roles`
- User M:N Site`user_sites`
- User 最終角色由 Site 推導,不做 user direct role 指派。

View File

@@ -1,215 +0,0 @@
# DB Schema Snapshot
- Date: 2026-03-30
- Database: `member.ose.tw`
- Schema: `public`
## Tables
- `api_clients`
- `auth_sync_state`
- `companies`
- `modules`
- `permissions`
- `sites`
- `user_scope_permissions`
- `users`
## `api_clients`
### Columns
- `id`: `uuid` (not null: `true`)
- `client_key`: `text` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `client_status` (not null: `true`)
- `api_key_hash`: `text` (not null: `true`)
- `allowed_origins`: `jsonb` (not null: `true`)
- `allowed_ips`: `jsonb` (not null: `true`)
- `allowed_paths`: `jsonb` (not null: `true`)
- `rate_limit_per_min`: `integer` (not null: `false`)
- `expires_at`: `timestamp with time zone` (not null: `false`)
- `last_used_at`: `timestamp with time zone` (not null: `false`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `api_clients_client_key_key` (`u`): UNIQUE (client_key)
- `api_clients_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `api_clients_client_key_key`: `CREATE UNIQUE INDEX api_clients_client_key_key ON public.api_clients USING btree (client_key)`
- `api_clients_pkey`: `CREATE UNIQUE INDEX api_clients_pkey ON public.api_clients USING btree (id)`
- `idx_api_clients_expires_at`: `CREATE INDEX idx_api_clients_expires_at ON public.api_clients USING btree (expires_at)`
- `idx_api_clients_status`: `CREATE INDEX idx_api_clients_status ON public.api_clients USING btree (status)`
## `auth_sync_state`
### Columns
- `user_id`: `uuid` (not null: `true`)
- `last_synced_at`: `timestamp with time zone` (not null: `false`)
- `source_version`: `text` (not null: `false`)
- `last_error`: `text` (not null: `false`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `auth_sync_state_pkey` (`p`): PRIMARY KEY (user_id)
- `auth_sync_state_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
### Indexes
- `auth_sync_state_pkey`: `CREATE UNIQUE INDEX auth_sync_state_pkey ON public.auth_sync_state USING btree (user_id)`
## `companies`
### Columns
- `id`: `uuid` (not null: `true`)
- `company_key`: `text` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `companies_company_key_key` (`u`): UNIQUE (company_key)
- `companies_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `companies_company_key_key`: `CREATE UNIQUE INDEX companies_company_key_key ON public.companies USING btree (company_key)`
- `companies_pkey`: `CREATE UNIQUE INDEX companies_pkey ON public.companies USING btree (id)`
## `modules`
### Columns
- `id`: `uuid` (not null: `true`)
- `module_key`: `text` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `modules_module_key_key` (`u`): UNIQUE (module_key)
- `modules_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `modules_module_key_key`: `CREATE UNIQUE INDEX modules_module_key_key ON public.modules USING btree (module_key)`
- `modules_pkey`: `CREATE UNIQUE INDEX modules_pkey ON public.modules USING btree (id)`
## `permissions`
### Columns
- `id`: `uuid` (not null: `true`)
- `user_id`: `uuid` (not null: `true`)
- `scope_type`: `character varying(32)` (not null: `true`)
- `scope_id`: `character varying(128)` (not null: `true`)
- `module`: `character varying(128)` (not null: `true`)
- `action`: `character varying(32)` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `permissions_pkey` (`p`): PRIMARY KEY (id)
- `permissions_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- `uq_permissions_user_scope_module_action` (`u`): UNIQUE (user_id, scope_type, scope_id, module, action)
### Indexes
- `idx_permissions_user_id`: `CREATE INDEX idx_permissions_user_id ON public.permissions USING btree (user_id)`
- `permissions_pkey`: `CREATE UNIQUE INDEX permissions_pkey ON public.permissions USING btree (id)`
- `uq_permissions_user_scope_module_action`: `CREATE UNIQUE INDEX uq_permissions_user_scope_module_action ON public.permissions USING btree (user_id, scope_type, scope_id, module, action)`
## `sites`
### Columns
- `id`: `uuid` (not null: `true`)
- `site_key`: `text` (not null: `true`)
- `company_id`: `uuid` (not null: `true`)
- `name`: `text` (not null: `true`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `sites_company_id_fkey` (`f`): FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
- `sites_pkey` (`p`): PRIMARY KEY (id)
- `sites_site_key_key` (`u`): UNIQUE (site_key)
### Indexes
- `idx_sites_company_id`: `CREATE INDEX idx_sites_company_id ON public.sites USING btree (company_id)`
- `sites_pkey`: `CREATE UNIQUE INDEX sites_pkey ON public.sites USING btree (id)`
- `sites_site_key_key`: `CREATE UNIQUE INDEX sites_site_key_key ON public.sites USING btree (site_key)`
## `user_scope_permissions`
### Columns
- `id`: `uuid` (not null: `true`)
- `user_id`: `uuid` (not null: `true`)
- `module_id`: `uuid` (not null: `true`)
- `action`: `permission_action` (not null: `true`)
- `scope_type`: `scope_type` (not null: `true`)
- `company_id`: `uuid` (not null: `false`)
- `site_id`: `uuid` (not null: `false`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
### Constraints
- `user_scope_permissions_check` (`c`): CHECK ((((scope_type = 'company'::scope_type) AND (company_id IS NOT NULL) AND (site_id IS NULL)) OR ((scope_type = 'site'::scope_type) AND (site_id IS NOT NULL) AND (company_id IS NULL))))
- `user_scope_permissions_company_id_fkey` (`f`): FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE
- `user_scope_permissions_module_id_fkey` (`f`): FOREIGN KEY (module_id) REFERENCES modules(id) ON DELETE CASCADE
- `user_scope_permissions_pkey` (`p`): PRIMARY KEY (id)
- `user_scope_permissions_site_id_fkey` (`f`): FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
- `user_scope_permissions_user_id_fkey` (`f`): FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
### Indexes
- `idx_usp_company_id`: `CREATE INDEX idx_usp_company_id ON public.user_scope_permissions USING btree (company_id)`
- `idx_usp_module_id`: `CREATE INDEX idx_usp_module_id ON public.user_scope_permissions USING btree (module_id)`
- `idx_usp_site_id`: `CREATE INDEX idx_usp_site_id ON public.user_scope_permissions USING btree (site_id)`
- `idx_usp_user_id`: `CREATE INDEX idx_usp_user_id ON public.user_scope_permissions USING btree (user_id)`
- `uq_usp_company`: `CREATE UNIQUE INDEX uq_usp_company ON public.user_scope_permissions USING btree (user_id, module_id, action, scope_type, company_id) WHERE (scope_type = 'company'::scope_type)`
- `uq_usp_site`: `CREATE UNIQUE INDEX uq_usp_site ON public.user_scope_permissions USING btree (user_id, module_id, action, scope_type, site_id) WHERE (scope_type = 'site'::scope_type)`
- `user_scope_permissions_pkey`: `CREATE UNIQUE INDEX user_scope_permissions_pkey ON public.user_scope_permissions USING btree (id)`
## `users`
### Columns
- `id`: `uuid` (not null: `true`)
- `authentik_sub`: `text` (not null: `true`)
- `email`: `text` (not null: `false`)
- `display_name`: `text` (not null: `false`)
- `status`: `record_status` (not null: `true`)
- `created_at`: `timestamp with time zone` (not null: `true`)
- `updated_at`: `timestamp with time zone` (not null: `true`)
- `authentik_user_id`: `integer` (not null: `false`)
- `is_active`: `boolean` (not null: `true`)
### Constraints
- `users_authentik_sub_key` (`u`): UNIQUE (authentik_sub)
- `users_email_key` (`u`): UNIQUE (email)
- `users_pkey` (`p`): PRIMARY KEY (id)
### Indexes
- `idx_users_authentik_sub`: `CREATE INDEX idx_users_authentik_sub ON public.users USING btree (authentik_sub)`
- `users_authentik_sub_key`: `CREATE UNIQUE INDEX users_authentik_sub_key ON public.users USING btree (authentik_sub)`
- `users_email_key`: `CREATE UNIQUE INDEX users_email_key ON public.users USING btree (email)`
- `users_pkey`: `CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id)`

View File

@@ -1,145 +0,0 @@
# Frontend API Contractmemberapi
Base URL`https://memberapi.ose.tw`
## 0. 帳號密碼登入
### POST `/auth/login`
Request:
```json
{
"username": "your-authentik-username",
"password": "your-password"
}
```
200 Response:
```json
{
"access_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid profile email"
}
```
401 Response:
```json
{ "detail": "invalid_username_or_password" }
```
## 1. 使用者資訊
### GET `/me`
Headers:
- `Authorization: Bearer <access_token>`
200 Response:
```json
{
"sub": "authentik-sub-123",
"email": "user@example.com",
"display_name": "User Name"
}
```
401 Error:
```json
{ "detail": "missing_bearer_token" }
```
```json
{ "detail": "invalid_bearer_token" }
```
## 2. 我的權限快照
### GET `/me/permissions/snapshot`
Headers:
- `Authorization: Bearer <access_token>`
200 Response:
```json
{
"authentik_sub": "authentik-sub-123",
"permissions": [
{
"scope_type": "site",
"scope_id": "tw-main",
"module": "campaign",
"action": "view"
}
]
}
```
## 3. Grant 權限
### POST `/admin/permissions/grant`
Headers:
- `X-Client-Key: <client_key>`
- `X-API-Key: <plain_api_key>`
Request:
```json
{
"authentik_sub": "authentik-sub-123",
"email": "user@example.com",
"display_name": "User Name",
"scope_type": "site",
"scope_id": "tw-main",
"module": "campaign",
"action": "view"
}
```
200 Response:
```json
{
"permission_id": "uuid",
"result": "granted"
}
```
## 4. Revoke 權限
### POST `/admin/permissions/revoke`
Headers:
- `X-Client-Key: <client_key>`
- `X-API-Key: <plain_api_key>`
Request:
```json
{
"authentik_sub": "authentik-sub-123",
"scope_type": "site",
"scope_id": "tw-main",
"module": "campaign",
"action": "view"
}
```
200 Response:
```json
{
"deleted": 1,
"result": "revoked"
}
```
404 Response:
```json
{ "detail": "user_not_found" }
```
## 5. Health Check
### GET `/healthz`
200 Response:
```json
{ "status": "ok" }
```
## 6. 常見錯誤碼
- `401 invalid_client`
- `401 invalid_api_key`
- `401 client_expired`
- `403 origin_not_allowed`
- `403 ip_not_allowed`
- `403 path_not_allowed`
- `503 internal_secret_not_configured`
- `503 authentik_admin_not_configured`

View File

@@ -1,87 +0,0 @@
# member.ose.tw 前端架構(可直接開工版)
## 1. 前端責任邊界
- 站點:`member.ose.tw`
- 主要責任:
- 顯示目前登入使用者資訊
- 顯示目前使用者權限快照
- 透過管理 API 執行 grant/revoke
- 不處理:
- Authentik 管理 API 直連
- 密碼重設流程(導向 Authentik
## 2. 建議技術堆疊
- Vue 3 + Vite + JavaScript
- Vue Router
- Pinia
- Axios
- Element Plus + Tailwind
## 3. 建議目錄結構
- `frontend/src/main.js`
- `frontend/src/router/index.js`
- `frontend/src/stores/`
- `auth.js`token、me、登入狀態
- `permission.js`snapshot 與快取
- `frontend/src/api/`
- `http.js`axios instance + interceptor
- `me.js`
- `permission-admin.js`
- `frontend/src/pages/`
- `profile/MePage.vue`
- `permissions/PermissionSnapshotPage.vue`
- `permissions/PermissionAdminPage.vue`
- `frontend/src/types/`
- `api.js`
## 4. 路由規劃(第一版)
- `/me`
- 顯示 `GET /me` 資料
- `/me/permissions`
- 顯示 `GET /me/permissions/snapshot`
- `/admin/permissions`
- grant/revoke 表單與結果顯示
## 5. Token 與 Header 策略
- 使用者路由(`/me*`
- 登入用 `POST /auth/login`(帳號密碼)取得 access token
- header: `Authorization: Bearer <access_token>`
- 管理路由(`/admin*`
- headers:
- `X-Client-Key`
- `X-API-Key`
- 建議:
- 使用 axios request interceptor 統一注入 header
- 401 時統一導向登入或刷新流程
## 6. 狀態管理最小模型
- `auth store`
- `accessToken: string | null`
- `me: { sub, email, display_name } | null`
- actions: `setToken`, `fetchMe`, `logout`
- `permission store`
- `snapshot: { authentik_sub, permissions[] } | null`
- actions: `fetchMySnapshot`, `grantPermission`, `revokePermission`
## 7. 錯誤處理規則
- `401`
- 使用者 token 過期或無效,導回登入
- `403`
- API client 白名單限制origin/ip/path
- `404`
- revoke 時 user 不存在
- `503`
- 後端必要設定缺失(例如 internal secret / authentik admin
## 8. 開工順序(建議)
1. 建立 axios client 與 interceptor
2. 完成 `/me``/me/permissions` 畫面
3. 完成 grant/revoke 表單
4. 補通知、loading、錯誤提示
詳細 request/response 契約請看 `docs/FRONTEND_API_CONTRACT.md`
## 9. 本地開發環境
- Vite 會自動讀取 `frontend/.env.development`
- 已預設:
- `VITE_API_BASE_URL=http://127.0.0.1:8000`

46
docs/FRONTEND_HANDOFF.md Normal file
View File

@@ -0,0 +1,46 @@
# Frontend HandoffRole-Site 模型)
## 目標
前端只實作新模型,不再使用舊 `permission_groups` / `module-action` 流程。
## 主要頁面
1. 公司管理CRUD
- 欄位:`company_key`, `name`, `status`
- 詳情頁需顯示底下 `sites` 列表
2. 站台管理CRUD
- 欄位:`site_key`, `company_key`, `display_name`, `domain`, `status`
- 站台詳情需顯示:
- 此站台綁定的 `roles`
- 此站台包含的 `users`
3. 系統管理(唯讀 + 同步)
- 欄位:`system_key`, `name`, `status`
- 系統詳情需顯示底下 `roles` 列表
- 建立/修改/刪除在 Keycloak 處理member 後台提供「同步 Keycloak」按鈕
- 所有資料列表頁不自動同步;需由使用者按下「同步」按鈕才觸發。
4. 角色管理DB 關聯為主)
- 欄位:`role_key`, `system_key`, `name`, `description`, `status`
- 關聯操作:指派到 Site新增/刪除 `site_roles`
- 需支援在「角色頁」直接多選站台並儲存(不必切到站台頁)。
5. 會員管理CRUD
- 欄位:`user_sub`, `username`, `email`, `display_name`, `is_active`, `status`
- 關聯操作:加入/移除 Site新增/刪除 `user_sites`
- 顯示推導角色(唯讀)
6. API Clients 管理CRUD
- 欄位:`client_key`, `name`, `status`, `allowed_origins`, `allowed_ips`, `allowed_paths`, `rate_limit_per_min`, `expires_at`
## 前端互動規則
- 角色不可直接綁會員UI 不提供此操作)。
- Site 與 User 的關聯調整後,角色顯示即時刷新。
- 刪除操作一律二次確認,顯示影響提示。
## 驗收重點
- 看不到舊 permission group 流程。
- 可以完整做:
- Site 綁 Role
- User 綁 Site
- 顯示 User 推導角色

View File

@@ -1,29 +0,0 @@
# Frontend Implementation Checklist
## A. 專案初始化
- [ ] 建立 `src/api/http.js`
- [ ] 建立 `src/stores/auth.js`
- [ ] 建立 `src/stores/permission.js`
- [ ] 配置 `VITE_API_BASE_URL`
## B. API 對接
- [ ] `GET /me`
- [ ] `GET /me/permissions/snapshot`
- [ ] `POST /admin/permissions/grant`
- [ ] `POST /admin/permissions/revoke`
## C. 頁面
- [ ] Me 頁面(顯示 sub/email/display_name
- [ ] 我的權限頁(表格)
- [ ] 權限管理頁grant/revoke
## D. 行為驗證
- [ ] Bearer token 遺失時顯示重新登入
- [ ] grant 成功後自動刷新 snapshot
- [ ] revoke 成功後自動刷新 snapshot
- [ ] 404 user_not_found 顯示可讀錯誤訊息
## E. 交付條件
- [ ] 三個頁面都可獨立刷新
- [ ] 所有 API 錯誤碼都有 UI 提示
- [ ] 主要操作有 loading 與成功提示

20
docs/FRONTEND_TASKPLAN.md Normal file
View File

@@ -0,0 +1,20 @@
# Frontend TaskPlan
## 待辦
- [ ] API 失敗狀態頁統一401/403/409/422/500
## 進行中
- [ ] OIDC callback 與 token 持久化穩定性檢查(含 token 到期後重新登入行為)。
## 已完成
- [x] Vue3 + JS + Vite + Element Plus + Tailwind 基礎框架。
- [x] OIDC 登入按鈕導轉與 callback 路由骨架。
- [x] 後台導覽改版:`公司 / 站台 / 系統 / 角色 / 會員 / API Clients`
- [x] 公司頁CRUD + 公司底下站台列表。
- [x] 站台頁CRUD + 站台角色指派 + 站台會員列表。
- [x] 系統頁:改為 Keycloak 唯一來源(唯讀 + 手動同步按鈕 + 角色列表)。
- [x] 角色頁CRUD + 角色綁定站台列表。
- [x] 會員頁CRUD + User 綁 Site + 顯示推導角色 + 重設密碼。
- [x] 刪除流程補齊(公司/站台/系統/角色/會員/API client
- [x] 移除舊流程前端檔案:`modules` / `permission_groups` / `permission_admin` 頁面與 API。
- [x] 我的角色頁改為 role snapshot 顯示(不再顯示舊 permission 欄位)。

View File

@@ -0,0 +1,49 @@
# Internal API Handoff新模型
## Base URL
- Local: `http://127.0.0.1:8000`
- Prod: 由部署環境提供
## Auth Headers`/internal/*`
- `X-Client-Key: <client_key>`
- `X-API-Key: <api_key>`
## Common Error Response
```json
{ "detail": "error_code" }
```
## 已實作端點
1. `GET /internal/companies`
2. `GET /internal/sites`
3. `GET /internal/systems`
4. `GET /internal/roles`
5. `GET /internal/members`
6. `POST /internal/users/upsert-by-sub`
7. `GET /internal/users/{user_sub}/roles`
8. `POST /internal/provider/users/ensure`
## 角色聚合回應(`GET /internal/users/{user_sub}/roles`
```json
{
"user_sub": "xxxxxxxx",
"roles": [
{
"site_key": "ST20260402X1234",
"site_display_name": "OSE Main",
"company_key": "CP20260402X5678",
"company_display_name": "OSE",
"system_key": "SY20260402X0001",
"system_name": "Marketing",
"role_key": "RL20260402X0002",
"role_code": "mkt:marketing_card:edit",
"role_name": "campaign_edit"
}
]
}
```
## 注意事項
- 不提供 user direct role 寫入 API。
- User 最終角色由 `user_sites` + `site_roles` 推導。
- `role_key` 是唯一識別鍵;業務語意解析請使用 `role_code`

83
docs/LOCAL_DEV_RUNBOOK.md Normal file
View File

@@ -0,0 +1,83 @@
# Local Dev RunbookKeycloak
## 1) 先重建 DB schema本次改版必做
```bash
cd backend
psql "$DATABASE_URL" -f scripts/init_schema.sql
```
- DB schema 檔案:[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
## 2) 啟動後端
本地開發使用 `.env.development`
```bash
cd backend
# edit .env.development directly
```
本機開發固定使用 `backend/.env.development`
再啟動:
```bash
cd backend
./scripts/start_dev.sh
```
- 專案路徑:[backend](../backend)
- 啟動腳本:[backend/scripts/start_dev.sh](../backend/scripts/start_dev.sh)
## 3) 啟動前端
```bash
cd frontend
npm install
npm run dev
```
- 本地開發固定使用 `frontend/.env.development`
- production build 讀取 `frontend/.env.production`
- 專案路徑:[frontend](../frontend)
## 4) 必要環境變數([backend/.env.development](../backend/.env.development)
- `KEYCLOAK_BASE_URL`
- `KEYCLOAK_REALM`
- `KEYCLOAK_CLIENT_ID`
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_ADMIN_CLIENT_ID`
- `KEYCLOAK_ADMIN_CLIENT_SECRET`
- `MEMBER_REQUIRED_REALM_ROLES`
- `ADMIN_REQUIRED_REALM_ROLES`
- `CACHE_BACKEND``memory``redis`
- `CACHE_REDIS_URL`
- `CACHE_PREFIX`
- `CACHE_DEFAULT_TTL_SECONDS`
### Cache 切換範例
- 本地(預設):
- `CACHE_BACKEND=memory`
- 切 Redis
- `CACHE_BACKEND=redis`
- `CACHE_REDIS_URL=redis://127.0.0.1:6379/0`
調整後重啟後端生效。
## 5) 基本檢查
1. `GET http://127.0.0.1:8000/healthz` 應為 200。
2. 前端按「前往 Keycloak 登入」應可成功導轉與回跳。
3. `GET /me` 登入後應有資料。
4. 非 admin realm role 帳號打 `/admin/*` 應為 403。
5. `POST /admin/sync/from-provider?force=true` 可手動觸發全量補齊同步。
6. 列表 API 不會自動同步 IdP避免高負載需手動按同步按鈕或呼叫同步 API。
## 6) 新模型驗收路徑
1. 新增 Company、Site。
2. 在 Keycloak 建立 SystemClient與 RoleClient Role
3. 在後台按「同步 Keycloak」確認 DB 補齊 System/Role。
4. 對 Site 指派 Role。
5. 新增 User加入 Site。
6. 驗證 User 的角色是由 Site 推導,不是 direct assign。
## 7) API 白名單驗收
1. 建立 `api_client`
2.`X-Client-Key` + `X-API-Key` 呼叫 `/internal/*`
3. 驗證未授權 key 會被拒絕。
## 8) VPS Docker 啟動Backend
- Dockerfile: [backend/Dockerfile](../backend/Dockerfile)
- 建置與啟動指令:參考 [backend/README.md](../backend/README.md) 的 `Docker (VPS / Production)` 章節。

View File

@@ -1,35 +0,0 @@
# Backend TaskPlan
## 目標
提供可被前端穩定串接的 memberapi包含身份驗證、權限快照、權限管理與 Authentik 同步能力。
## 已完成
- [x] FastAPI 專案骨架與分層api/service/repository/model
- [x] `GET /healthz`
- [x] `GET /me`
- [x] `GET /me/permissions/snapshot`
- [x] `POST /admin/permissions/grant`
- [x] `POST /admin/permissions/revoke`
- [x] `POST /internal/users/upsert-by-sub`
- [x] `GET /internal/permissions/{authentik_sub}/snapshot`
- [x] `POST /internal/authentik/users/ensure`
- [x] API client 驗證(`X-Client-Key` + `X-API-Key`
- [x] Authentik JWT 驗證JWKS
- [x] DB schema 初始化 SQL + migration SQL
- [x] 基本測試與 lint
## 進行中(建議近期)
- [ ] 增加 Alembic migration 正式流程
- [ ] 補齊更多 API 測試admin/internal success + fail cases
- [ ] 增加 request id / logging 格式
## 待辦(上線前)
- [ ] 加入 rate limit依 client 或 IP
- [ ] 針對 admin API 加 audit log授權/撤銷行為)
- [ ] 加上 CORS 白名單與反向代理配置驗證
- [ ] 製作部署腳本systemd / container
## 驗收條件
- [ ] 前端可完成 `/me` + `/me/permissions/snapshot` + grant/revoke 全流程
- [ ] 401/403/404/503 錯誤碼與文件一致
- [ ] 至少一輪 staging smoke test 通過

View File

@@ -1,33 +0,0 @@
# Frontend TaskPlan
## 目標
完成 member.ose.tw 前端Vue3 + JS + Vite + Element Plus + Tailwind可獨立完成登入、查看個人資料、查看權限、管理授權。
## 已完成(依目前程式)
- [x] Vite + Vue3 專案結構
- [x] Element Plus + Tailwind 基礎接入
- [x] Router 與頁面骨架
- [x] `LoginPage`token 輸入)
- [x] `MePage``GET /me`
- [x] `PermissionSnapshotPage``GET /me/permissions/snapshot`
- [x] `PermissionAdminPage`grant/revoke
- [x] Pinia storeauth + permission
- [x] Axios 分離 user/admin client
- [x] Production build 可通過
## 進行中(建議近期)
- [ ] 補路由守衛策略(是否限制 `/admin/permissions` 需登入)
- [ ] 錯誤訊息 i18n 與統一顯示格式
- [ ] 新增操作完成後自動刷新快照的 UX
## 待辦(上線前)
- [ ] 增加 e2e / UI smoke 測試
- [ ] 優化 bundle size目前 main chunk 偏大)
- [ ] 加入環境切換策略dev/staging/prod
- [ ] 加入登入來源與 token 取得說明頁
## 驗收條件
- [ ] 未登入時導向登入頁行為正確
- [ ] 登入後可穩定讀取 `/me` 與快照
- [ ] 管理頁 grant/revoke 成功與錯誤提示完整
- [ ] 與後端契約文件一致(`FRONTEND_API_CONTRACT.md`

109
docs/VPS_DEPLOY_RUNBOOK.md Normal file
View File

@@ -0,0 +1,109 @@
# VPS Deploy Runbook
## 1) 拉整合層 + 子模組
```bash
cd /opt
git clone --recurse-submodules http://127.0.0.1:8888/member/member-platform.git
cd member-platform
git submodule update --init --recursive
```
## 2) 後端部署Docker
```bash
cd /opt/member-platform/backend
cp .env.production .env
```
編輯 `.env`DB、Keycloak、Realm Roles、Cache
首次建表:
```bash
psql "postgresql://<user>:<pass>@<host>:<port>/<db>" -f scripts/init_schema.sql
```
啟動:
```bash
docker build -t memberapi-backend:latest .
docker rm -f memberapi-backend 2>/dev/null || true
docker run -d \
--name memberapi-backend \
--restart unless-stopped \
-p 127.0.0.1:8000:8000 \
--env-file .env \
memberapi-backend:latest
```
檢查:
```bash
curl http://127.0.0.1:8000/healthz
docker logs -f memberapi-backend
```
### 用 docker compose建議
Compose 檔案:[docker-compose.example.yml](../docker-compose.example.yml)
啟動:
```bash
cd /opt/member-platform
cp docker-compose.example.yml docker-compose.yml
docker compose up -d --build
```
檢查:
```bash
docker compose ps
docker compose logs -f backend
```
停止:
```bash
docker compose down
```
## 3) 前端部署Nginx
```bash
cd /opt/member-platform/frontend
```
production build 會自動讀取 `.env.production`,請先確認設定:
```env
VITE_API_BASE_URL=https://memberapi.ose.tw
```
Build
```bash
npm ci
npm run build
```
Nginx root 指向 `frontend/dist`,並加 SPA rewrite
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
## 4) 更新流程
```bash
cd /opt/member-platform
git pull
git submodule update --init --recursive --remote
```
後端更新:
```bash
cd backend
docker build -t memberapi-backend:latest .
docker rm -f memberapi-backend
docker run -d --name memberapi-backend --restart unless-stopped -p 127.0.0.1:8000:8000 --env-file .env memberapi-backend:latest
```
前端更新:
```bash
cd ../frontend
npm ci
npm run build
```
## 5) 建議網域
- Frontend: `member.ose.tw`
- API: `memberapi.ose.tw`(反代 `127.0.0.1:8000`

View File

@@ -0,0 +1,81 @@
-- Directus key auto-generation triggers
-- Target tables: companies, sites, users, roles
-- Key format: PREFIX + yyyymmdd + 'X' + 4 digits
-- Example: CP20260404X1234
BEGIN;
CREATE OR REPLACE FUNCTION public.directus_autogen_entity_key()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_column_name text := TG_ARGV[0];
v_prefix text := TG_ARGV[1];
v_current_value text;
v_candidate text;
v_exists boolean;
v_attempt int;
v_day text;
v_suffix text;
BEGIN
v_current_value := to_jsonb(NEW) ->> v_column_name;
IF v_current_value IS NOT NULL AND btrim(v_current_value) <> '' THEN
RETURN NEW;
END IF;
v_day := to_char(clock_timestamp(), 'YYYYMMDD');
FOR v_attempt IN 0..9999 LOOP
v_suffix := lpad((((extract(epoch FROM clock_timestamp()) * 1000)::bigint + v_attempt) % 10000)::text, 4, '0');
v_candidate := v_prefix || v_day || 'X' || v_suffix;
EXECUTE format(
'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I = $1)',
TG_TABLE_SCHEMA,
TG_TABLE_NAME,
v_column_name
) INTO v_exists USING v_candidate;
IF NOT v_exists THEN
NEW := jsonb_populate_record(NEW, jsonb_build_object(v_column_name, v_candidate));
RETURN NEW;
END IF;
END LOOP;
RAISE EXCEPTION 'Failed to generate unique key for %.% (column=%)', TG_TABLE_SCHEMA, TG_TABLE_NAME, v_column_name;
END;
$$;
DROP TRIGGER IF EXISTS trg_companies_key_autogen ON public.companies;
CREATE TRIGGER trg_companies_key_autogen
BEFORE INSERT ON public.companies
FOR EACH ROW
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'CP');
DROP TRIGGER IF EXISTS trg_sites_key_autogen ON public.sites;
CREATE TRIGGER trg_sites_key_autogen
BEFORE INSERT ON public.sites
FOR EACH ROW
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'ST');
DROP TRIGGER IF EXISTS trg_users_key_autogen ON public.users;
CREATE TRIGGER trg_users_key_autogen
BEFORE INSERT ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'UE');
DROP TRIGGER IF EXISTS trg_roles_key_autogen ON public.roles;
CREATE TRIGGER trg_roles_key_autogen
BEFORE INSERT ON public.roles
FOR EACH ROW
EXECUTE FUNCTION public.directus_autogen_entity_key('key', 'RL');
-- cleanup old trigger names to avoid duplicates from previous scripts
DROP TRIGGER IF EXISTS trg_companies_company_key_autogen ON public.companies;
DROP TRIGGER IF EXISTS trg_sites_site_key_autogen ON public.sites;
DROP TRIGGER IF EXISTS trg_users_user_key_autogen ON public.users;
DROP TRIGGER IF EXISTS trg_systems_system_key_autogen ON public.systems;
DROP TRIGGER IF EXISTS trg_roles_role_key_autogen ON public.roles;
COMMIT;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
{
"version": 1,
"directus": "11.0.0",
"vendor": "postgres",
"collections": [
{ "collection": "users", "meta": null, "schema": { "name": "users" } },
{ "collection": "companies", "meta": null, "schema": { "name": "companies" } },
{ "collection": "sites", "meta": null, "schema": { "name": "sites" } },
{ "collection": "systems", "meta": null, "schema": { "name": "systems" } },
{ "collection": "roles", "meta": null, "schema": { "name": "roles" } },
{ "collection": "site_roles", "meta": null, "schema": { "name": "site_roles" } },
{ "collection": "user_sites", "meta": null, "schema": { "name": "user_sites" } },
{ "collection": "auth_sync_state", "meta": null, "schema": { "name": "auth_sync_state" } },
{ "collection": "api_clients", "meta": null, "schema": { "name": "api_clients" } }
],
"fields": [
{ "collection": "users", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "has_auto_increment": false, "default_value": "gen_random_uuid()" } },
{ "collection": "users", "field": "user_sub", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
{ "collection": "users", "field": "provider_user_id", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
{ "collection": "users", "field": "username", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
{ "collection": "users", "field": "email", "type": "string", "meta": null, "schema": { "is_nullable": true, "is_unique": true } },
{ "collection": "users", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "users", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
{ "collection": "users", "field": "is_active", "type": "boolean", "meta": null, "schema": { "is_nullable": false, "default_value": true } },
{ "collection": "users", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "users", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "companies", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "companies", "field": "company_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
{ "collection": "companies", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "companies", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "companies", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
{ "collection": "companies", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "companies", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "sites", "field": "site_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
{ "collection": "sites", "field": "company_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "companies", "foreign_key_column": "id" } },
{ "collection": "sites", "field": "display_name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "sites", "field": "domain", "type": "string", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "sites", "field": "provider_group_id", "type": "string", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "sites", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
{ "collection": "sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "systems", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "systems", "field": "system_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
{ "collection": "systems", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "systems", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
{ "collection": "systems", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "systems", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "roles", "field": "role_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
{ "collection": "roles", "field": "role_code", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "roles", "field": "system_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "systems", "foreign_key_column": "id" } },
{ "collection": "roles", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "roles", "field": "description", "type": "text", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "roles", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
{ "collection": "roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "roles", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "site_roles", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "site_roles", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
{ "collection": "site_roles", "field": "role_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "roles", "foreign_key_column": "id" } },
{ "collection": "site_roles", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "user_sites", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "user_sites", "field": "user_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "users", "foreign_key_column": "id" } },
{ "collection": "user_sites", "field": "site_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "foreign_key_table": "sites", "foreign_key_column": "id" } },
{ "collection": "user_sites", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "user_sites", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "auth_sync_state", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "auth_sync_state", "field": "entity_type", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "auth_sync_state", "field": "entity_id", "type": "uuid", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "auth_sync_state", "field": "last_synced_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "auth_sync_state", "field": "source_version", "type": "string", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "auth_sync_state", "field": "last_error", "type": "text", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "auth_sync_state", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "api_clients", "field": "id", "type": "uuid", "meta": null, "schema": { "is_nullable": false, "is_primary_key": true, "default_value": "gen_random_uuid()" } },
{ "collection": "api_clients", "field": "client_key", "type": "string", "meta": null, "schema": { "is_nullable": false, "is_unique": true } },
{ "collection": "api_clients", "field": "name", "type": "string", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "api_clients", "field": "status", "type": "string", "meta": null, "schema": { "is_nullable": false, "default_value": "active" } },
{ "collection": "api_clients", "field": "api_key_hash", "type": "text", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "api_clients", "field": "allowed_origins", "type": "json", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "api_clients", "field": "allowed_ips", "type": "json", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "api_clients", "field": "allowed_paths", "type": "json", "meta": null, "schema": { "is_nullable": false } },
{ "collection": "api_clients", "field": "rate_limit_per_min", "type": "integer", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "api_clients", "field": "expires_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "api_clients", "field": "last_used_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": true } },
{ "collection": "api_clients", "field": "created_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } },
{ "collection": "api_clients", "field": "updated_at", "type": "timestamp", "meta": null, "schema": { "is_nullable": false, "default_value": "now()" } }
],
"relations": [
{
"collection": "sites",
"field": "company_id",
"related_collection": "companies",
"schema": {
"table": "sites",
"column": "company_id",
"foreign_key_table": "companies",
"foreign_key_column": "id",
"on_update": "NO ACTION",
"on_delete": "CASCADE"
},
"meta": null
},
{
"collection": "roles",
"field": "system_id",
"related_collection": "systems",
"schema": {
"table": "roles",
"column": "system_id",
"foreign_key_table": "systems",
"foreign_key_column": "id",
"on_update": "NO ACTION",
"on_delete": "CASCADE"
},
"meta": null
},
{
"collection": "site_roles",
"field": "site_id",
"related_collection": "sites",
"schema": {
"table": "site_roles",
"column": "site_id",
"foreign_key_table": "sites",
"foreign_key_column": "id",
"on_update": "NO ACTION",
"on_delete": "CASCADE"
},
"meta": null
},
{
"collection": "site_roles",
"field": "role_id",
"related_collection": "roles",
"schema": {
"table": "site_roles",
"column": "role_id",
"foreign_key_table": "roles",
"foreign_key_column": "id",
"on_update": "NO ACTION",
"on_delete": "CASCADE"
},
"meta": null
},
{
"collection": "user_sites",
"field": "user_id",
"related_collection": "users",
"schema": {
"table": "user_sites",
"column": "user_id",
"foreign_key_table": "users",
"foreign_key_column": "id",
"on_update": "NO ACTION",
"on_delete": "CASCADE"
},
"meta": null
},
{
"collection": "user_sites",
"field": "site_id",
"related_collection": "sites",
"schema": {
"table": "user_sites",
"column": "site_id",
"foreign_key_table": "sites",
"foreign_key_column": "id",
"on_update": "NO ACTION",
"on_delete": "CASCADE"
},
"meta": null
}
]
}

View File

@@ -1,22 +1,51 @@
# member docs index
# member-platform 文件入口(新架構)
## 先看這三份
1. `docs/FRONTEND_ARCHITECTURE.md`
2. `docs/FRONTEND_API_CONTRACT.md`
3. `docs/FRONTEND_IMPLEMENTATION_CHECKLIST.md`
## 閱讀順序
1. [docs/ARCHITECTURE.md](./ARCHITECTURE.md)
2. [docs/DB_SCHEMA.md](./DB_SCHEMA.md)
3. [docs/BACKEND_TASKPLAN.md](./BACKEND_TASKPLAN.md)
4. [docs/FRONTEND_TASKPLAN.md](./FRONTEND_TASKPLAN.md)
5. [docs/FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md)
6. [docs/INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)
7. [docs/LOCAL_DEV_RUNBOOK.md](./LOCAL_DEV_RUNBOOK.md)
8. [docs/VPS_DEPLOY_RUNBOOK.md](./VPS_DEPLOY_RUNBOOK.md)
## 系統架構與後端
- `docs/ARCHITECTURE_AND_CONFIG.md`
- `docs/BACKEND_ARCHITECTURE.md`
- `docs/BACKEND_BOOTSTRAP.md`
## 交辦順序(建議)
1. 先看 [ARCHITECTURE.md](./ARCHITECTURE.md) 鎖定資料模型與權限模型。
2. 再看 [DB_SCHEMA.md](./DB_SCHEMA.md) 對齊 table/欄位/關聯。
3. 後端依 [BACKEND_TASKPLAN.md](./BACKEND_TASKPLAN.md) 執行 schema/API/Keycloak 同步調整。
4. 前端依 [FRONTEND_TASKPLAN.md](./FRONTEND_TASKPLAN.md) + [FRONTEND_HANDOFF.md](./FRONTEND_HANDOFF.md) 開工。
5. 其他系統串接時看 [INTERNAL_API_HANDOFF.md](./INTERNAL_API_HANDOFF.md)。
## 任務管理
- `docs/TASKPLAN_FRONTEND.md`
- `docs/TASKPLAN_BACKEND.md`
## 目前狀態
- 架構定版:`Company -> Site``System -> Role`
- 權限定版:`Role` 只能指派給 `Site`(透過 `site_roles`)。
- 成員授權定版:`User` 不直接綁 `Role`,僅透過 `user_sites` 取得 Site 角色。
- IdP 定版Keycloak 為唯一 IdP。
- 系統定版:`System`/`Role` 由 Keycloak 管理member 後台僅同步與顯示。
- API 白名單:保留 `api_clients`
- 後端:新 schema 與 admin/internal API 已切到 role-site 模型。
- 前端:管理頁已切到新模型(公司/站台/系統/角色/會員/API Clients
## SQL 與配置
- `docs/API_CLIENTS_SQL.sql`
- `docs/DB_SCHEMA_SNAPSHOT.md`
## 單一真實來源
- DB SQL[backend/scripts/init_schema.sql](../backend/scripts/init_schema.sql)
## 給前端 AI 的一句話交接
請先完成 `/me``/me/permissions/snapshot``/admin/permissions/grant|revoke` 三組 API 對接,並依 `FRONTEND_IMPLEMENTATION_CHECKLIST.md` 逐項完成。
## Repo 結構(已拆分)
- 整合層(本 repo`member-platform`docs / 部署 / 整合)
- 後端子模組:[backend](../backend)submodule: `../member-backend`
- 前端子模組:[frontend](../frontend)submodule: `../member-frontend`
## 開發工作目錄(防呆)
- 只在 `member-platform` 內開發與提交。
- 後端只改 `member-platform/backend`
- 前端只改 `member-platform/frontend`
- 根目錄外的 `member-backend` / `member-frontend` 若有另一份 clone視為非主要工作副本避免混用。
## 提交順序(固定)
1. 先在 `member-platform/backend``member-platform/frontend` 各自 commit / push。
2. 再回 `member-platform` 根目錄提交子模組版本指標變更submodule pointer並 push。
3. 部署端只需要更新 `member-platform`,再執行 `git submodule update --init --recursive`
## 文件邊界
- 本輪只保留可開發、可交辦、可驗收文件。
- 最終規格白皮書延後到專案完成後再產出。

1
frontend Submodule

Submodule frontend added at ed63eaffc6

View File

@@ -1,2 +0,0 @@
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

View File

@@ -1,2 +0,0 @@
VITE_APP_TITLE=member.ose.tw (dev)
VITE_API_BASE_URL=http://127.0.0.1:8000

View File

@@ -1,3 +0,0 @@
# member.ose.tw frontend env
VITE_APP_TITLE=member.ose.tw
VITE_API_BASE_URL=https://memberapi.ose.tw

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>member.ose.tw</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
{
"name": "member-ose-tw",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.11"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@@ -1,51 +0,0 @@
<template>
<div class="min-h-screen bg-gray-50">
<nav v-if="!isLoginPage" class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shadow-sm">
<div class="flex items-center gap-6">
<span class="font-bold text-gray-800 text-base">member.ose.tw</span>
<router-link
to="/me"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
我的資料
</router-link>
<router-link
to="/me/permissions"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
我的權限
</router-link>
<router-link
to="/admin/permissions"
class="text-sm text-gray-600 hover:text-blue-600 transition-colors"
active-class="text-blue-600 font-medium"
>
權限管理
</router-link>
</div>
<el-button v-if="authStore.isLoggedIn" size="small" @click="logout">登出</el-button>
</nav>
<main class="p-6 max-w-4xl mx-auto">
<router-view />
</main>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isLoginPage = computed(() => route.name === 'login')
function logout() {
authStore.logout()
router.push('/login')
}
</script>

View File

@@ -1,4 +0,0 @@
import { userHttp } from './http'
export const loginWithPassword = (username, password) =>
userHttp.post('/auth/login', { username, password })

View File

@@ -1,37 +0,0 @@
import axios from 'axios'
import router from '@/router'
const BASE_URL = import.meta.env.VITE_API_BASE_URL
// 使用者 API帶 Bearer token
export const userHttp = axios.create({ baseURL: BASE_URL })
userHttp.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
return config
})
userHttp.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('access_token')
router.push('/login')
}
return Promise.reject(err)
}
)
// 管理員 API帶 X-Client-Key / X-API-Key
export const adminHttp = axios.create({ baseURL: BASE_URL })
adminHttp.interceptors.request.use(config => {
const clientKey = sessionStorage.getItem('admin_client_key')
const apiKey = sessionStorage.getItem('admin_api_key')
if (clientKey) config.headers['X-Client-Key'] = clientKey
if (apiKey) config.headers['X-API-Key'] = apiKey
return config
})

View File

@@ -1,4 +0,0 @@
import { userHttp } from './http'
export const getMe = () => userHttp.get('/me')
export const getMyPermissionSnapshot = () => userHttp.get('/me/permissions/snapshot')

View File

@@ -1,4 +0,0 @@
import { adminHttp } from './http'
export const grantPermission = (data) => adminHttp.post('/admin/permissions/grant', data)
export const revokePermission = (data) => adminHttp.post('/admin/permissions/revoke', data)

View File

@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,21 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhTw from 'element-plus/es/locale/lang/zh-tw'
import App from './App.vue'
import router from './router'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhTw })
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

View File

@@ -1,95 +0,0 @@
<template>
<div class="flex items-center justify-center min-h-[70vh]">
<el-card class="w-full max-w-md shadow-md">
<template #header>
<div class="text-center">
<h1 class="text-xl font-bold text-gray-800">member.ose.tw</h1>
<p class="text-sm text-gray-500 mt-1">使用 Authentik 帳號密碼登入</p>
</div>
</template>
<el-form @submit.prevent="handleLogin">
<el-form-item label="帳號">
<el-input
v-model="username"
placeholder="請輸入 Authentik username / email"
clearable
/>
</el-form-item>
<el-form-item label="密碼">
<el-input
v-model="password"
type="password"
placeholder="請輸入密碼"
clearable
show-password
/>
</el-form-item>
<el-alert
v-if="error"
:title="error"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="primary"
native-type="submit"
class="w-full"
:loading="loading"
:disabled="!username.trim() || !password.trim()"
>
登入
</el-button>
</el-form-item>
</el-form>
<p class="text-xs text-gray-400 text-center mt-2">登入成功後 access token 會存於本機 localStorage</p>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { loginWithPassword } from '@/api/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
async function handleLogin() {
if (!username.value.trim() || !password.value.trim()) return
loading.value = true
error.value = ''
try {
const loginRes = await loginWithPassword(username.value.trim(), password.value)
authStore.setToken(loginRes.data.access_token)
await authStore.fetchMe()
const redirect = route.query.redirect || '/me'
router.push(redirect)
} catch (err) {
authStore.logout()
const detail = err.response?.data?.detail
if (detail === 'invalid_username_or_password') {
error.value = '帳號或密碼錯誤'
} else if (detail === 'authentik_login_not_configured') {
error.value = '後端尚未設定 Authentik 登入參數'
} else {
error.value = '登入失敗,請稍後再試'
}
} finally {
loading.value = false
}
}
</script>

View File

@@ -1,315 +0,0 @@
<template>
<div>
<h2 class="text-xl font-bold text-gray-800 mb-6">權限管理</h2>
<!-- 認證設定 -->
<el-card class="mb-6 shadow-sm">
<template #header>
<div class="flex items-center justify-between">
<span class="font-medium text-gray-700">管理員認證</span>
<el-tag v-if="credsSaved" type="success" size="small">已儲存session</el-tag>
<el-tag v-else type="warning" size="small">未設定</el-tag>
</div>
</template>
<el-form :model="credsForm" inline>
<el-form-item label="X-Client-Key">
<el-input
v-model="credsForm.clientKey"
placeholder="client key"
style="width: 220px"
show-password
/>
</el-form-item>
<el-form-item label="X-API-Key">
<el-input
v-model="credsForm.apiKey"
placeholder="api key"
style="width: 220px"
show-password
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveCreds">儲存認證</el-button>
<el-button v-if="credsSaved" @click="clearCreds" class="ml-2">清除</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- Grant / Revoke -->
<el-tabs v-model="activeTab" type="border-card" class="shadow-sm">
<!-- Grant Tab -->
<el-tab-pane label="Grant 授權" name="grant">
<el-form
ref="grantFormRef"
:model="grantForm"
:rules="grantRules"
label-width="130px"
class="max-w-xl mt-4"
@submit.prevent="handleGrant"
>
<el-form-item label="Authentik Sub" prop="authentik_sub">
<el-input v-model="grantForm.authentik_sub" placeholder="authentik-sub-xxx" />
</el-form-item>
<el-form-item label="Email" prop="email">
<el-input v-model="grantForm.email" placeholder="user@example.com" />
</el-form-item>
<el-form-item label="顯示名稱" prop="display_name">
<el-input v-model="grantForm.display_name" placeholder="User Name" />
</el-form-item>
<el-form-item label="Scope 類型" prop="scope_type">
<el-input v-model="grantForm.scope_type" placeholder="site" />
</el-form-item>
<el-form-item label="Scope ID" prop="scope_id">
<el-input v-model="grantForm.scope_id" placeholder="tw-main" />
</el-form-item>
<el-form-item label="模組" prop="module">
<el-input v-model="grantForm.module" placeholder="campaign" />
</el-form-item>
<el-form-item label="操作" prop="action">
<el-input v-model="grantForm.action" placeholder="view" />
</el-form-item>
<el-alert
v-if="grantError"
:title="grantError"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="grantSuccess"
:title="grantSuccess"
type="success"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="primary"
native-type="submit"
:loading="grantLoading"
:disabled="!credsSaved"
>
Grant 授權
</el-button>
<el-button @click="resetGrant">清除</el-button>
</el-form-item>
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
</el-form>
</el-tab-pane>
<!-- Revoke Tab -->
<el-tab-pane label="Revoke 撤銷" name="revoke">
<el-form
ref="revokeFormRef"
:model="revokeForm"
:rules="revokeRules"
label-width="130px"
class="max-w-xl mt-4"
@submit.prevent="handleRevoke"
>
<el-form-item label="Authentik Sub" prop="authentik_sub">
<el-input v-model="revokeForm.authentik_sub" placeholder="authentik-sub-xxx" />
</el-form-item>
<el-form-item label="Scope 類型" prop="scope_type">
<el-input v-model="revokeForm.scope_type" placeholder="site" />
</el-form-item>
<el-form-item label="Scope ID" prop="scope_id">
<el-input v-model="revokeForm.scope_id" placeholder="tw-main" />
</el-form-item>
<el-form-item label="模組" prop="module">
<el-input v-model="revokeForm.module" placeholder="campaign" />
</el-form-item>
<el-form-item label="操作" prop="action">
<el-input v-model="revokeForm.action" placeholder="view" />
</el-form-item>
<el-alert
v-if="revokeError"
:title="revokeError"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-alert
v-if="revokeSuccess"
:title="revokeSuccess"
type="success"
show-icon
:closable="false"
class="mb-4"
/>
<el-form-item>
<el-button
type="danger"
native-type="submit"
:loading="revokeLoading"
:disabled="!credsSaved"
>
Revoke 撤銷
</el-button>
<el-button @click="resetRevoke">清除</el-button>
</el-form-item>
<p v-if="!credsSaved" class="text-xs text-yellow-600 ml-2">請先設定管理員認證</p>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { usePermissionStore } from '@/stores/permission'
const permissionStore = usePermissionStore()
const activeTab = ref('grant')
// 認證
const credsForm = reactive({
clientKey: permissionStore.adminClientKey,
apiKey: permissionStore.adminApiKey
})
const credsSaved = computed(() => permissionStore.hasAdminCreds())
function saveCreds() {
if (!credsForm.clientKey || !credsForm.apiKey) {
ElMessage.warning('請填寫完整認證')
return
}
permissionStore.setAdminCreds(credsForm.clientKey, credsForm.apiKey)
ElMessage.success('認證已儲存session')
}
function clearCreds() {
permissionStore.clearAdminCreds()
credsForm.clientKey = ''
credsForm.apiKey = ''
ElMessage.info('認證已清除')
}
// Grant
const grantFormRef = ref()
const grantLoading = ref(false)
const grantError = ref('')
const grantSuccess = ref('')
const grantForm = reactive({
authentik_sub: '',
email: '',
display_name: '',
scope_type: '',
scope_id: '',
module: '',
action: ''
})
const required = { required: true, message: '必填', trigger: 'blur' }
const grantRules = {
authentik_sub: [required],
email: [required],
display_name: [required],
scope_type: [required],
scope_id: [required],
module: [required],
action: [required]
}
async function handleGrant() {
const valid = await grantFormRef.value.validate().catch(() => false)
if (!valid) return
grantLoading.value = true
grantError.value = ''
grantSuccess.value = ''
try {
const result = await permissionStore.grant({ ...grantForm })
grantSuccess.value = `授權成功ID: ${result.permission_id}`
ElMessage.success('Grant 成功')
} catch (err) {
grantError.value = formatAdminError(err)
} finally {
grantLoading.value = false
}
}
function resetGrant() {
grantFormRef.value?.resetFields()
grantError.value = ''
grantSuccess.value = ''
}
// Revoke
const revokeFormRef = ref()
const revokeLoading = ref(false)
const revokeError = ref('')
const revokeSuccess = ref('')
const revokeForm = reactive({
authentik_sub: '',
scope_type: '',
scope_id: '',
module: '',
action: ''
})
const revokeRules = {
authentik_sub: [required],
scope_type: [required],
scope_id: [required],
module: [required],
action: [required]
}
async function handleRevoke() {
const valid = await revokeFormRef.value.validate().catch(() => false)
if (!valid) return
revokeLoading.value = true
revokeError.value = ''
revokeSuccess.value = ''
try {
const result = await permissionStore.revoke({ ...revokeForm })
revokeSuccess.value = `撤銷成功(共刪除 ${result.deleted} 筆)`
ElMessage.success('Revoke 成功')
} catch (err) {
revokeError.value = formatAdminError(err)
} finally {
revokeLoading.value = false
}
}
function resetRevoke() {
revokeFormRef.value?.resetFields()
revokeError.value = ''
revokeSuccess.value = ''
}
function formatAdminError(err) {
const status = err.response?.status
const detail = err.response?.data?.detail
const map = {
invalid_client: '無效的 Client Key',
invalid_api_key: '無效的 API Key',
client_expired: 'Client 已過期',
origin_not_allowed: '來源 Origin 不允許',
ip_not_allowed: 'IP 不在白名單',
path_not_allowed: '路徑不允許',
internal_secret_not_configured: '後端設定缺失internal secret',
authentik_admin_not_configured: '後端設定缺失authentik admin',
user_not_found: '找不到該使用者'
}
if (detail && map[detail]) return map[detail]
if (detail) return `錯誤:${detail}`
if (status === 401) return '認證失敗,請檢查 Client Key / API Key'
if (status === 403) return '存取被拒IP 或 Origin 限制)'
if (status === 404) return '找不到該使用者'
if (status === 503) return '後端設定不完整,請聯絡管理員'
return '操作失敗,請稍後再試'
}
</script>

View File

@@ -1,80 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">我的權限快照</h2>
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
</div>
<el-alert
v-if="error"
:title="errorMessage"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-skeleton v-if="loading && !snapshot" :rows="4" animated />
<template v-if="snapshot">
<p class="text-sm text-gray-500 mb-3">
Sub<span class="font-mono">{{ snapshot.authentik_sub }}</span>
</p>
<el-empty
v-if="snapshot.permissions.length === 0"
description="目前沒有任何權限"
/>
<el-table
v-else
:data="snapshot.permissions"
stripe
border
class="w-full shadow-sm"
>
<el-table-column prop="scope_type" label="Scope 類型" width="130" />
<el-table-column prop="scope_id" label="Scope ID" min-width="160" />
<el-table-column prop="module" label="模組" width="140" />
<el-table-column prop="action" label="操作" width="100" />
</el-table>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { usePermissionStore } from '@/stores/permission'
const permissionStore = usePermissionStore()
const snapshot = ref(null)
const loading = ref(false)
const error = ref(null)
const errorMessage = ref('')
async function load() {
loading.value = true
error.value = null
try {
await permissionStore.fetchMySnapshot()
snapshot.value = permissionStore.snapshot
} catch (err) {
error.value = err
const status = err.response?.status
const detail = err.response?.data?.detail
if (status === 401) {
errorMessage.value = 'Token 已過期,請重新登入'
} else if (detail) {
errorMessage.value = `錯誤:${detail}`
} else {
errorMessage.value = '載入失敗,請稍後再試'
}
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -1,71 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-gray-800">我的資料</h2>
<el-button :loading="loading" @click="load" :icon="Refresh" size="small">重新整理</el-button>
</div>
<el-alert
v-if="error"
:title="errorMessage"
type="error"
show-icon
:closable="false"
class="mb-4"
/>
<el-skeleton v-if="loading && !me" :rows="3" animated />
<el-card v-if="me && !loading" class="shadow-sm">
<el-descriptions :column="1" border>
<el-descriptions-item label="Sub">
<span class="font-mono text-sm text-gray-700">{{ me.sub }}</span>
</el-descriptions-item>
<el-descriptions-item label="Email">
{{ me.email }}
</el-descriptions-item>
<el-descriptions-item label="顯示名稱">
{{ me.display_name }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const me = ref(null)
const loading = ref(false)
const error = ref(null)
const errorMessage = ref('')
async function load() {
loading.value = true
error.value = null
try {
await authStore.fetchMe()
me.value = authStore.me
} catch (err) {
error.value = err
const status = err.response?.status
const detail = err.response?.data?.detail
if (status === 401) {
errorMessage.value = 'Token 已過期,請重新登入'
} else if (detail) {
errorMessage.value = `錯誤:${detail}`
} else {
errorMessage.value = '載入失敗,請稍後再試'
}
} finally {
loading.value = false
}
}
onMounted(load)
</script>

View File

@@ -1,42 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{ path: '/', redirect: '/me' },
{
path: '/login',
name: 'login',
component: () => import('@/pages/LoginPage.vue')
},
{
path: '/me',
name: 'me',
component: () => import('@/pages/profile/MePage.vue'),
meta: { requiresAuth: true }
},
{
path: '/me/permissions',
name: 'my-permissions',
component: () => import('@/pages/permissions/PermissionSnapshotPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/permissions',
name: 'admin-permissions',
component: () => import('@/pages/permissions/PermissionAdminPage.vue')
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach((to) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
})
export default router

View File

@@ -1,29 +0,0 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getMe } from '@/api/me'
export const useAuthStore = defineStore('auth', () => {
const accessToken = ref(localStorage.getItem('access_token') || null)
const me = ref(null)
const isLoggedIn = computed(() => !!accessToken.value)
function setToken(token) {
accessToken.value = token
localStorage.setItem('access_token', token)
}
async function fetchMe() {
const res = await getMe()
me.value = res.data
return res.data
}
function logout() {
accessToken.value = null
me.value = null
localStorage.removeItem('access_token')
}
return { accessToken, me, isLoggedIn, setToken, fetchMe, logout }
})

View File

@@ -1,54 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getMyPermissionSnapshot } from '@/api/me'
import { grantPermission, revokePermission } from '@/api/permission-admin'
export const usePermissionStore = defineStore('permission', () => {
const snapshot = ref(null)
const adminClientKey = ref(sessionStorage.getItem('admin_client_key') || '')
const adminApiKey = ref(sessionStorage.getItem('admin_api_key') || '')
const hasAdminCreds = () => !!(adminClientKey.value && adminApiKey.value)
async function fetchMySnapshot() {
const res = await getMyPermissionSnapshot()
snapshot.value = res.data
return res.data
}
function setAdminCreds(clientKey, apiKey) {
adminClientKey.value = clientKey
adminApiKey.value = apiKey
sessionStorage.setItem('admin_client_key', clientKey)
sessionStorage.setItem('admin_api_key', apiKey)
}
function clearAdminCreds() {
adminClientKey.value = ''
adminApiKey.value = ''
sessionStorage.removeItem('admin_client_key')
sessionStorage.removeItem('admin_api_key')
}
async function grant(data) {
const res = await grantPermission(data)
return res.data
}
async function revoke(data) {
const res = await revokePermission(data)
return res.data
}
return {
snapshot,
adminClientKey,
adminApiKey,
hasAdminCreds,
fetchMySnapshot,
setAdminCreds,
clearAdminCreds,
grant,
revoke
}
})

View File

@@ -1,8 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js}'],
theme: {
extend: {}
},
plugins: []
}

View File

@@ -1,12 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

44
scripts/push-backend.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -lt 1 ]; then
echo "用法: scripts/push-backend.sh \"後端 commit 訊息\" [根目錄 commit 訊息]"
exit 1
fi
SUB_MSG="$1"
ROOT_MSG="${2:-chore: bump backend submodule}"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SUB_DIR="$ROOT_DIR/backend"
current_branch="$(git -C "$SUB_DIR" branch --show-current)"
if [ -z "$current_branch" ]; then
echo "backend 目前是 detached HEAD請先執行: cd backend && git checkout master"
exit 1
fi
if [ "$current_branch" != "master" ]; then
echo "backend 目前分支是 $current_branch,建議先切到 master 再執行。"
exit 1
fi
if [ -n "$(git -C "$SUB_DIR" status --porcelain)" ]; then
git -C "$SUB_DIR" add -A
git -C "$SUB_DIR" commit -m "$SUB_MSG"
else
echo "backend 無本地變更,略過 backend commit。"
fi
git -C "$SUB_DIR" push origin master
git -C "$ROOT_DIR" add backend
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
git -C "$ROOT_DIR" commit -m "$ROOT_MSG"
else
echo "根目錄無 submodule pointer 變更,略過根目錄 commit。"
fi
git -C "$ROOT_DIR" push origin master
echo "完成: backend 與根目錄已 push。"

44
scripts/push-frontend.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -lt 1 ]; then
echo "用法: scripts/push-frontend.sh \"前端 commit 訊息\" [根目錄 commit 訊息]"
exit 1
fi
SUB_MSG="$1"
ROOT_MSG="${2:-chore: bump frontend submodule}"
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SUB_DIR="$ROOT_DIR/frontend"
current_branch="$(git -C "$SUB_DIR" branch --show-current)"
if [ -z "$current_branch" ]; then
echo "frontend 目前是 detached HEAD請先執行: cd frontend && git checkout master"
exit 1
fi
if [ "$current_branch" != "master" ]; then
echo "frontend 目前分支是 $current_branch,建議先切到 master 再執行。"
exit 1
fi
if [ -n "$(git -C "$SUB_DIR" status --porcelain)" ]; then
git -C "$SUB_DIR" add -A
git -C "$SUB_DIR" commit -m "$SUB_MSG"
else
echo "frontend 無本地變更,略過 frontend commit。"
fi
git -C "$SUB_DIR" push origin master
git -C "$ROOT_DIR" add frontend
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
git -C "$ROOT_DIR" commit -m "$ROOT_MSG"
else
echo "根目錄無 submodule pointer 變更,略過根目錄 commit。"
fi
git -C "$ROOT_DIR" push origin master
echo "完成: frontend 與根目錄已 push。"