# 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`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, ): Promise ``` **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 ``` 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 } /> } /> } /> } /> ``` **Add `/admin` route** with inline redirect guard: ```typescript import { AdminPage } from './routes/AdminPage.js'; // ... : } /> ``` **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 { 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; } ``` 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(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
``` **Password input pattern** (UI-SPEC — never pre-filled, `type="password"`, `autocomplete="new-password"`): ```tsx 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
{/* KeyRound icon + heading + body + CTA */}
``` --- ## 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 { 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: ({ ...tabBase, ...(isActive ? tabActiveOverride : {}), })}> ``` **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