Skip to content

Commit 2e47969

Browse files
fakechrischris
andauthored
Add Google OAuth sessions and team access UI (#8)
* Add Google OAuth sessions and team access UI * Fix OAuth review issues and e2e CORS * Fix team access review bugs * Gate destructive Prisma sync --------- Co-authored-by: chris <chris@chrisdeMac-mini.local>
1 parent a525e74 commit 2e47969

35 files changed

Lines changed: 3290 additions & 82 deletions

.env.example

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
DATABASE_URL=postgresql://involute:involute@127.0.0.1:5434/involute?schema=public
2-
AUTH_TOKEN=changeme-set-your-token # Change this for any non-local environment.
3-
VIEWER_ASSERTION_SECRET=compose-viewer-secret # Change this for any non-local environment.
2+
AUTH_TOKEN=changeme-set-your-token # Trusted CLI/dev bearer token. Not required for browser sessions.
3+
VIEWER_ASSERTION_SECRET=compose-viewer-secret # Trusted impersonation for CLI/dev only.
44
ALLOW_ADMIN_FALLBACK=false # Set true only for local/dev bootstrap flows.
5+
APP_ORIGIN=http://localhost:4201
6+
GOOGLE_OAUTH_CLIENT_ID=
7+
GOOGLE_OAUTH_CLIENT_SECRET=
8+
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:4200/auth/google/callback
9+
GOOGLE_OAUTH_ADMIN_EMAILS=
10+
SESSION_TTL_SECONDS=2592000
511
PORT=4200

README.md

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,34 @@ Create a repo-root `.env` file based on `.env.example`:
2121
DATABASE_URL=postgresql://involute:involute@127.0.0.1:5434/involute?schema=public
2222
AUTH_TOKEN=changeme-set-your-token
2323
VIEWER_ASSERTION_SECRET=compose-viewer-secret
24+
APP_ORIGIN=http://localhost:4201
25+
GOOGLE_OAUTH_CLIENT_ID=...
26+
GOOGLE_OAUTH_CLIENT_SECRET=...
27+
GOOGLE_OAUTH_REDIRECT_URI=http://localhost:4200/auth/google/callback
28+
GOOGLE_OAUTH_ADMIN_EMAILS=you@example.com
2429
PORT=4200
2530
```
2631

2732
Required server variables:
2833

2934
- `DATABASE_URL` — PostgreSQL connection string
30-
- `AUTH_TOKEN` — bearer token expected by the API and CLI clients
31-
- `VIEWER_ASSERTION_SECRET` — HMAC secret used to verify signed viewer assertions for trusted impersonation
35+
- `APP_ORIGIN` — browser origin used for cookie/CORS handling and post-login redirects
3236
- `PORT` — API port (defaults to `4200`)
3337

38+
Optional but recommended server variables:
39+
40+
- `AUTH_TOKEN` — trusted bearer token used by the CLI and local/dev bootstrap flows
41+
- `VIEWER_ASSERTION_SECRET` — HMAC secret used to verify signed viewer assertions for trusted impersonation
42+
- `GOOGLE_OAUTH_CLIENT_ID` — Google OAuth client id for browser sign-in
43+
- `GOOGLE_OAUTH_CLIENT_SECRET` — Google OAuth client secret
44+
- `GOOGLE_OAUTH_REDIRECT_URI` — Google callback URL handled by the API server
45+
- `GOOGLE_OAUTH_ADMIN_EMAILS` — comma-separated allowlist of emails that should become `ADMIN`
46+
- `SESSION_TTL_SECONDS` — browser session lifetime in seconds
47+
3448
Optional web runtime variables:
3549

3650
- `VITE_INVOLUTE_GRAPHQL_URL` — override the web app GraphQL endpoint (default: `http://localhost:4200/graphql`)
37-
- `VITE_INVOLUTE_AUTH_TOKEN`provide the web app bearer token at build/dev time
51+
- `VITE_INVOLUTE_AUTH_TOKEN`trusted local/dev bearer token for bypassing browser login
3852
- `VITE_INVOLUTE_VIEWER_ASSERTION` — signed viewer assertion to act as a specific user without exposing the server secret
3953

4054
## Quick start
@@ -54,6 +68,8 @@ curl http://localhost:4201
5468

5569
Then open `http://localhost:4201` in your browser.
5670

71+
If Google OAuth is configured, the web nav will expose `Sign in with Google` and use session cookies. If it is not configured, the browser can still talk to the API with `VITE_INVOLUTE_AUTH_TOKEN` for trusted local development.
72+
5773
Compose defaults:
5874

5975
- API: `http://localhost:4200`
@@ -103,7 +119,7 @@ Recommended acceptance checks:
103119
Start the API:
104120

105121
```bash
106-
DATABASE_URL="postgresql://involute:involute@127.0.0.1:5434/involute?schema=public" AUTH_TOKEN="changeme-set-your-token" VIEWER_ASSERTION_SECRET="compose-viewer-secret" pnpm --filter @involute/server exec tsx src/index.ts
122+
DATABASE_URL="postgresql://involute:involute@127.0.0.1:5434/involute?schema=public" AUTH_TOKEN="changeme-set-your-token" VIEWER_ASSERTION_SECRET="compose-viewer-secret" APP_ORIGIN="http://127.0.0.1:4201" GOOGLE_OAUTH_REDIRECT_URI="http://127.0.0.1:4200/auth/google/callback" pnpm --filter @involute/server exec tsx src/index.ts
107123
```
108124

109125
Start the web app:
@@ -128,6 +144,15 @@ pnpm --filter @involute/cli exec node dist/index.js config set viewer-assertion
128144

129145
The web UI can use the same signed assertion via `VITE_INVOLUTE_VIEWER_ASSERTION` or localStorage key `involute.viewerAssertion`.
130146

147+
## Auth and permissions
148+
149+
- Browser auth now supports Google OAuth plus session cookies.
150+
- `AUTH_TOKEN` and viewer assertions remain available for trusted CLI/dev flows.
151+
- Teams now have `PUBLIC` / `PRIVATE` visibility.
152+
- Team edits are gated by membership role: `EDITOR` or `OWNER`.
153+
- Team access management is currently exposed through GraphQL mutations: `teamUpdateAccess`, `teamMembershipUpsert`, and `teamMembershipRemove`.
154+
- A dedicated team-admin UI is still pending; this is the backend and runtime foundation.
155+
131156
## Quality gates
132157

133158
Unit and integration checks:
@@ -177,8 +202,9 @@ pnpm --filter @involute/cli exec node dist/index.js import team --token "$LINEAR
177202

178203
## Current focus
179204

180-
- Make single-team import a repeatable acceptance loop
181-
- Keep the compose stack and CI reproducible
182-
- Lock the core board lifecycle down with E2E before the larger UI/UX redesign
205+
- Make the current stack deployable on VPS and Railway
206+
- Replace the shared-token-only model with Google OAuth plus sessions
207+
- Add team visibility and edit permissions without regressing the single-team import loop
208+
- Keep the compose stack and CI reproducible while the product boundary hardens
183209

184210
See [docs/vision.md](docs/vision.md) and [docs/milestones.md](docs/milestones.md) for the product direction.

docker-compose.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,18 @@ services:
2626
- /bin/sh
2727
- -lc
2828
command: >
29-
pnpm --filter @involute/server exec prisma db push --skip-generate &&
29+
if [ "${PRISMA_ALLOW_DATA_LOSS:-false}" = "true" ]; then
30+
pnpm --filter @involute/server exec prisma db push --accept-data-loss --skip-generate;
31+
else
32+
pnpm --filter @involute/server exec prisma db push --skip-generate ||
33+
pnpm --filter @involute/server exec prisma migrate deploy;
34+
fi &&
3035
if [ "${SEED_DATABASE:-true}" = "true" ]; then
3136
pnpm --filter @involute/server exec prisma db seed;
3237
fi
3338
environment:
3439
DATABASE_URL: postgresql://involute:involute@db:5432/involute?schema=public
40+
PRISMA_ALLOW_DATA_LOSS: ${PRISMA_ALLOW_DATA_LOSS:-false}
3541
SEED_DATABASE: ${SEED_DATABASE:-true}
3642
restart: "no"
3743

docs/milestones.md

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## M0: Single-team migration acceptance
44

5-
Status: in progress, core path now implemented.
5+
Status: done.
66

77
Done:
88

@@ -18,9 +18,44 @@ Exit criteria:
1818
- `pnpm e2e` is green locally and in CI
1919
- `docker compose up --build -d db server web` is a stable demo path
2020

21-
## M1: UI/UX redesign
21+
## M1: Deployable self-hosting
2222

23-
Status: queued behind stability.
23+
Status: next.
24+
25+
Scope:
26+
27+
- ship a production deployment path for VPS and Railway
28+
- define `.env.production` expectations and runtime secrets
29+
- add reverse proxy / TLS guidance and database backup guidance
30+
- keep Docker images and compose-based demo/runtime aligned
31+
32+
Exit criteria:
33+
34+
- a fresh host can run Involute with Postgres, API, and web using documented steps
35+
- deployment docs are specific enough to reproduce without reading the source
36+
- image publishing and runtime config are consistent with the supported hosting path
37+
38+
## M2: Auth and team permissions
39+
40+
Status: next, after deployment is pinned down.
41+
42+
Scope:
43+
44+
- move away from the current shared-token simplification
45+
- add a real session-backed viewer model
46+
- start with Google OAuth rather than magic-link email
47+
- add `admin`, `team visibility`, and `team membership` edit boundaries
48+
49+
Exit criteria:
50+
51+
- an admin can sign in and manage access without touching raw headers
52+
- public teams are readable but not writable by non-members
53+
- private teams are only visible to members and admins
54+
- team members can be granted viewer/editor-style access explicitly
55+
56+
## M3: UI/UX redesign
57+
58+
Status: later, after M1 and M2.
2459

2560
Scope:
2661

@@ -31,9 +66,9 @@ Scope:
3166
Exit criteria:
3267

3368
- visual direction is intentional and no longer feels placeholder-like
34-
- redesign does not regress the M0 lifecycle and import flow
69+
- redesign does not regress the M0 lifecycle, deployment path, or team permission model
3570

36-
## M2: Multi-team workspace import
71+
## M4: Multi-team workspace import
3772

3873
Status: later.
3974

@@ -47,13 +82,3 @@ Exit criteria:
4782

4883
- multiple Linear teams can be brought in predictably
4984
- repeated imports have explicit behavior and reporting
50-
51-
## M3: Auth and multi-user hardening
52-
53-
Status: later.
54-
55-
Scope:
56-
57-
- move away from the current shared-token simplification
58-
- define a real viewer identity model
59-
- add clearer trust boundaries for API and UI clients

docs/review/triage.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,21 @@
1616
- `G9`[`e2e/board-flow.spec.ts`](e2e/board-flow.spec.ts) 已补“导入后看板展示正确”的 Playwright 验收;fixture 脚本在 [`packages/server/scripts/import-board-fixture.ts`](packages/server/scripts/import-board-fixture.ts)
1717
- `G10`[`packages/web/src/routes/BoardPage.tsx`](packages/web/src/routes/BoardPage.tsx) 继续拆出 [`BoardCreateIssueDialog.tsx`](packages/web/src/components/BoardCreateIssueDialog.tsx)[`BoardLoadMoreNotice.tsx`](packages/web/src/components/BoardLoadMoreNotice.tsx),已不再属于当前 bug 清单。
1818

19+
## 当前状态说明
20+
21+
- 本文后续的 `G1-G12` 详细条目和逐条矩阵,保留的是 2026-04-04 做 triage 时的历史证据快照。
22+
- 它们的价值在于说明“这些问题最初是怎么被发现和归并的”,不是当前 `main` 的开放缺陷列表。
23+
- 判断当前状态时,应以上面的 `2026-04-05 收尾结论` 为准。
24+
- 当前主线下一阶段工作已从 review 修 bug 转向部署、身份体系、权限边界和后续 UI/UX 迭代。
25+
1926
## 判定口径
2027

2128
- `成立`:当前主线代码中仍然存在,且原 review 的问题表述基本准确。
2229
- `部分成立`:问题方向是对的,但严重性、触发条件或细节表述不准确,或只剩下部分子问题。
2330
- `已修复/已过期`:review 当时可能成立,但当前主线代码已不再成立。
2431
- `当前非缺陷/延期`:这是产品缺口、范围取舍或后续里程碑工作,不计入当前缺陷清单。
2532

26-
## Canonical 问题清单(已去重,按重要性排序
33+
## Canonical 问题清单(历史快照,非当前开放问题
2734

2835
### G1 [P0] 看板列仍然硬编码 6 个 workflow state,非标准状态的 issue 会在 Board 中“消失”
2936

docs/vision.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ If this path is stable, the product is already useful for migration rehearsal, a
2121

2222
## What we are optimizing for now
2323

24-
- Reliable single-team import and verification
25-
- Reproducible local stack through Docker Compose
26-
- Stable issue lifecycle in the UI: create, edit, comment, delete
27-
- Automated end-to-end acceptance coverage before a larger UI/UX rewrite
24+
- Stable self-hosted deployment on VPS or Railway
25+
- Simple multi-user access with Google OAuth and session auth
26+
- Team-level visibility and edit permissions
27+
- Keep the single-team import and issue lifecycle green while the product boundary hardens
2828

2929
## Explicitly not optimizing for yet
3030

3131
- Multi-team workspace import
3232
- Large-scale performance work
3333
- Final visual design language
3434
- Enterprise auth and permission boundaries
35+
- Magic-link email auth and provider sprawl

e2e/board-flow.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ test.describe('board flow', () => {
1111
page.on('dialog', (dialog) => dialog.accept());
1212

1313
await page.goto('/');
14-
await expect(page.getByRole('heading', { name: 'Board' })).toBeVisible();
14+
await expect(page.getByRole('heading', { name: 'Board', exact: true })).toBeVisible();
1515
await expect(page.getByText('Workflow overview for Involute.')).toBeVisible();
1616

1717
await page.getByRole('button', { name: 'Create issue' }).click();
@@ -65,7 +65,7 @@ test.describe('board flow', () => {
6565
runBoardFixtureCommand('seed');
6666

6767
await page.goto('/');
68-
await expect(page.getByRole('heading', { name: 'Board' })).toBeVisible();
68+
await expect(page.getByRole('heading', { name: 'Board', exact: true })).toBeVisible();
6969

7070
await page.getByLabel('Select team').selectOption({ label: 'Imported Acceptance Team' });
7171

e2e/setup-backend.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ DATABASE_URL="${E2E_DATABASE_URL:-postgresql://involute:involute@127.0.0.1:${DB_
88
AUTH_TOKEN="${E2E_AUTH_TOKEN:-e2e-auth-token}"
99
VIEWER_ASSERTION_SECRET="${E2E_VIEWER_ASSERTION_SECRET:-e2e-viewer-assertion-secret}"
1010
SERVER_PORT="${E2E_SERVER_PORT:-4300}"
11+
WEB_PORT="${E2E_WEB_PORT:-4301}"
12+
APP_ORIGIN="${E2E_APP_ORIGIN:-http://127.0.0.1:${WEB_PORT}}"
1113

1214
export COMPOSE_PROJECT_NAME
1315
export DB_PORT
@@ -35,6 +37,7 @@ DATABASE_URL="$DATABASE_URL" pnpm --filter @involute/server exec prisma db push
3537
DATABASE_URL="$DATABASE_URL" pnpm --filter @involute/server exec prisma db seed
3638

3739
exec env \
40+
APP_ORIGIN="$APP_ORIGIN" \
3841
DATABASE_URL="$DATABASE_URL" \
3942
AUTH_TOKEN="$AUTH_TOKEN" \
4043
ALLOW_ADMIN_FALLBACK="false" \

packages/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"typecheck": "pnpm --filter @involute/shared build && pnpm exec prisma generate && pnpm exec tsc -p tsconfig.json --noEmit && pnpm exec tsc -p tsconfig.prisma.json",
2121
"setup:web-ui-validation": "pnpm exec tsx src/validation-data-setup.ts",
2222
"setup:son-validation": "pnpm exec tsx src/son-validation-restore.ts",
23-
"test": "pnpm --filter @involute/shared build && pnpm exec prisma generate && pnpm exec prisma db push --skip-generate && node scripts/run-vitest.mjs",
23+
"test": "pnpm --filter @involute/shared build && pnpm exec prisma generate && sh -c 'if [ \"${PRISMA_ALLOW_DATA_LOSS:-false}\" = \"true\" ]; then pnpm exec prisma db push --accept-data-loss --skip-generate; else pnpm exec prisma db push --skip-generate || pnpm exec prisma migrate deploy; fi' && node scripts/run-vitest.mjs",
2424
"lint": "pnpm --filter @involute/shared build && pnpm exec prisma generate && pnpm exec tsc -p tsconfig.json --noEmit && pnpm exec tsc -p tsconfig.prisma.json"
2525
},
2626
"prisma": {

packages/server/prisma/schema.prisma

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,31 @@ datasource db {
77
url = env("DATABASE_URL")
88
}
99

10+
enum GlobalRole {
11+
ADMIN
12+
USER
13+
}
14+
15+
enum TeamVisibility {
16+
PRIVATE
17+
PUBLIC
18+
}
19+
20+
enum TeamMembershipRole {
21+
VIEWER
22+
EDITOR
23+
OWNER
24+
}
25+
1026
model Team {
1127
id String @id @default(uuid()) @db.Uuid
1228
key String @unique
1329
name String
30+
visibility TeamVisibility @default(PRIVATE)
1431
nextIssueNumber Int @default(1)
1532
states WorkflowState[]
1633
issues Issue[]
34+
memberships TeamMembership[]
1735
1836
@@index([key])
1937
}
@@ -41,8 +59,39 @@ model User {
4159
id String @id @default(uuid()) @db.Uuid
4260
name String
4361
email String @unique
62+
avatarUrl String?
63+
googleSubject String? @unique
64+
globalRole GlobalRole @default(USER)
4465
assignedIssues Issue[] @relation("IssueAssignee")
4566
comments Comment[]
67+
memberships TeamMembership[]
68+
sessions Session[]
69+
}
70+
71+
model Session {
72+
id String @id @default(uuid()) @db.Uuid
73+
tokenHash String @unique
74+
createdAt DateTime @default(now())
75+
expiresAt DateTime
76+
userId String @db.Uuid
77+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
78+
79+
@@index([userId])
80+
@@index([expiresAt])
81+
}
82+
83+
model TeamMembership {
84+
id String @id @default(uuid()) @db.Uuid
85+
teamId String @db.Uuid
86+
userId String @db.Uuid
87+
role TeamMembershipRole
88+
createdAt DateTime @default(now())
89+
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
90+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
91+
92+
@@unique([teamId, userId])
93+
@@index([teamId])
94+
@@index([userId])
4695
}
4796

4897
model Issue {

0 commit comments

Comments
 (0)