docs(10): record planning completion, annotate roadmap waves, add pattern map
This commit is contained in:
+13
-2
@@ -170,12 +170,23 @@ Plans:
|
|||||||
|
|
||||||
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
|
**Folded-in scope** (from backlog 999.5, self-service member onboarding): the credential surface this phase builds is the same one a member needs on first login. Expose a `needsProviderSetup` signal (member has no `member_credentials` row) and let a member enter/validate (CalDAV PROPFIND) + encrypt their **own** Fastmail app password — the self-service counterpart of the admin-managed flow, sharing the validation/encryption/initial-sync path. Non-technical-friendly instructions (link to Fastmail's app-password page, required Calendars/CalDAV scope) are the hard UX constraint. Member-scoped: a member can only set their own credential; never log/echo the password.
|
||||||
|
|
||||||
**Plans**: 4 plans (4 waves)
|
**Plans**: 4 plans (4 waves)Plans:
|
||||||
Plans:
|
**Wave 1**
|
||||||
|
|
||||||
- [ ] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
|
- [ ] 10-01-PLAN.md — v1.1 DB foundation migration (is_admin, provider_type+unique, reminder_lead_minutes, app_config) + dev-bypass admin seed
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
- [ ] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
|
- [ ] 10-02-PLAN.md — requireAdmin guard + first-login-wins bootstrap + /api/me isAdmin/needsProviderSetup (TDD)
|
||||||
|
|
||||||
|
**Wave 3** *(blocked on Wave 2 completion)*
|
||||||
|
|
||||||
- [ ] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD)
|
- [ ] 10-03-PLAN.md — adminRouter (members/credentials/calendars/shared) + member self-service credential, validate→encrypt→sync (TDD)
|
||||||
|
|
||||||
|
**Wave 4** *(blocked on Wave 3 completion)*
|
||||||
|
|
||||||
- [ ] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
|
- [ ] 10-04-PLAN.md — PWA /admin route + nav gating + CredentialSheet + SetupBanner (playwright-cli verified)
|
||||||
|
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 11: Per-Event Reminders
|
### Phase 11: Per-Event Reminders
|
||||||
|
|||||||
+8
-8
@@ -2,10 +2,10 @@
|
|||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.1
|
milestone: v1.1
|
||||||
milestone_name: Operability & Polish
|
milestone_name: Operability & Polish
|
||||||
status: verifying
|
status: executing
|
||||||
stopped_at: Completed 16-05-PLAN.md
|
stopped_at: Phase 10 UI-SPEC approved
|
||||||
last_updated: "2026-06-13T12:59:54.942Z"
|
last_updated: "2026-06-13T18:10:25.965Z"
|
||||||
last_activity: 2026-06-13
|
last_activity: "2026-06-13 - Completed quick task 260613-fp9: .gitea/.planning-only pushes skip the Docker publish"
|
||||||
progress:
|
progress:
|
||||||
total_phases: 19
|
total_phases: 19
|
||||||
completed_phases: 7
|
completed_phases: 7
|
||||||
@@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-06-10)
|
|||||||
|
|
||||||
Phase: 999.1
|
Phase: 999.1
|
||||||
Plan: Not started
|
Plan: Not started
|
||||||
Status: Phase complete — ready for verification
|
Status: Ready to execute
|
||||||
Last activity: 2026-06-13 - Completed quick task 260613-fp9: .gitea/.planning-only pushes skip the Docker publish
|
Last activity: 2026-06-13 - Completed quick task 260613-fp9: .gitea/.planning-only pushes skip the Docker publish
|
||||||
|
|
||||||
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
|
||||||
@@ -235,9 +235,9 @@ Recent decisions affecting current work:
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-06-13T12:28:17.736Z
|
Last session: 2026-06-13T17:21:38.274Z
|
||||||
Stopped at: Completed 16-05-PLAN.md
|
Stopped at: Phase 10 UI-SPEC approved
|
||||||
Resume file: None
|
Resume file: .planning/phases/10-admin-role-settings/10-UI-SPEC.md
|
||||||
|
|
||||||
## Operator Next Steps
|
## Operator Next Steps
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,710 @@
|
|||||||
|
# Phase 10: Admin Role & Settings - Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-06-13
|
||||||
|
**Files analyzed:** 14 new/modified files
|
||||||
|
**Analogs found:** 13 / 14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `apps/api/src/db/schema.ts` | model | CRUD | self (existing schema.ts) | exact — extend in place |
|
||||||
|
| `apps/api/src/db/migrations/0001_v1_1_foundation.sql` | config | batch | `0000_baseline.sql` | exact |
|
||||||
|
| `apps/api/src/routes/admin.ts` (NEW) | controller | request-response | `apps/api/src/routes/push.ts` | role-match |
|
||||||
|
| `apps/api/src/lib/requireAdmin.ts` (NEW) | middleware | request-response | `apps/api/src/auth/devBypass.ts` | role-match |
|
||||||
|
| `apps/api/src/index.ts` | config | request-response | self (existing index.ts) | exact — extend in place |
|
||||||
|
| `apps/api/src/routes/me.ts` | controller | request-response | self (existing me.ts) | exact — extend in place |
|
||||||
|
| `apps/api/src/auth/user.ts` | service | CRUD | self (existing user.ts) | exact — extend in place |
|
||||||
|
| `apps/api/src/broker/crypto.ts` | utility | transform | — | reuse only, no changes |
|
||||||
|
| `apps/api/src/broker/client.ts` | utility | request-response | — | reuse only, no changes |
|
||||||
|
| `apps/api/src/broker/outboxWorker.ts` | service | event-driven | — | reuse `loadClientForUser` / `triggerTargetedResync` (promote to export) |
|
||||||
|
| `apps/pwa/src/App.tsx` | component | request-response | self (existing App.tsx) | exact — extend in place |
|
||||||
|
| `apps/pwa/src/api/client.ts` | utility | request-response | self (existing client.ts) | exact — extend in place |
|
||||||
|
| `apps/pwa/src/routes/AdminPage.tsx` (NEW) | component | request-response | `apps/pwa/src/routes/ListsIndex.tsx` | role-match |
|
||||||
|
| `apps/pwa/src/components/CredentialSheet.tsx` (NEW) | component | request-response | `apps/pwa/src/components/SettingsSheet.tsx` | exact |
|
||||||
|
| `apps/pwa/src/components/SetupBanner.tsx` (NEW) | component | event-driven | `apps/pwa/src/components/PermissionDeniedBanner.tsx` | role-match |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `apps/api/src/db/schema.ts` — add columns + new table
|
||||||
|
|
||||||
|
**Analog:** self — extend in place.
|
||||||
|
|
||||||
|
**Existing import pattern** (lines 1–14):
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
mysqlTable,
|
||||||
|
mysqlEnum,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
int,
|
||||||
|
date,
|
||||||
|
timestamp,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
unique,
|
||||||
|
customType,
|
||||||
|
} from 'drizzle-orm/mysql-core';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Existing column patterns to copy for new columns:**
|
||||||
|
|
||||||
|
`boolean` with NOT NULL DEFAULT false — copy from `calendarEvents.allDay` (line 127):
|
||||||
|
```typescript
|
||||||
|
allDay: boolean('all_day').default(false).notNull(),
|
||||||
|
```
|
||||||
|
|
||||||
|
`varchar` with length + notNull + default — copy from `memberCredentials.fastmailEmail` (line 64):
|
||||||
|
```typescript
|
||||||
|
fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
|
||||||
|
```
|
||||||
|
|
||||||
|
`int` nullable — copy from `calendarEvents.dtstartUtc` (line 125) but use `int`:
|
||||||
|
```typescript
|
||||||
|
dtstartUtc: timestamp('dtstart_utc'), // nullable = no .notNull()
|
||||||
|
```
|
||||||
|
|
||||||
|
**New `app_config` table — follow `pushSubscriptions` single-table pattern** (lines 236–257):
|
||||||
|
```typescript
|
||||||
|
export const pushSubscriptions = mysqlTable(
|
||||||
|
'push_subscriptions',
|
||||||
|
{
|
||||||
|
id: int().primaryKey().autoincrement(),
|
||||||
|
userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
endpoint: varchar('endpoint', { length: 2048 }).notNull(),
|
||||||
|
...
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
unique('uniq_push_endpoint').on(t.endpoint),
|
||||||
|
index('idx_push_subscriptions_user_id').on(t.userId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**`memberCredentials` `unique` constraint pattern** — copy from `calendars` (lines 91–100):
|
||||||
|
```typescript
|
||||||
|
unique('uniq_calendar_user_url').on(t.userId, t.url),
|
||||||
|
```
|
||||||
|
Apply as `unique('uniq_member_credential_user').on(t.userId)` to enforce one-credential-per-member and enable `onDuplicateKeyUpdate`.
|
||||||
|
|
||||||
|
**Changes to make:**
|
||||||
|
1. `users` table: add `isAdmin: boolean('is_admin').default(false).notNull()`
|
||||||
|
2. `memberCredentials` table: add `providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav')` + add `unique('uniq_member_credential_user').on(t.userId)` to the index array
|
||||||
|
3. `calendarEvents` table: add `reminderLeadMinutes: int('reminder_lead_minutes')` (nullable — no `.notNull()`)
|
||||||
|
4. New `appConfig` table: `key VARCHAR PK, value TEXT, updatedAt timestamp`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/db/migrations/0001_v1_1_foundation.sql` (NEW, generated)
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/db/migrations/0000_baseline.sql` lines 1–16.
|
||||||
|
|
||||||
|
**Migration file format** — each DDL statement separated by `--> statement-breakpoint`:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `users` ADD COLUMN `is_admin` boolean NOT NULL DEFAULT false;
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `member_credentials` ADD COLUMN `provider_type` varchar(64) NOT NULL DEFAULT 'caldav';
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `member_credentials` ADD UNIQUE `uniq_member_credential_user`(`user_id`);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `calendar_events` ADD COLUMN `reminder_lead_minutes` int;
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `app_config` ( ... );
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not hand-write.** Run `pnpm --filter @familysync/api db:generate` after editing schema.ts; the file is generated automatically. Commit the output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/routes/admin.ts` (NEW) — admin sub-router
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/routes/push.ts` (closest: Hono sub-router + zValidator + resolveUserId pattern)
|
||||||
|
|
||||||
|
**Imports pattern** — copy from `push.ts` lines 15–26, substitute admin-specific imports:
|
||||||
|
```typescript
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { Context, MiddlewareHandler } from 'hono';
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users, memberCredentials, calendars } from '../db/schema.js';
|
||||||
|
import { encryptPassword } from '../broker/crypto.js';
|
||||||
|
import { createFastmailClient } from '../broker/client.js';
|
||||||
|
import { requireAdmin } from '../lib/requireAdmin.js';
|
||||||
|
// Side-effect import for ContextVariableMap augmentation
|
||||||
|
import '../auth/devBypass.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Router + guard pattern** (Pitfall 9 — guard FIRST inside the sub-router):
|
||||||
|
```typescript
|
||||||
|
export const adminRouter = new Hono();
|
||||||
|
adminRouter.use('*', requireAdmin); // ← MUST be first; guards every sub-route
|
||||||
|
```
|
||||||
|
|
||||||
|
**zValidator with no-echo hook** (Pitfall 7) — adapt from `push.ts` lines 60–67 (subscribeSchema):
|
||||||
|
```typescript
|
||||||
|
const credentialSchema = z.object({
|
||||||
|
userId: z.number().int().positive(),
|
||||||
|
providerType: z.literal('caldav'),
|
||||||
|
fastmailEmail: z.string().email().max(256),
|
||||||
|
appPassword: z.string().min(1).max(500),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hook MUST never echo Zod issues (which contain .received = the password value)
|
||||||
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||||||
|
if (!result.success) return c.json({ error: 'Invalid request' }, 400);
|
||||||
|
};
|
||||||
|
|
||||||
|
adminRouter.post(
|
||||||
|
'/credentials',
|
||||||
|
zValidator('json', credentialSchema, noEchoHook),
|
||||||
|
async (c) => {
|
||||||
|
const { userId, fastmailEmail, appPassword } = c.req.valid('json');
|
||||||
|
// NEVER log appPassword or c.req.valid('json')
|
||||||
|
// validate → encrypt → upsert → trigger-sync
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Drizzle SELECT pattern** — copy from `events.ts` lines 165–177 (join + where):
|
||||||
|
```typescript
|
||||||
|
const rows = await db
|
||||||
|
.select({ ... })
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id))
|
||||||
|
.where(/* ... */);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Drizzle upsert pattern** — copy from `events.ts` `onDuplicateKeyUpdate` usage (found in outboxWorker):
|
||||||
|
```typescript
|
||||||
|
await db.insert(memberCredentials)
|
||||||
|
.values({ userId, encryptedPassword: encrypted, fastmailEmail, providerType: 'caldav' })
|
||||||
|
.onDuplicateKeyUpdate({ set: { encryptedPassword: encrypted, fastmailEmail, providerType: 'caldav' } });
|
||||||
|
// Requires UNIQUE(user_id) added by v1.1 migration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exclusive is_shared update** — two sequential Drizzle UPDATEs (RESEARCH.md Pattern 7):
|
||||||
|
```typescript
|
||||||
|
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
||||||
|
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/lib/requireAdmin.ts` (NEW) — role middleware
|
||||||
|
|
||||||
|
**Analog:** `apps/api/src/auth/devBypass.ts` (MiddlewareHandler pattern)
|
||||||
|
|
||||||
|
**Import + type pattern** (devBypass.ts lines 27–28):
|
||||||
|
```typescript
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
```
|
||||||
|
|
||||||
|
**MiddlewareHandler signature** (devBypass.ts lines 58–76):
|
||||||
|
```typescript
|
||||||
|
export function devAuthBypass(): MiddlewareHandler {
|
||||||
|
return async (c, next) => {
|
||||||
|
c.set('user', DEV_USER);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**requireAdmin must be an inline `MiddlewareHandler`**, not a factory function (applied as `.use('*', requireAdmin)`):
|
||||||
|
```typescript
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users } from '../db/schema.js';
|
||||||
|
|
||||||
|
export const requireAdmin: MiddlewareHandler = async (c, next) => {
|
||||||
|
const devUser = c.get('user') as { id: number } | undefined;
|
||||||
|
const userId = devUser?.id;
|
||||||
|
if (!userId) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
|
||||||
|
// Always look up is_admin from DB — bypass only skips OIDC, not the DB check
|
||||||
|
const [row] = await db
|
||||||
|
.select({ isAdmin: users.isAdmin })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!row?.isAdmin) return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**ContextVariableMap augmentation** — include side-effect import from devBypass.ts (line 39):
|
||||||
|
```typescript
|
||||||
|
import '../auth/devBypass.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/index.ts` — mount adminRouter
|
||||||
|
|
||||||
|
**Analog:** self — extend in place.
|
||||||
|
|
||||||
|
**Existing route mounting pattern** (lines 67–72):
|
||||||
|
```typescript
|
||||||
|
app.route('/api/me', meRouter);
|
||||||
|
app.route('/api/events', eventsRouter);
|
||||||
|
app.route('/api/lists', listsRouter);
|
||||||
|
app.route('/api/list-items', listItemsRouter);
|
||||||
|
app.route('/api/push', pushRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add after the existing route block** (same style, behind the existing devAuthBypass → oidcAuthMiddleware band already covering `/api/*`):
|
||||||
|
```typescript
|
||||||
|
import { adminRouter } from './routes/admin.js';
|
||||||
|
// ...
|
||||||
|
app.route('/api/admin', adminRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
No additional middleware needed at the `app` level — `requireAdmin` is applied inside `adminRouter` itself (Pitfall 9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/routes/me.ts` — add isAdmin + needsProviderSetup
|
||||||
|
|
||||||
|
**Analog:** self — extend in place.
|
||||||
|
|
||||||
|
**Current response shape** (lines 30–43 dev-bypass path, lines 66–73 OIDC path):
|
||||||
|
```typescript
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: devUser.id,
|
||||||
|
displayName: devUser.displayName,
|
||||||
|
color: devUser.color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern:** Both paths (dev-bypass + OIDC) must add `isAdmin` and `needsProviderSetup`. The dev-bypass path currently short-circuits WITHOUT a DB lookup — for `isAdmin` it MUST query the DB for user id 1 (same as requireAdmin). `needsProviderSetup` requires a COUNT/EXISTS on `memberCredentials` for the current user id.
|
||||||
|
|
||||||
|
**DB import additions needed:**
|
||||||
|
```typescript
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users, memberCredentials } from '../db/schema.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**needsProviderSetup lookup pattern** — copy Drizzle `.select().from().where().limit(1)` pattern from user.ts lines 79–82:
|
||||||
|
```typescript
|
||||||
|
const [cred] = await db
|
||||||
|
.select({ id: memberCredentials.id })
|
||||||
|
.from(memberCredentials)
|
||||||
|
.where(eq(memberCredentials.userId, userId))
|
||||||
|
.limit(1);
|
||||||
|
const needsProviderSetup = !cred;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/auth/user.ts` — first-login-wins is_admin bootstrap
|
||||||
|
|
||||||
|
**Analog:** self — extend in place.
|
||||||
|
|
||||||
|
**Insert block** (lines 112–122) — add `isAdmin` to the `.values({...})` call:
|
||||||
|
```typescript
|
||||||
|
// Before INSERT: check if zero admins exist (first-login-wins, D-01)
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`COUNT(*)` })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.isAdmin, true));
|
||||||
|
const shouldBeAdmin = Number(count) === 0;
|
||||||
|
|
||||||
|
const [inserted] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
oidcIss,
|
||||||
|
oidcSub,
|
||||||
|
displayName: displayName ?? null,
|
||||||
|
color,
|
||||||
|
isAdmin: shouldBeAdmin, // ← new
|
||||||
|
})
|
||||||
|
.$returningId();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import additions needed:**
|
||||||
|
```typescript
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/api/src/broker/outboxWorker.ts` — promote triggerTargetedResync
|
||||||
|
|
||||||
|
**Analog:** self — promote private function to export.
|
||||||
|
|
||||||
|
**Current private function signature** (lines 302–348):
|
||||||
|
```typescript
|
||||||
|
async function triggerTargetedResync(
|
||||||
|
calendarUrl: string,
|
||||||
|
userId: number,
|
||||||
|
clientCache?: Map<number, FastmailClient>,
|
||||||
|
): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Change:** add `export` keyword. Admin routes (and member self-service) will import and call it after credential upsert.
|
||||||
|
|
||||||
|
**Also export `loadClientForUser`** (lines 271–288) — needed for the initial full per-member sync (no known `calendarUrl` after first credential save):
|
||||||
|
```typescript
|
||||||
|
export async function loadClientForUser(userId: number): Promise<FastmailClient>
|
||||||
|
```
|
||||||
|
|
||||||
|
For the post-credential-save full sync (no specific `calendarUrl`), the admin route calls `loadClientForUser`, then `client.fetchCalendars()`, iterates each `davCal`, and calls `syncCalendar` for each — mirroring what the poller does per member.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/App.tsx` — add /admin route
|
||||||
|
|
||||||
|
**Analog:** self — extend in place.
|
||||||
|
|
||||||
|
**Existing Routes block** (lines 121–127):
|
||||||
|
```typescript
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Navigate to="/calendar" replace />} />
|
||||||
|
<Route path="/calendar" element={<CalendarShell />} />
|
||||||
|
<Route path="/lists" element={<ListsIndex />} />
|
||||||
|
<Route path="/lists/:listId" element={<ListDetail />} />
|
||||||
|
</Routes>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add `/admin` route** with inline redirect guard:
|
||||||
|
```typescript
|
||||||
|
import { AdminPage } from './routes/AdminPage.js';
|
||||||
|
// ...
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
meQuery.data?.user.isAdmin
|
||||||
|
? <AdminPage />
|
||||||
|
: <Navigate to="/calendar" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**meQuery consumption pattern** (lines 63–68):
|
||||||
|
```typescript
|
||||||
|
const meQuery = useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: fetchMe,
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
The `isAdmin` guard on the route uses `meQuery.data?.user.isAdmin` — while `meQuery` is loading, `isAdmin` is `undefined` (falsy), so the route redirects. Add a loading gate if flash-of-redirect is a concern (planner's call).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/api/client.ts` — add isAdmin + needsProviderSetup to MeUser
|
||||||
|
|
||||||
|
**Analog:** self — extend in place.
|
||||||
|
|
||||||
|
**Current MeUser interface** (lines 62–66):
|
||||||
|
```typescript
|
||||||
|
export interface MeUser {
|
||||||
|
id: number;
|
||||||
|
displayName: string | null;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add fields:**
|
||||||
|
```typescript
|
||||||
|
export interface MeUser {
|
||||||
|
id: number;
|
||||||
|
displayName: string | null;
|
||||||
|
color: string;
|
||||||
|
isAdmin: boolean; // from users.is_admin
|
||||||
|
needsProviderSetup: boolean; // true when no member_credentials row exists
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API fetch functions pattern for admin routes** — copy from `createEvent` (lines 221–233):
|
||||||
|
```typescript
|
||||||
|
export async function createEvent(payload: CreateEventPayload): Promise<CreateEventResponse> {
|
||||||
|
const res = await fetch('/api/events/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
redirect: 'manual',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
handleAuthResponse(res, 'POST /api/events/create');
|
||||||
|
return res.json() as Promise<CreateEventResponse>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Apply same pattern for `fetchAdminMembers`, `saveCredential`, `fetchAdminCalendars`, `setSharedCalendar`, `saveMyCredential`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/routes/AdminPage.tsx` (NEW) — /admin page shell
|
||||||
|
|
||||||
|
**Analog:** `apps/pwa/src/routes/ListsIndex.tsx` (page-level component with TanStack Query + sections)
|
||||||
|
|
||||||
|
**Page structure pattern** — copy AppNav/content layout from `App.tsx` content area style (lines 99–102):
|
||||||
|
```typescript
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**TanStack Query fetch pattern** — copy from App.tsx meQuery (lines 63–68); admin page will add its own queries for members and calendars:
|
||||||
|
```typescript
|
||||||
|
const membersQuery = useQuery({
|
||||||
|
queryKey: ['admin', 'members'],
|
||||||
|
queryFn: fetchAdminMembers,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Section label style** — per UI-SPEC, copy the pattern from `SettingsSheet.tsx` section headers:
|
||||||
|
```typescript
|
||||||
|
// 13px / weight 600 / var(--color-text-muted) / uppercase / letterSpacing 0.06em
|
||||||
|
{
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.06em',
|
||||||
|
marginBottom: 'var(--space-2)',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/components/CredentialSheet.tsx` (NEW) — credential bottom sheet
|
||||||
|
|
||||||
|
**Analog:** `apps/pwa/src/components/SettingsSheet.tsx` (closest exact match: bottom sheet pattern, role="dialog", Escape key, focus management)
|
||||||
|
|
||||||
|
**Bottom sheet structural pattern** (SettingsSheet.tsx lines 52–76):
|
||||||
|
```typescript
|
||||||
|
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && closeButtonRef.current) {
|
||||||
|
closeButtonRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sheet container style** (apply zIndex 301, backdrop 300, borderRadius 12px 12px 0 0 — matching SettingsSheet):
|
||||||
|
```typescript
|
||||||
|
// Backdrop
|
||||||
|
{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 300 }
|
||||||
|
// Sheet
|
||||||
|
{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--color-surface)',
|
||||||
|
borderRadius: '12px 12px 0 0', padding: 'var(--space-6)', zIndex: 301 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**ARIA pattern:**
|
||||||
|
```tsx
|
||||||
|
<div role="dialog" aria-modal="true" aria-label="Rotate Credential">
|
||||||
|
```
|
||||||
|
|
||||||
|
**Password input pattern** (UI-SPEC — never pre-filled, `type="password"`, `autocomplete="new-password"`):
|
||||||
|
```tsx
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
style={{ /* ... full-width, border, borderRadius, fontSize */ }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**TanStack Query mutation pattern** — copy from PWA list mutation (useMutation with onSuccess invalidation):
|
||||||
|
```typescript
|
||||||
|
const credentialMutation = useMutation({
|
||||||
|
mutationFn: saveCredential,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['me'] }); // needsProviderSetup refresh
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `apps/pwa/src/components/SetupBanner.tsx` (NEW) — needsProviderSetup banner
|
||||||
|
|
||||||
|
**Analog:** `apps/pwa/src/components/PermissionDeniedBanner.tsx` (conditional banner rendered from App.tsx level)
|
||||||
|
|
||||||
|
**Pattern:** renders only when `meQuery.data?.user.needsProviderSetup === true`. No dismiss button per UI-SPEC — disappears when `needsProviderSetup` becomes false after save.
|
||||||
|
|
||||||
|
**Banner style** (UI-SPEC Surface 4):
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
background: 'var(--color-surface-dim)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--space-2)',
|
||||||
|
padding: 'var(--space-4)',
|
||||||
|
margin: 'var(--space-4)',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`role="status"` for live announcement:**
|
||||||
|
```tsx
|
||||||
|
<div role="status" aria-live="polite">
|
||||||
|
{/* KeyRound icon + heading + body + CTA */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### resolveUserId — auth helper per router
|
||||||
|
|
||||||
|
**Source:** `apps/api/src/routes/push.ts` lines 37–49 (canonical copy in use across push, events, lists routers)
|
||||||
|
|
||||||
|
**Apply to:** `apps/api/src/routes/admin.ts` (member self-service endpoint on `/api/me/credential` added to meRouter)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function resolveUserId(c: Context): Promise<number | null> {
|
||||||
|
const devUser = c.get('user') as { id: number } | undefined;
|
||||||
|
if (devUser) return devUser.id;
|
||||||
|
|
||||||
|
const auth = await getAuth(c);
|
||||||
|
if (!auth) return null;
|
||||||
|
|
||||||
|
const iss = (auth.iss as string | undefined) ?? '';
|
||||||
|
const sub = auth.sub ?? '';
|
||||||
|
const displayName = deriveDisplayName(auth);
|
||||||
|
const user = await upsertUser(iss, sub, displayName);
|
||||||
|
return user?.id ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hono sub-router mounting
|
||||||
|
|
||||||
|
**Source:** `apps/api/src/index.ts` lines 67–72
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
app.route('/api/admin', adminRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:** index.ts — adminRouter added to the existing route block (after the auth guards already cover `/api/*`).
|
||||||
|
|
||||||
|
### Zod + zValidator (no hook = safe for non-credential fields)
|
||||||
|
|
||||||
|
**Source:** `apps/api/src/routes/events.ts` lines 27–28 + 137
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
// Usage:
|
||||||
|
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:** non-credential admin routes (GET /members, GET /calendars, PUT /calendars/:id/shared).
|
||||||
|
|
||||||
|
**For credential routes only — add the no-echo hook** (RESEARCH.md Pattern 2). Never return `result.error` directly for any route that accepts `appPassword`.
|
||||||
|
|
||||||
|
### ContextVariableMap side-effect import
|
||||||
|
|
||||||
|
**Source:** every route file (push.ts line 25, events.ts line 39, me.ts line 26)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import '../auth/devBypass.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:** `apps/api/src/routes/admin.ts` and `apps/api/src/lib/requireAdmin.ts`.
|
||||||
|
|
||||||
|
### NavLink + Lucide icon (nav entry)
|
||||||
|
|
||||||
|
**Source:** `apps/pwa/src/components/AppNav.tsx` lines 14–15 + `BottomTabBar.tsx` lines 76–98
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NavLink } from 'react-router';
|
||||||
|
import { CalendarDays, List } from 'lucide-react';
|
||||||
|
// NavLink usage:
|
||||||
|
<NavLink to="/calendar" aria-label="Calendar" style={({ isActive }) => ({
|
||||||
|
...tabBase, ...(isActive ? tabActiveOverride : {}),
|
||||||
|
})}>
|
||||||
|
<CalendarDays size={22} aria-hidden="true" />
|
||||||
|
<span>Calendar</span>
|
||||||
|
</NavLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:** `AppNav.tsx` (DesktopNav section) and `BottomTabBar.tsx` — add Admin entry with `ShieldCheck` icon (size 18/22), conditional on `isAdmin === true`.
|
||||||
|
|
||||||
|
### CSS token inline style pattern
|
||||||
|
|
||||||
|
**Source:** `apps/pwa/src/components/BottomTabBar.tsx` lines 27–45
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const tabBase: React.CSSProperties = {
|
||||||
|
fontSize: 'var(--text-label-size, 13px)',
|
||||||
|
color: 'var(--color-text-muted)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
minHeight: '44px',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:** all new PWA components (AdminPage, CredentialSheet, SetupBanner). No hard-coded px except the 44px touch-target minimum. All color/typography/spacing references through `var(--token)`.
|
||||||
|
|
||||||
|
### `handleAuthResponse` + `redirect: 'manual'` in fetch
|
||||||
|
|
||||||
|
**Source:** `apps/pwa/src/api/client.ts` lines 51–58 + 80–83
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function handleAuthResponse(res: Response, label: string): void {
|
||||||
|
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
|
||||||
|
if (!res.ok) throw new Error(`${label} failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
// Usage:
|
||||||
|
const res = await fetch('/api/admin/members', { credentials: 'include', redirect: 'manual' });
|
||||||
|
handleAuthResponse(res, 'GET /api/admin/members');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Apply to:** all new `client.ts` fetch functions for admin and me/credential endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
| File | Role | Data Flow | Reason |
|
||||||
|
|---|---|---|---|
|
||||||
|
| (none) | — | — | All files have close analogs in the existing codebase |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Analog Search Scope
|
||||||
|
|
||||||
|
- `apps/api/src/routes/` — all route files
|
||||||
|
- `apps/api/src/auth/` — devBypass.ts, user.ts, middleware.ts
|
||||||
|
- `apps/api/src/broker/` — crypto.ts, client.ts, outboxWorker.ts
|
||||||
|
- `apps/api/src/db/` — schema.ts, migrations/
|
||||||
|
- `apps/pwa/src/` — App.tsx, api/client.ts, components/, routes/
|
||||||
|
|
||||||
|
**Files scanned:** 15 source files read directly.
|
||||||
|
|
||||||
|
**Pattern extraction date:** 2026-06-13
|
||||||
Reference in New Issue
Block a user