Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ PORT=4000

# Database
DATABASE_URL="postgresql://使用者:密碼@localhost:5432/你的資料庫名"

# E2E 測試專用資料庫(本機、可拋棄)。
# `pnpm test:e2e` 只會連這條,且會清空整個 DB,所以:
# - 必須指向本機 Postgres(localhost / 127.0.0.1),否則 e2e 會直接中止報錯
# - 絕對不要填遠端 / 正式 DB 的連線字串
# - 先建立一個空的測試資料庫:createdb form_platform_test
TEST_DATABASE_URL="postgresql://使用者:密碼@localhost:5432/form_platform_test"
JWT_SECRET="dev-secret-change-me"
CLIENT_URL="http://localhost:3000"

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

# Prisma Client
/generated/prisma

# secret backups — never track
*.env.bak
.env.bak.*
.env.bak
94 changes: 94 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# AGENTS.md

This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.

## Commands

```bash
# Install dependencies
pnpm install

# Development (watch mode)
pnpm start:dev

# Build
pnpm build

# Lint (auto-fix)
pnpm lint

# Unit tests
pnpm test

# Run a single test file
pnpm test -- --testPathPattern=activity

# E2E tests
pnpm test:e2e

# Database migrations
npx prisma migrate dev

# Seed database
npx prisma db seed

# Generate Prisma client (after schema changes)
npx prisma generate
```

## Environment Variables

Required in `.env`:
- `DATABASE_URL` — PostgreSQL connection string
- `JWT_SECRET` — Secret for signing JWTs
- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_REDIRECT_URI` — GitHub OAuth app credentials
- `CLIENT_URL` — Frontend origin for CORS and OAuth redirect (default: `http://localhost:3000`)
- `PORT` — Server port (default: `4000`)

## Architecture

This is a NestJS + Prisma + PostgreSQL API server for a conference CFP (Call for Papers) platform.

**Global setup (`src/main.ts`):** The app runs on `api/v1` prefix. `JwtAuthGuard` and `PermissionGuard` are applied globally — all routes require a valid JWT unless decorated with `@Public()`.

**Auth flow (`src/auth/`):**
- GitHub OAuth via the `arctic` library. Callback sets an `access_token` httpOnly cookie containing a JWT.
- JWT only carries `{ sub: memberId, v: tokenVersion }` — **no permissions in the token**. Permissions are stored in an in-memory cache (`@nestjs/cache-manager`) and loaded by `JwtStrategy.validate()` on every request.
- Cache miss fallback: if permissions aren't cached, `JwtStrategy` queries the database and populates the cache.
- `POST /auth/dev-login` is available in non-production for quick local testing without GitHub OAuth.

**Scoped RBAC (`src/auth/`):**
- Three permission scopes: `PLATFORM` (global), `ORG` (organization-level), `EVENT` (activity-level).
- `AuthService.getScopedPermissions()` returns `{ platform: string[], org: Record<orgId, string[]>, event: Record<eventId, string[]> }`.
- **ORG permissions cascade to all events under that organization** — the guard resolves `activity → organization` in real-time and merges org permissions into the event scope.
- To mark a route public: use the `@Public()` decorator.
- To require permissions in a specific scope: use `@RequirePermissions({ scope: "EVENT", param: "activityId", perms: ["event:edit"] })`.
- Legacy `@Permissions('permission:code')` still works — treated as `PLATFORM` scope.

**Permission invalidation:**
- Call `AuthService.bumpTokenVersion(memberId)` to revoke all tokens for a user. This increments `tokenVersion`, invalidates the cache, and revokes all refresh tokens. Existing JWTs immediately receive 401.

**Permission codes (seed):**
- `org:profile`, `org:finance`, `org:member:manage`, `org:report`
- `event:edit`, `event:registration:read`, `event:checkin`, `event:order:manage`
- `activity:manage`, `permission:manage`, `role:manage` (platform)

**Permission model:** `Member` → `Membership` (join with scope) → `Role` → `RolePermission` (join) → `Permission`.
- `Membership` includes `scopeType` (PLATFORM | ORG | EVENT) and optional `organizationId` / `activityId` FKs.
- Permissions use colon-namespaced codes (e.g., `event:edit`). Seed creates scoped roles:
- PLATFORM: `admin` (all platform permissions)
- ORG: `Owner`, `Admin`, `Accountant`
- EVENT: `Admin`, `Creator`, `Accountant`, `Checkin`, `Streaming`

**Prisma setup:**
- Schema is split across `prisma/models/*.prisma` files; `prisma.config.ts` points Prisma at the `prisma/` directory.
- Generated client lives in `generated/prisma/` (not `node_modules`).
- Uses the `@prisma/adapter-pg` driver adapter (connection pool via `pg`).
- All IDs are UUIDv7 strings. Use the `withId()` helper from `src/common/utils/db.util.ts` when creating records.

**Activity module (`src/activity/`):** Demonstrates the standard pattern:
- Public route: `GET /activities/slug/:slug` — uses `@Public()`, returns only `supportedLanguages`-filtered contents.
- Admin routes: require `activity:manage` permission via `@Permissions(...)`.
- `ActivityContent` is the i18n table; each `Activity` has multiple `ActivityContent` rows keyed by `lang`.

**Swagger:** Available at `/docs` in development.
27 changes: 24 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,32 @@ This is a NestJS + Prisma + PostgreSQL API server for a conference CFP (Call for

**Auth flow (`src/auth/`):**
- GitHub OAuth via the `arctic` library. Callback sets an `access_token` httpOnly cookie containing a JWT.
- JWT payload carries `{ sub: memberId, email, permissions: string[] }`. Permissions are resolved at login time by walking `Member → MemberRole → Role → RolePermission → Permission`.
- JWT only carries `{ sub: memberId, v: tokenVersion }` — **no permissions in the token**. Permissions are stored in an in-memory cache (`@nestjs/cache-manager`) and loaded by `JwtStrategy.validate()` on every request.
- Cache miss fallback: if permissions aren't cached, `JwtStrategy` queries the database and populates the cache.
- `POST /auth/dev-login` is available in non-production for quick local testing without GitHub OAuth.
- To mark a route public: use the `@Public()` decorator. To require a permission: use `@Permissions('permission:code')`.

**Permission model (`prisma/models/role.prisma`):** `Member` → `MemberRole` (join) → `Role` → `RolePermission` (join) → `Permission`. Permissions use colon-namespaced codes (e.g., `activity:manage`). Seed creates an `admin` role with all permissions.
**Scoped RBAC (`src/auth/`):**
- Three permission scopes: `PLATFORM` (global), `ORG` (organization-level), `EVENT` (activity-level).
- `AuthService.getScopedPermissions()` returns `{ platform: string[], org: Record<orgId, string[]>, event: Record<eventId, string[]> }`.
- **ORG permissions cascade to all events under that organization** — the guard resolves `activity → organization` in real-time and merges org permissions into the event scope.
- To mark a route public: use the `@Public()` decorator.
- To require permissions in a specific scope: use `@RequirePermissions({ scope: "EVENT", param: "activityId", perms: ["event:edit"] })`.
- Legacy `@Permissions('permission:code')` still works — treated as `PLATFORM` scope.

**Permission invalidation:**
- Call `AuthService.bumpTokenVersion(memberId)` to revoke all tokens for a user. This increments `tokenVersion`, invalidates the cache, and revokes all refresh tokens. Existing JWTs immediately receive 401.

**Permission codes (seed):**
- `org:profile`, `org:finance`, `org:member:manage`, `org:report`
- `event:edit`, `event:registration:read`, `event:checkin`, `event:order:manage`
- `activity:manage`, `permission:manage`, `role:manage` (platform)

**Permission model:** `Member` → `Membership` (join with scope) → `Role` → `RolePermission` (join) → `Permission`.
- `Membership` includes `scopeType` (PLATFORM | ORG | EVENT) and optional `organizationId` / `activityId` FKs.
- Permissions use colon-namespaced codes (e.g., `event:edit`). Seed creates scoped roles:
- PLATFORM: `admin` (all platform permissions)
- ORG: `Owner`, `Admin`, `Accountant`
- EVENT: `Admin`, `Creator`, `Accountant`, `Checkin`, `Streaming`

**Prisma setup:**
- Schema is split across `prisma/models/*.prisma` files; `prisma.config.ts` points Prisma at the `prisma/` directory.
Expand Down
74 changes: 74 additions & 0 deletions HANDOFF-permission-admin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# HANDOFF — 權限管理後台(前端)

> 給接手繼續開發的人。自足文件。
> 建立:2026-07-25。對照後端 RBAC 交接見 [`HANDOFF.md`](HANDOFF.md)、[`progress.md`](progress.md)、KKTIX 調研見 [`docs/rbac-kktix-research.md`](docs/rbac-kktix-research.md)。

## 一句話

後端的「參考 KKTIX」scoped RBAC(PLATFORM / ORG / EVENT + Email 邀請)**已完成並有測試**;前端 admin 只做完了**組織層的角色指派**,活動層與邀請 UI 尚未做。這份文件記錄實測到的進度與待辦。

---

## 目前進度(2026-07-25 實測)

前端 `web/`(Vite + React + shadcn/ui + react-router),登入用 dev-login(`yale@agent.local` 是唯一有 `permission:manage` 的帳號)。

| 頁面 / 分頁 | 後端端點 | 前端 | 實測狀態 |
|---|---|---|---|
| 成員管理 `/members` | `GET/POST/PATCH/DELETE /members` | ✅ | 列表、CRUD 正常 |
| 角色 `/permissions`(預設) | `GET/POST/PATCH/DELETE /roles` | ✅ | 9 個角色 + 權限勾選正常 |
| 權限碼 `/permissions/permissions` | `GET/POST/PATCH/DELETE /permissions` | ✅ | 14 筆權限碼正常 |
| **組織成員** `/permissions/org-members` | `GET /members/organization/:orgId`、`PUT /organizations/:orgId/members/:memberId/roles` | ✅ | **端到端驗過**:建組織→選組織→列成員→管理角色→儲存→DB 確認寫入 |
| **成員** `/permissions/members` | (同組織成員) | ❌ | **placeholder 空殼**(`members-coming-soon.tsx`),且承諾的功能與「組織成員」重複 |
| **活動成員(EVENT scope)** | `GET/PUT /activities/:activityId/members/:memberId/roles`(後端已有) | ❌ | **前端完全沒做** |
| **Email 邀請** | 見下方端點清單(後端完整) | ❌ | **前端完全沒做**(一個畫面都沒有) |

**判斷**:當「後端 API + 權限引擎」交付可以;當「給人用的 KKTIX-like 權限後台」交付**還不行**,缺活動層 + 邀請兩塊硬功能。

---

## 待辦 TODO(依優先序)

### 高 — 補齊 KKTIX 兩層裡缺的活動層
- [ ] **活動成員角色指派頁**。後端端點已就緒(`GET/PUT /activities/:activityId/members/:memberId/roles`,需 `event:member:manage`)。可直接照 `web/src/pages/permissions/org-members-page.tsx` 的結構複製一份,把 org 換成 activity、角色 catalog 抓 `EVENT` scope(`listRoleCatalog("EVENT")`)。
- [ ] 活動下拉的資料來源:目前前端沒有活動列表 API 的封裝,需確認 `GET /activities`(或等價端點)並加 `web/src/lib/` 封裝。

### 高 — 補齊 KKTIX 招牌的邀請流程 UI
- [ ] **邀請管理頁**(組織 + 活動)。後端端點:
- `GET /organizations/:orgId/invitations`、`POST /organizations/:orgId/invitations`、`POST /organizations/:orgId/invitations/:id/resend`
- `GET /activities/:activityId/invitations`、`POST …`、`POST …/:id/resend`
- `POST /invitations/accept`、`POST /invitations/reject`(受邀者端,帶 token)
- [ ] 邀請狀態機 UI:PENDING / ACCEPTED / EXPIRED(7 天)等狀態呈現,見調研文件第 4 節。

### 中 — 清理與收尾
- [ ] **移除重複的「成員」placeholder 分頁**(`web/src/pages/permissions/members-coming-soon.tsx` + router `permissions/members`)。它承諾的東西「組織成員」已做完,留著只會誤導。
- [ ] **user-menu 顯示錯誤**:登入後左下角仍顯示「訪客 guest@example.com」,未反映實際登入者。需接 `/auth/me`(或等價)把當前使用者帶進 `user-menu.tsx`。router 註解也提到「route guard can be added once /auth/me is wired up」。
- [ ] **cascade 產品決策**(見 `HANDOFF.md` 第 49 點):seed 的 ORG `Owner`/`Admin` 只含 `org:*`、不含 event 碼,所以預設 org admin 無法 cascade 管活動。要符合 KKTIX「組織管理員可管旗下活動」需在 ORG 角色加 event 權限碼。這是產品決定,非純工程。

---

## 已知行為(不是 bug,記下來免得誤判)

- **改自己的角色會被登出**。改角色 → 後端 `bumpTokenVersion(memberId)`(`src/rbac/membership.service.ts:113`)使舊 JWT 失效 → 前端攔截器 401 → 踢回登入。這是正確的權限即時失效機制;只是自己改自己時體感像閃退。改別人不會。
- **組織成員頁「你目前不屬於任何組織」**:當登入者不屬於任何組織時的正常空狀態,不是壞掉。建組織後即消失。

---

## 本機怎麼跑

```bash
# 後端(讀本機 dev DB,見下)
cd form-platform-server && pnpm start:dev # :4000

# 前端
cd form-platform-server/web && pnpm dev # :3000(被佔會往上找,本次實測在 :3003)
```

- 前端 API base 走**相對路徑 + vite proxy**(`web/.env` 的 `VITE_API_BASE_URL` 已註解掉),所以前端跑在哪個 port 都能連後端,不吃 CORS。若要直打後端絕對網址,必須同步把該 port 加進後端 `.env` 的 `CLIENT_URL`。
- 登入:`/login` 下半部 dev-login,email 填 `yale@agent.local`(唯一有 `permission:manage`)。GitHub 按鈕目前不能用(`GITHUB_CLIENT_ID` 為空,未設 OAuth app),dev-login 才是可用路徑。

## 資料庫

- 本機開發已改用 **local `form_platform_dev`**(`DATABASE_URL` 指 localhost;原雲端 Zeabur 那條保留成 `.env` 裡的註解 `# PROD_DATABASE_URL`)。
- e2e 用 **local `form_platform_test`**(`TEST_DATABASE_URL`),`test/jest-e2e.setup.ts` 會 abort 任何非 localhost 主機——已實測負向測試(缺變數 / 遠端主機都會擋)。
- dev DB 目前種子:14 permissions + 9 roles + `yale@agent.local`(PLATFORM admin) + 測試組織「COSCUP 測試組織」(yale=Owner+Accountant)。**沒有活動資料**,所以活動層功能要先建 activity 才驗得動。
55 changes: 55 additions & 0 deletions HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Handoff — Scoped RBAC + 成員邀請(參考 KKTIX)

> 日期:2026-06-19 / 分支:`feature/authentication`

## 背景
`form-platform-server`(NestJS + Prisma + PostgreSQL,conference CFP 平台)。PM 要求權限系統「參考 KKTIX」。本次把原本的**全域扁平 RBAC** 改造為 **兩層 scope(PLATFORM / ORG / EVENT)的 scoped RBAC + Email 邀請流程**。功能已完成並通過實機驗證。

## 設計核心
1. **scopeType 三層**:`PLATFORM`(平台運營者,相容舊全域角色)、`ORG`、`EVENT`。
2. **`Membership` 取代 `MemberRole`**:成員資格 = (member, scope, role);scope 用 `scopeType` + 可空的 `organizationId` / `activityId` FK。
3. **權限 scoped 結構**:`getScopedPermissions()` 回傳 `{ platform: string[], org: Record<orgId,string[]>, event: Record<actId,string[]> }`,存進記憶體 cache。JWT 仍只帶 `{ sub, v }`(沿用既有 TokenVersion 機制)。
4. **Guard cascade**:`@RequirePermissions({ scope, param, perms })` 從路由 param 取 scopeId;PLATFORM 全域生效;**ORG 權限向下 cascade 到該 org 旗下所有 EVENT**(guard 即時查 activity→org,並快取 `act_org:<id>`)。

## 關鍵檔案
| 區域 | 檔案 |
|------|------|
| Schema | `prisma/models/{organization,membership,invitation,role,member,activity}.prisma` |
| Migration | `prisma/migrations/20260619162429_add_scoped_rbac_org_invitation/`(已搬遷舊 member_roles → PLATFORM) |
| 權限解析 | `src/auth/auth.service.ts`(`getScopedPermissions`)、`src/auth/types/scoped-permissions.ts` |
| Guard/Decorator | `src/auth/guards/permission.guard.ts`、`src/auth/decorators/scoped-permissions.decorator.ts`(`@RequirePermissions`) |
| Cache | `src/auth/services/permissions-cache.service.ts`(含 `getActivityOrg`/`setActivityOrg`) |
| 管理 API | `src/organization/*`、`src/rbac/membership.{service,controller}.ts`、`src/rbac/role.*` |
| 邀請 | `src/invitation/*`(`invitation.service.ts` 是狀態機;`mail.service.ts` 是 log stub) |
| Seed | `prisma/seed.ts`(scoped 權限碼 + KKTIX 預設角色) |
| 測試 UI | `public/index.html`(serve 在 `/ui`) |
| 調研文件 | `docs/rbac-kktix-research.md` |

## API 重點
- `POST /organizations`(任何登入者,建立者**自動成 Owner**)、`GET /organizations`(我的)、`GET/PATCH/DELETE /organizations/:id`
- `PUT|GET /organizations/:orgId/members/:memberId/roles`、`PUT|GET /activities/:activityId/members/:memberId/roles`
- `POST|GET /organizations/:orgId/invitations`、`/activities/:activityId/invitations`、`:id/resend`、`DELETE :id`
- `POST /invitations/accept`、`/invitations/reject`(需登入,email 須與被邀者相符;過期回 **410**)
- `GET /roles/catalog`(**dev-only**,免登入,給測試 UI 載入角色下拉用;prod 會擋)

## 預設角色(seed)
- ORG:`Owner`、`Admin`(都全 org 權限)、`Accountant`(finance+report)
- EVENT:`Admin`、`Creator`、`Accountant`、`Checkin`、`Streaming`
- PLATFORM:`admin`(相容舊全域)
- 權限碼:`org:profile|finance|member:manage|report`、`event:edit|registration:read|checkin|order:manage|report|venue|member:manage`、`activity:manage|permission:manage|role:manage`(平台)

## 驗證狀態
- ✅ `pnpm build` / `pnpm lint`(0 errors,只剩 main.ts 既有 warning)/ `pnpm test`(**61 passed**)
- ✅ `prisma migrate deploy` + `db seed` 通過
- ✅ 實機煙霧測試:建組織→Owner 權限→邀請→接受→teammate 取得 org 權限,cascade 結構正常;`/roles/catalog`、`/ui` 皆正常
- ⚠️ **e2e (`test/auth.e2e-spec.ts`) 已改寫但尚未執行** —— 它的 `beforeEach` 會**清空 `DATABASE_URL` 指向的 DB**(目前指向 Zeabur 雲端 dev DB,沒有獨立 test DB),請在可拋棄的 DB 上才跑。

## 待辦 / 已知問題
1. **e2e 未跑** + 沒有獨立 test DB → 需設定 test 專用 `DATABASE_URL` 後再跑 `pnpm test:e2e`。
2. **Cascade 設計缺口**:seed 的 ORG `Owner`/`Admin` 角色**只含 `org:*` 碼,不含 event 碼**,所以預設情況下 org admin **無法** cascade 去管 event 成員。若要符合 KKTIX「組織管理員可管旗下活動」,需在 ORG 角色加入 event 範圍權限碼(產品決定)。
3. **`Activity` 建立 API 沒有帶 `organizationId`**(`CreateActivityDto` 未含),目前活動掛到 org 只能靠 Prisma Studio。若要從 API 建立掛 org 的活動,需擴充 activity DTO/service。
4. **文件漂移**:`CLAUDE.md` / `AGENTS.md` 仍描述舊的「JWT 內嵌 permissions」「全域 RBAC」模型,需更新。
5. **使用者偏好**:之後**不要再額外寫 unit/e2e 測試碼**,用實機跑過確認功能即可(本次測試碼保留不動)。

## 怎麼測(最快)
`pnpm start:dev` → 開 `http://localhost:4000/ui`:dev-login → 建組織 →(org ID / 角色下拉 / token 都會自動帶入)送邀請 → 換帳號登入 → Accept → /me 看 scoped 權限。Assign roles 面板可改某人角色 → 對方舊 token 立即失效(401)。
Loading