1092 lines
38 KiB
Markdown
1092 lines
38 KiB
Markdown
# Phase 19: Local Auth (No-OIDC Mode) - Pattern Map
|
||
|
||
**Mapped:** 2026-06-17
|
||
**Files analyzed:** 20 (new/modified)
|
||
**Analogs found:** 20 / 20
|
||
|
||
---
|
||
|
||
## File Classification
|
||
|
||
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||
|---|---|---|---|---|
|
||
| `apps/api/src/auth/localCredentials.ts` | utility | transform | `apps/api/src/auth/user.ts` | role-match |
|
||
| `apps/api/src/auth/localSession.ts` | utility | request-response | `apps/api/src/auth/persistSessionCookie.ts` | exact |
|
||
| `apps/api/src/auth/localAuthMiddleware.ts` | middleware | request-response | `apps/api/src/auth/devBypass.ts` | exact |
|
||
| `apps/api/src/routes/authMode.ts` | route | request-response | `apps/api/src/routes/setup.ts` (GET /status) | exact |
|
||
| `apps/api/src/routes/localAuth.ts` | route | request-response | `apps/api/src/routes/setup.ts` (POST /credential) | exact |
|
||
| `apps/api/src/db/schema.ts` (modified) | model | CRUD | itself — `memberCredentials` block (lines 74–94) | exact |
|
||
| `apps/api/src/db/migrations/0003_local_credentials.sql` | migration | batch | existing `0002` migration | exact |
|
||
| `apps/api/src/routes/admin.ts` (modified) | route | CRUD | itself — `POST /credentials` + `GET /members` (lines 83–132) | exact |
|
||
| `apps/api/src/routes/me.ts` (modified) | route | request-response | itself — `POST /credential` + `GET /` (lines 88–202) | exact |
|
||
| `apps/api/src/index.ts` (modified) | config | request-response | itself — middleware ordering block (lines 31–73) | exact |
|
||
| `apps/api/src/auth/middleware.ts` (modified) | middleware | request-response | itself | exact |
|
||
| `apps/api/src/lib/bootGuards.ts` (modified) | utility | request-response | itself (lines 1–34) | exact |
|
||
| `apps/api/scripts/reset-admin.ts` | utility | CRUD | `apps/pwa/e2e/global-setup.ts` seed pattern | role-match |
|
||
| `apps/pwa/src/routes/LoginPage.tsx` | component | request-response | `apps/pwa/src/routes/SetupPage.tsx` | exact |
|
||
| `apps/pwa/src/components/BrandSlot.tsx` | component | — | `apps/pwa/src/routes/SetupPage.tsx` (header block) | role-match |
|
||
| `apps/pwa/src/routes/AdminPage.tsx` (modified) | component | CRUD | itself — `CredentialSheet` + section pattern (lines 42–100) | exact |
|
||
| `apps/pwa/src/components/SettingsSheet.tsx` (modified) | component | request-response | itself + `CredentialSheet.tsx` | exact |
|
||
| `apps/pwa/src/App.tsx` (modified) | component | request-response | itself — `setupQuery` gate + `/setup` route (lines 72–167) | exact |
|
||
| `apps/pwa/src/api/client.ts` (modified) | utility | request-response | itself — `fetchMe`, `handleAuthResponse` pattern (lines 51–84) | exact |
|
||
| `apps/pwa/e2e/global-setup.ts` (modified) | test | batch | itself (lines 119–148) | exact |
|
||
|
||
---
|
||
|
||
## Pattern Assignments
|
||
|
||
---
|
||
|
||
### `apps/api/src/auth/localCredentials.ts` (utility, transform)
|
||
|
||
**Analog:** `apps/api/src/auth/user.ts`
|
||
|
||
**Imports pattern** (user.ts lines 11–13):
|
||
```typescript
|
||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||
import { db } from '../db/client.js';
|
||
import { users, appConfig } from '../db/schema.js';
|
||
```
|
||
New file will substitute:
|
||
```typescript
|
||
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
||
// No npm deps — pure stdlib
|
||
```
|
||
|
||
**Core pattern** — PHC-encoded hash (from RESEARCH.md §Password Hashing, runtime-verified):
|
||
```typescript
|
||
const SCRYPT_N = 16384;
|
||
const SCRYPT_R = 8;
|
||
const SCRYPT_P = 1;
|
||
const KEY_LEN = 32;
|
||
|
||
export function hashPassword(password: string): string {
|
||
const salt = randomBytes(16);
|
||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
||
return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join('$');
|
||
}
|
||
|
||
export function verifyPassword(storedEncoded: string, candidate: string): boolean {
|
||
try {
|
||
const [, n, r, p, saltB64, hashB64] = storedEncoded.split('$');
|
||
const salt = Buffer.from(saltB64, 'base64url');
|
||
const storedHash = Buffer.from(hashB64, 'base64url');
|
||
const candidateHash = scryptSync(candidate, salt, storedHash.length, { N: Number(n), r: Number(r), p: Number(p) });
|
||
return timingSafeEqual(storedHash, candidateHash);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
**No error types defined here** — all errors returned as boolean false (timing-safe contract).
|
||
|
||
---
|
||
|
||
### `apps/api/src/auth/localSession.ts` (utility, request-response)
|
||
|
||
**Analog:** `apps/api/src/auth/persistSessionCookie.ts`
|
||
|
||
**Imports pattern** (persistSessionCookie.ts lines 24–26):
|
||
```typescript
|
||
import type { MiddlewareHandler } from 'hono';
|
||
import { setCookie } from 'hono/cookie';
|
||
```
|
||
New file extends to:
|
||
```typescript
|
||
import { Jwt } from 'hono/utils/jwt';
|
||
import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
|
||
import type { Context } from 'hono';
|
||
```
|
||
|
||
**Core cookie-issue pattern** — mirrors persistSessionCookie.ts (lines 55–77) but issues a new JWT rather than re-issuing an existing one:
|
||
```typescript
|
||
const COOKIE_NAME = 'local-session';
|
||
const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
|
||
|
||
export async function issueLocalSessionCookie(c: Context, userId: number): Promise<void> {
|
||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||
if (!secret) throw new Error('LOCAL_SESSION_SECRET env var not set');
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const token = await Jwt.sign({ userId, iat: now, exp: now + SESSION_MAX_AGE_SECONDS }, secret, 'HS256');
|
||
setCookie(c, COOKIE_NAME, token, {
|
||
httpOnly: true,
|
||
secure: process.env.NODE_ENV === 'production',
|
||
sameSite: 'Lax',
|
||
path: '/',
|
||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||
});
|
||
}
|
||
```
|
||
|
||
**Verify pattern** — wraps Jwt.verify in try/catch (Pitfall 9 — throws on expiry):
|
||
```typescript
|
||
export async function verifyLocalSessionCookie(c: Context): Promise<number | null> {
|
||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||
if (!secret) return null;
|
||
const token = getCookie(c, COOKIE_NAME);
|
||
if (!token) return null;
|
||
try {
|
||
const payload = await Jwt.verify(token, secret, 'HS256');
|
||
return typeof payload.userId === 'number' ? payload.userId : null;
|
||
} catch {
|
||
return null; // includes JwtTokenExpired
|
||
}
|
||
}
|
||
```
|
||
|
||
**Clear pattern** — mirrors cookie attribute set on issue:
|
||
```typescript
|
||
export function clearLocalSessionCookie(c: Context): void {
|
||
deleteCookie(c, COOKIE_NAME, { path: '/', httpOnly: true, secure: true, sameSite: 'Lax' });
|
||
}
|
||
```
|
||
|
||
**Cookie name:** `local-session` — distinct from OIDC cookie `oidc-auth` (persistSessionCookie.ts line 55: `process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'`).
|
||
|
||
---
|
||
|
||
### `apps/api/src/auth/localAuthMiddleware.ts` (middleware, request-response)
|
||
|
||
**Analog:** `apps/api/src/auth/devBypass.ts`
|
||
|
||
**Imports pattern** (devBypass.ts lines 27–28):
|
||
```typescript
|
||
import type { MiddlewareHandler } from 'hono';
|
||
import { COLOR_PALETTE } from './user.js';
|
||
```
|
||
New file:
|
||
```typescript
|
||
import type { MiddlewareHandler } from 'hono';
|
||
import { eq } from 'drizzle-orm';
|
||
import { db } from '../db/client.js';
|
||
import { users } from '../db/schema.js';
|
||
import { verifyLocalSessionCookie } from './localSession.js';
|
||
```
|
||
|
||
**`c.set('user', ...)` pattern** — must produce same shape as `DEV_USER` (devBypass.ts lines 30–36):
|
||
```typescript
|
||
export const DEV_USER = {
|
||
id: 1,
|
||
oidcIss: 'dev',
|
||
oidcSub: 'dev-user',
|
||
displayName: 'Dev User',
|
||
color: COLOR_PALETTE[0],
|
||
} as const;
|
||
// ContextVariableMap declares user: typeof DEV_USER
|
||
```
|
||
The new middleware must call `c.set('user', { id, oidcIss, oidcSub, displayName, color })` with the same shape — fetched from the `users` table by the `userId` from the JWT.
|
||
|
||
**Core middleware pattern** (mirrors devBypass.ts lines 58–76):
|
||
```typescript
|
||
export function localAuthMiddleware(): MiddlewareHandler {
|
||
return async (c, next) => {
|
||
const userId = await verifyLocalSessionCookie(c);
|
||
if (!userId) {
|
||
await next();
|
||
return;
|
||
}
|
||
// Load user row to populate the same shape as DEV_USER
|
||
const [row] = await db.select({ ... }).from(users).where(eq(users.id, userId)).limit(1);
|
||
if (!row) { await next(); return; }
|
||
c.set('user', { id: row.id, oidcIss: row.oidcIss ?? '', oidcSub: row.oidcSub ?? '', displayName: row.displayName ?? null, color: row.color });
|
||
await next();
|
||
};
|
||
}
|
||
```
|
||
|
||
**Key rule:** Must be a no-op (call `next()`) when no `local-session` cookie is present — never set `c.get('user')` to undefined (Pitfall 1: OIDC guard redirects only when `c.get('user')` is falsy, so leaving it unset is correct fall-through behavior).
|
||
|
||
---
|
||
|
||
### `apps/api/src/routes/authMode.ts` (route, request-response)
|
||
|
||
**Analog:** `apps/api/src/routes/setup.ts` — `GET /status` (lines 86–95)
|
||
|
||
**Imports pattern** (setup.ts lines 29–41):
|
||
```typescript
|
||
import { Hono } from 'hono';
|
||
import { eq } from 'drizzle-orm';
|
||
import { db } from '../db/client.js';
|
||
import { appConfig } from '../db/schema.js';
|
||
```
|
||
|
||
**Pre-auth pattern** — same pattern as `setupRouter.get('/status', ...)`: no `isSetupLocked()` gate, no auth middleware, always reachable:
|
||
```typescript
|
||
export const authModeRouter = new Hono();
|
||
|
||
authModeRouter.get('/', async (c) => {
|
||
const issuerFromEnv = process.env.OIDC_ISSUER;
|
||
let oidcEnabled = Boolean(issuerFromEnv);
|
||
if (!oidcEnabled) {
|
||
const [row] = await db.select({ value: appConfig.value }).from(appConfig)
|
||
.where(eq(appConfig.key, 'oidc_issuer')).limit(1);
|
||
oidcEnabled = Boolean(row?.value);
|
||
}
|
||
return c.json({ localEnabled: true, oidcEnabled });
|
||
});
|
||
```
|
||
|
||
**Mount position in index.ts:** Before `app.use('/api/*', devAuthBypass())` — same position as `app.route('/api/setup', setupRouter)` (index.ts line 49).
|
||
|
||
---
|
||
|
||
### `apps/api/src/routes/localAuth.ts` (route, request-response)
|
||
|
||
**Analog:** `apps/api/src/routes/setup.ts` — `POST /credential` + `noEchoHook` pattern (lines 44–53, 73–77)
|
||
|
||
**Imports pattern** (setup.ts lines 29–39):
|
||
```typescript
|
||
import { Hono } from 'hono';
|
||
import type { Context } from 'hono';
|
||
import { zValidator } from '@hono/zod-validator';
|
||
import { z } from 'zod';
|
||
import { eq } from 'drizzle-orm';
|
||
import { db } from '../db/client.js';
|
||
```
|
||
|
||
**noEchoHook pattern** (setup.ts lines 49–53) — copy verbatim:
|
||
```typescript
|
||
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||
if (!result.success) {
|
||
return c.json({ error: 'Invalid request' }, 400);
|
||
}
|
||
};
|
||
```
|
||
|
||
**zValidator usage** (admin.ts line 112):
|
||
```typescript
|
||
localAuthRouter.post('/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
|
||
const { username, password } = c.req.valid('json');
|
||
// ...
|
||
});
|
||
```
|
||
|
||
**Success response pattern** (me.ts line 201, admin.ts line 131):
|
||
```typescript
|
||
return c.json({ ok: true }, 200);
|
||
```
|
||
|
||
**Error response pattern** (admin.ts lines 119–130):
|
||
```typescript
|
||
if (err instanceof SomeError) {
|
||
return c.json({ error: 'Invalid request' }, 400);
|
||
}
|
||
console.error('[localAuth/POST /login] Unexpected error:', err instanceof Error ? err.message : String(err));
|
||
return c.json({ error: 'Service unavailable' }, 503);
|
||
```
|
||
|
||
**Rate-limiting:** In-memory Map — no analog in codebase; pattern is from RESEARCH.md §Rate Limiting. See RESEARCH.md for the full `loginAttempts` Map implementation.
|
||
|
||
**Logout route** — uses `clearLocalSessionCookie(c)` then:
|
||
```typescript
|
||
return c.json({ ok: true }, 200);
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/db/schema.ts` (modified — additive) (model, CRUD)
|
||
|
||
**Analog:** `memberCredentials` block in schema.ts (lines 74–94) — direct template.
|
||
|
||
**Pattern to copy** (schema.ts lines 74–94):
|
||
```typescript
|
||
export const memberCredentials = mysqlTable(
|
||
'member_credentials',
|
||
{
|
||
id: int().primaryKey().autoincrement(),
|
||
userId: int('user_id')
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: 'cascade' }),
|
||
encryptedPassword: text('encrypted_password').notNull(),
|
||
fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
|
||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||
providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav'),
|
||
},
|
||
(t) => [
|
||
index('idx_member_credentials_user_id').on(t.userId),
|
||
unique('uniq_member_credential_user').on(t.userId),
|
||
],
|
||
);
|
||
```
|
||
|
||
**New `localCredentials` table** — replace `encryptedPassword`/`fastmailEmail`/`providerType` with `username` + `passwordHash`, add a second `unique` on `username`:
|
||
```typescript
|
||
export const localCredentials = mysqlTable(
|
||
'local_credentials',
|
||
{
|
||
id: int().primaryKey().autoincrement(),
|
||
userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||
username: varchar('username', { length: 128 }).notNull(),
|
||
passwordHash: varchar('password_hash', { length: 256 }).notNull(),
|
||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||
},
|
||
(t) => [
|
||
unique('uniq_local_cred_user').on(t.userId),
|
||
unique('uniq_local_cred_username').on(t.username),
|
||
index('idx_local_credentials_user_id').on(t.userId),
|
||
],
|
||
);
|
||
```
|
||
|
||
**Import additions needed** (schema.ts line 1–14): `varchar` and `timestamp` already imported; `int`, `unique`, `index` already imported — no new imports required.
|
||
|
||
**Export rule:** Add `localCredentials` to the existing named exports so `test/setup.ts` can truncate it.
|
||
|
||
---
|
||
|
||
### `apps/api/src/routes/admin.ts` (modified — additive) (route, CRUD)
|
||
|
||
**Analog:** itself — `POST /credentials` (lines 112–132) and `GET /members` (lines 83–102).
|
||
|
||
**Admin guard pattern** (admin.ts line 42) — already applies to all new routes via `adminRouter.use('*', requireAdmin)`:
|
||
```typescript
|
||
adminRouter.use('*', requireAdmin); // FIRST statement; never move this
|
||
```
|
||
|
||
**noEchoHook pattern** (admin.ts lines 70–74) — reuse existing:
|
||
```typescript
|
||
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||
if (!result.success) {
|
||
return c.json({ error: 'Invalid request' }, 400);
|
||
}
|
||
};
|
||
```
|
||
|
||
**Transaction pattern for create-member** (admin.ts lines 170–183 — `PUT /calendars/:id/shared` uses `db.transaction`):
|
||
```typescript
|
||
await db.transaction(async (tx) => {
|
||
// 1. INSERT into users
|
||
// 2. INSERT into local_credentials
|
||
});
|
||
```
|
||
|
||
**`GET /members` LEFT JOIN extension** (admin.ts lines 83–101) — extend the existing query to add `hasLocalCredential`:
|
||
```typescript
|
||
const rows = await db
|
||
.select({
|
||
id: users.id,
|
||
displayName: users.displayName,
|
||
color: users.color,
|
||
credentialId: memberCredentials.id,
|
||
localCredId: localCredentials.id, // NEW — LEFT JOIN
|
||
})
|
||
.from(users)
|
||
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id))
|
||
.leftJoin(localCredentials, eq(localCredentials.userId, users.id)); // NEW
|
||
|
||
const members = rows.map((row) => ({
|
||
id: row.id,
|
||
displayName: row.displayName,
|
||
color: row.color,
|
||
hasCredential: row.credentialId !== null,
|
||
hasLocalCredential: row.localCredId !== null, // NEW
|
||
}));
|
||
```
|
||
|
||
**Conflict (409) pattern** — not currently in codebase; use:
|
||
```typescript
|
||
return c.json({ error: 'Username already in use' }, 409);
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/src/routes/me.ts` (modified — additive) (route, request-response)
|
||
|
||
**Analog:** itself — `POST /credential` (lines 154–202) and `resolveUserId` (lines 74–86).
|
||
|
||
**`resolveUserId` pattern** (me.ts lines 74–86) — unchanged; new `POST /password` route calls it the same way:
|
||
```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;
|
||
// ... upsertUser
|
||
}
|
||
```
|
||
|
||
**`meNoEchoHook` pattern** (me.ts lines 164–168) — copy for password route:
|
||
```typescript
|
||
const meNoEchoHook = (result: { success: boolean }, c: Context) => {
|
||
if (!result.success) {
|
||
return c.json({ error: 'Invalid request' }, 400);
|
||
}
|
||
};
|
||
```
|
||
|
||
**`resolveAdminAndSetupStatus` extension** — add `hasLocalCredential` (after existing `cred` query pattern at lines 50–66):
|
||
```typescript
|
||
const [localCred] = await db
|
||
.select({ id: localCredentials.id })
|
||
.from(localCredentials)
|
||
.where(eq(localCredentials.userId, userId))
|
||
.limit(1);
|
||
// Add hasLocalCredential: Boolean(localCred) to the return object
|
||
```
|
||
|
||
**Response shape extension** (me.ts lines 93–104 / 129–139) — add `hasLocalCredential` alongside `isAdmin`, `needsProviderSetup`.
|
||
|
||
**Self-change password route** — zValidator + noEchoHook + resolveUserId + error pattern identical to `POST /credential` (lines 170–202).
|
||
|
||
---
|
||
|
||
### `apps/api/src/index.ts` (modified) (config, request-response)
|
||
|
||
**Analog:** itself — middleware ordering block (lines 31–73).
|
||
|
||
**Current middleware chain** (index.ts lines 49–73):
|
||
```typescript
|
||
app.route('/api/setup', setupRouter); // pre-auth
|
||
|
||
app.use('/api/*', devAuthBypass());
|
||
|
||
if (!devBypassActive) {
|
||
app.use('/api/*', oidcConfigFallbackMiddleware);
|
||
app.use('/api/*', oidcAuthMiddleware());
|
||
app.use('/api/*', persistSessionCookie());
|
||
}
|
||
```
|
||
|
||
**New chain** — insert `authModeRouter`, `localAuthRouter`, `localAuthMiddleware`, and OIDC guard wrapper:
|
||
```typescript
|
||
app.route('/api/setup', setupRouter); // pre-auth (unchanged)
|
||
app.route('/api/auth', authModeRouter); // GET /api/auth/mode — pre-auth
|
||
app.route('/api/auth', localAuthRouter); // POST /api/auth/local/login, /logout — pre-auth
|
||
|
||
app.use('/api/*', devAuthBypass()); // unchanged
|
||
app.use('/api/*', localAuthMiddleware()); // NEW — sets c.get('user') from local-session cookie
|
||
|
||
if (!devBypassActive) {
|
||
app.use('/api/*', oidcConfigFallbackMiddleware);
|
||
// OIDC guard: skip if user already set by localAuthMiddleware or devAuthBypass
|
||
app.use('/api/*', async (c, next) => {
|
||
if (c.get('user')) { await next(); return; }
|
||
await oidcAuthMiddleware()(c, next);
|
||
});
|
||
app.use('/api/*', persistSessionCookie());
|
||
}
|
||
```
|
||
|
||
**`devBypassActive` computation** (index.ts lines 31–32) — unchanged; `localAuthMiddleware` is always mounted (it's a no-op when no cookie is present).
|
||
|
||
**Boot guard extension** (index.ts lines 134–136) — add `LOCAL_SESSION_SECRET` assertion alongside `assertNotDevBypassInProduction()`.
|
||
|
||
---
|
||
|
||
### `apps/api/src/auth/middleware.ts` (modified) (middleware, request-response)
|
||
|
||
**Analog:** itself.
|
||
|
||
**Change scope:** Comment-only de-Authelia-ization (D-06). Line 5 header comment "Authelia as the identity provider" → "generic OIDC identity provider". Inline comments referencing "Authelia base URL" → "OIDC issuer URL". No runtime behavior changes.
|
||
|
||
---
|
||
|
||
### `apps/api/src/lib/bootGuards.ts` (modified) (utility, request-response)
|
||
|
||
**Analog:** itself (lines 26–34).
|
||
|
||
**Existing pattern** — copy structure for new `LOCAL_SESSION_SECRET` guard:
|
||
```typescript
|
||
export function assertNotDevBypassInProduction(): void {
|
||
if (process.env.NODE_ENV === 'production' && process.env.DEV_AUTH_BYPASS === 'true') {
|
||
console.error('[FATAL] DEV_AUTH_BYPASS=true is set in a production environment. ...');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
**New guard to add** — same pattern, different env var:
|
||
```typescript
|
||
export function assertLocalSessionSecretSet(): void {
|
||
// Only required when not in dev-bypass mode (bypass doesn't issue local-session cookies)
|
||
if (process.env.DEV_AUTH_BYPASS === 'true') return;
|
||
const secret = process.env.LOCAL_SESSION_SECRET;
|
||
if (!secret || secret.length < 32) {
|
||
console.error('[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters. Refusing to start.');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/api/scripts/reset-admin.ts` (utility, CRUD)
|
||
|
||
**Analog:** `apps/pwa/e2e/global-setup.ts` — direct DB seed pattern (lines 95–148).
|
||
|
||
**DB connection pattern** (global-setup.ts lines 95–101):
|
||
```typescript
|
||
import mysql from 'mysql2/promise';
|
||
|
||
const conn = await mysql.createConnection({
|
||
host: process.env.DB_HOST ?? '127.0.0.1',
|
||
port: Number(process.env.DB_PORT ?? 3306),
|
||
user: process.env.DB_USER ?? 'familysync',
|
||
password: process.env.DB_PASSWORD ?? '',
|
||
database: process.env.DB_NAME ?? 'familysync',
|
||
});
|
||
```
|
||
|
||
**Idempotent upsert pattern** (global-setup.ts lines 125–129):
|
||
```typescript
|
||
await conn.execute(
|
||
`INSERT INTO users (id, ...) VALUES (1, ...) ON DUPLICATE KEY UPDATE is_admin=true`,
|
||
);
|
||
```
|
||
|
||
**Dev-only guard** — same pattern as global-setup.ts lines 34–44:
|
||
```typescript
|
||
if (process.env.NODE_ENV === 'production') {
|
||
throw new Error('reset-admin refused: NODE_ENV=production.');
|
||
}
|
||
```
|
||
|
||
**CLI arg parsing** — use `process.argv` directly (no new deps):
|
||
```typescript
|
||
const args = Object.fromEntries(
|
||
process.argv.slice(2).reduce<string[][]>((acc, arg, i, arr) => {
|
||
if (arg.startsWith('--')) acc.push([arg.slice(2), arr[i + 1] ?? '']);
|
||
return acc;
|
||
}, [])
|
||
);
|
||
const { username, password } = args;
|
||
```
|
||
|
||
**hashPassword inline** — copy the 5-line scrypt implementation inline (cannot import compiled TS — same constraint as global-setup.ts "Plain Node.js only" pattern). Use `import { scryptSync, randomBytes } from 'node:crypto'` directly.
|
||
|
||
---
|
||
|
||
### `apps/pwa/src/routes/LoginPage.tsx` (component, request-response)
|
||
|
||
**Analog:** `apps/pwa/src/routes/SetupPage.tsx`
|
||
|
||
**Page shell styles** (SetupPage.tsx lines 60–84) — copy verbatim, adjust `maxWidth`:
|
||
```typescript
|
||
const pageStyle: React.CSSProperties = {
|
||
minHeight: '100dvh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'flex-start',
|
||
background: 'var(--color-surface, #ffffff)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
color: 'var(--color-text-primary, #111318)',
|
||
};
|
||
|
||
const contentColStyle: React.CSSProperties = {
|
||
maxWidth: '400px', // LoginPage: 400px, not 540px (UI-SPEC Surface 1)
|
||
width: '100%',
|
||
margin: '0 auto',
|
||
padding: 'var(--space-12, 48px) var(--space-6, 24px)',
|
||
};
|
||
|
||
const cardStyle: React.CSSProperties = {
|
||
background: 'var(--color-surface, #ffffff)',
|
||
border: '1px solid var(--color-border, #e2e4e9)',
|
||
borderRadius: '8px',
|
||
padding: 'var(--space-6, 24px)',
|
||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||
};
|
||
```
|
||
|
||
**Button styles** (SetupPage.tsx lines 86–113) — copy verbatim:
|
||
```typescript
|
||
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
|
||
background: disabled ? 'var(--color-border, #e2e4e9)' : 'var(--color-member-0, #4a90d9)',
|
||
color: '#ffffff',
|
||
border: 'none',
|
||
cursor: disabled ? 'default' : 'pointer',
|
||
fontSize: 'var(--text-label-size, 13px)',
|
||
fontWeight: 600,
|
||
minHeight: '44px',
|
||
minWidth: '44px',
|
||
padding: '0 var(--space-6, 24px)',
|
||
borderRadius: 'var(--space-1, 4px)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
transition: 'background 0.15s ease',
|
||
});
|
||
|
||
const ghostBtnStyle: React.CSSProperties = {
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
fontSize: 'var(--text-label-size, 13px)',
|
||
fontWeight: 600,
|
||
color: 'var(--color-text-secondary, #6b7280)',
|
||
minHeight: '44px',
|
||
minWidth: '44px',
|
||
padding: '0 var(--space-4, 16px)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
borderRadius: 'var(--space-1, 4px)',
|
||
};
|
||
```
|
||
|
||
**Input style** (SetupPage.tsx lines 115–126) — copy verbatim (has `hasError` variant):
|
||
```typescript
|
||
const inputStyle = (hasError: boolean): React.CSSProperties => ({
|
||
width: '100%',
|
||
boxSizing: 'border-box',
|
||
padding: 'var(--space-3, 12px) var(--space-4, 16px)',
|
||
border: `1px solid ${hasError ? 'var(--color-destructive, #dc2626)' : 'var(--color-border, #e2e4e9)'}`,
|
||
borderRadius: 'var(--space-1, 4px)',
|
||
fontSize: 'var(--text-body-size, 15px)',
|
||
color: 'var(--color-text-primary, #111318)',
|
||
background: 'var(--color-surface, #ffffff)',
|
||
fontFamily: 'var(--font-family-base)',
|
||
outline: 'none',
|
||
});
|
||
|
||
const labelStyle: React.CSSProperties = {
|
||
display: 'block',
|
||
fontSize: 'var(--text-label-size, 13px)',
|
||
fontWeight: 600,
|
||
color: 'var(--color-text-primary, #111318)',
|
||
marginBottom: 'var(--space-1, 4px)',
|
||
};
|
||
```
|
||
|
||
**Mutation + error state pattern** (SetupPage.tsx `useMutation` + `useState` for error):
|
||
```typescript
|
||
import { useState } from 'react';
|
||
import { useMutation } from '@tanstack/react-query';
|
||
import { Loader2, AlertCircle, Eye, EyeOff, ShieldCheck } from 'lucide-react';
|
||
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [showPassword, setShowPassword] = useState(false);
|
||
const [loginError, setLoginError] = useState<'invalid' | 'rate-limit' | 'locked' | 'server' | null>(null);
|
||
|
||
const loginMutation = useMutation({
|
||
mutationFn: () => fetchLocalLogin({ username, password }),
|
||
onSuccess: () => { window.location.replace('/'); },
|
||
onError: (err) => {
|
||
if (err instanceof LoginError) {
|
||
setLoginError(err.code);
|
||
} else {
|
||
setLoginError('server');
|
||
}
|
||
},
|
||
});
|
||
```
|
||
|
||
**Loader2 spinner pattern** (SetupPage.tsx — inline during pending):
|
||
```tsx
|
||
{loginMutation.isPending && <Loader2 size={16} style={{ animation: 'spin 1s linear infinite', marginRight: 6 }} />}
|
||
```
|
||
|
||
**No AppNav / BottomTabBar** — same constraint as SetupPage: this component renders standalone; `App.tsx` routes `/login` outside the normal app shell.
|
||
|
||
**Password show/hide toggle** — new pattern (no analog in codebase); position: relative wrapper + absolute button at right:
|
||
```tsx
|
||
<div style={{ position: 'relative' }}>
|
||
<input
|
||
type={showPassword ? 'text' : 'password'}
|
||
style={{ ...inputStyle(hasError), paddingRight: 44 }}
|
||
...
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||
aria-pressed={showPassword}
|
||
onClick={() => setShowPassword(!showPassword)}
|
||
style={{ position: 'absolute', right: 0, top: 0, height: '100%', minWidth: 44, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)' }}
|
||
>
|
||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||
</button>
|
||
</div>
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/pwa/src/components/BrandSlot.tsx` (component, n/a)
|
||
|
||
**Analog:** `apps/pwa/src/routes/SetupPage.tsx` — ShieldCheck icon header pattern
|
||
|
||
**Pattern:** A standalone component with no props (initially); renders a placeholder circle with "FS" initials above the login card. Phase 17 replaces internals only.
|
||
|
||
```tsx
|
||
export function BrandSlot() {
|
||
return (
|
||
<div style={{ textAlign: 'center' }}>
|
||
{/* Phase 17 replaces this div with <img> */}
|
||
<div style={{
|
||
width: 'var(--brand-logo-size, 48px)',
|
||
height: 'var(--brand-logo-size, 48px)',
|
||
borderRadius: 'var(--brand-logo-border-radius, 50%)',
|
||
background: 'var(--brand-logo-bg, var(--color-member-0, #4a90d9))',
|
||
color: 'var(--brand-logo-text, #ffffff)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
margin: '0 auto var(--space-2, 8px)',
|
||
fontSize: 'var(--text-display-size, 24px)',
|
||
fontWeight: 600,
|
||
fontFamily: 'var(--font-family-base)',
|
||
}}>
|
||
FS
|
||
</div>
|
||
<h1 style={{ fontSize: 'var(--text-display-size, 24px)', fontWeight: 600, lineHeight: 1.2, color: 'var(--color-text-primary)', marginTop: 'var(--space-2, 8px)', marginBottom: 'var(--space-1, 4px)' }}>
|
||
FamilySync
|
||
</h1>
|
||
<p style={{ fontSize: 'var(--text-body-size, 15px)', fontWeight: 400, color: 'var(--color-text-secondary)', marginBottom: 'var(--space-8, 32px)' }}>
|
||
Family calendar & lists
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/pwa/src/routes/AdminPage.tsx` (modified) (component, CRUD)
|
||
|
||
**Analog:** itself — `sectionLabelStyle` (line 42–49) and `CredentialSheet` open pattern (lines 53–100).
|
||
|
||
**Section label style** (AdminPage.tsx lines 42–49) — copy verbatim for "LOCAL ACCOUNTS" section:
|
||
```typescript
|
||
const sectionLabelStyle: React.CSSProperties = {
|
||
fontSize: 'var(--text-label-size, 13px)',
|
||
fontWeight: 600,
|
||
color: 'var(--color-text-muted)',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.06em',
|
||
marginBottom: 'var(--space-2, 8px)',
|
||
};
|
||
```
|
||
|
||
**Sheet open/close pattern** (AdminPage.tsx lines 55–59 + `triggerRef` pattern):
|
||
```typescript
|
||
const [resetSheetOpen, setResetSheetOpen] = useState(false);
|
||
const [resetTargetMember, setResetTargetMember] = useState<AdminMember | null>(null);
|
||
const resetTriggerRef = useRef<HTMLButtonElement>(null);
|
||
```
|
||
|
||
**Query + mutation pattern** (AdminPage.tsx lines 76–81):
|
||
```typescript
|
||
const membersQuery = useQuery({
|
||
queryKey: ['admin', 'members'],
|
||
queryFn: fetchAdminMembers,
|
||
retry: false,
|
||
staleTime: 60 * 1000,
|
||
});
|
||
```
|
||
|
||
**Invalidate on success** (CredentialSheet.tsx lines 136–139):
|
||
```typescript
|
||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||
```
|
||
|
||
**Member row action button pattern** (follows existing "Rotate"/"Add credential" button pattern in the member row map):
|
||
```tsx
|
||
{member.hasLocalCredential && (
|
||
<button
|
||
ref={member === sheetMember ? resetTriggerRef : undefined}
|
||
onClick={() => { setResetTargetMember(member); setResetSheetOpen(true); }}
|
||
style={ghostBtnStyle}
|
||
>
|
||
Reset password
|
||
</button>
|
||
)}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/pwa/src/components/SettingsSheet.tsx` (modified) (component, request-response)
|
||
|
||
**Analog:** itself + `CredentialSheet.tsx`
|
||
|
||
**Bottom sheet structure** (SettingsSheet.tsx lines 146–178) — "Change password" and "Link OIDC" open nested sheets using the same backdrop + dialog pattern:
|
||
```tsx
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Settings"
|
||
style={{
|
||
position: 'fixed',
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
background: 'var(--color-surface-raised, #ffffff)',
|
||
borderRadius: '12px 12px 0 0',
|
||
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
|
||
padding: 'var(--space-6, 24px)',
|
||
zIndex: 301,
|
||
fontFamily: 'var(--font-family-base, system-ui, sans-serif)',
|
||
maxWidth: '480px',
|
||
margin: '0 auto',
|
||
}}
|
||
>
|
||
```
|
||
|
||
**Escape key pattern** (SettingsSheet.tsx lines 69–76):
|
||
```typescript
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||
document.addEventListener('keydown', onKeyDown);
|
||
return () => document.removeEventListener('keydown', onKeyDown);
|
||
}, [isOpen, onClose]);
|
||
```
|
||
|
||
**Focus-on-open pattern** (CredentialSheet.tsx lines 97–102):
|
||
```typescript
|
||
useEffect(() => {
|
||
if (isOpen && headingRef.current) {
|
||
headingRef.current.focus();
|
||
}
|
||
}, [isOpen]);
|
||
```
|
||
|
||
**Mutation error state pattern** (CredentialSheet.tsx lines 141–144):
|
||
```typescript
|
||
onError: () => {
|
||
setValidationError(FAILURE_TEXT);
|
||
},
|
||
```
|
||
|
||
**Conditional row render** — "Change password" row shown only when `hasLocalCredential`:
|
||
```tsx
|
||
{meData?.user.hasLocalCredential && (
|
||
<button onClick={() => setChangePasswordOpen(true)} style={settingsRowStyle}>
|
||
Change password
|
||
</button>
|
||
)}
|
||
```
|
||
|
||
**New `hasLocalCredential` from `/api/me`** — already flows through `meQuery` in App.tsx; pass as prop or read from `useQuery(['me'])` inside the sheet.
|
||
|
||
---
|
||
|
||
### `apps/pwa/src/App.tsx` (modified) (component, request-response)
|
||
|
||
**Analog:** itself — `setupQuery` gate (lines 72–79, 140–167).
|
||
|
||
**Auth mode query pattern** — mirrors `setupQuery` (App.tsx lines 72–79):
|
||
```typescript
|
||
const authModeQuery = useQuery({
|
||
queryKey: ['authMode'],
|
||
queryFn: () => fetch('/api/auth/mode').then(r => r.json()) as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>,
|
||
retry: false,
|
||
staleTime: 60_000, // auth mode changes rarely
|
||
});
|
||
```
|
||
|
||
**Gate logic** — mirrors the `setupComplete` gate (App.tsx lines 140–167); add login gate after setup gate:
|
||
```tsx
|
||
// After setup gate, before normal app shell:
|
||
// If user is not authenticated (meQuery.isError with 401) AND localEnabled:
|
||
// render <Navigate to="/login" replace />
|
||
// If user is not authenticated AND !localEnabled AND oidcEnabled:
|
||
// top-level redirect to /api/login (OIDC flow)
|
||
```
|
||
|
||
**`/login` route** — same structure as `/setup` route (App.tsx lines 156–167):
|
||
```tsx
|
||
<Route
|
||
path="/login"
|
||
element={<LoginPage authMode={authModeQuery.data} />}
|
||
/>
|
||
```
|
||
|
||
**No AppNav/BottomTabBar on `/login`** — same constraint as `/setup`: login route is a sibling of the `*` route, rendered standalone.
|
||
|
||
---
|
||
|
||
### `apps/pwa/src/api/client.ts` (modified) (utility, request-response)
|
||
|
||
**Analog:** itself — `fetchMe` (lines 74–84) and `handleAuthResponse` (lines 51–58).
|
||
|
||
**New fetch functions follow exact same pattern** as existing ones:
|
||
```typescript
|
||
export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnabled: boolean }> {
|
||
// No credentials:'include' needed — pre-auth endpoint
|
||
const res = await fetch('/api/auth/mode');
|
||
if (!res.ok) throw new Error(`fetchAuthMode failed: ${res.status}`);
|
||
return res.json() as Promise<{ localEnabled: boolean; oidcEnabled: boolean }>;
|
||
}
|
||
|
||
export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
|
||
const res = await fetch('/api/auth/local/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
redirect: 'manual',
|
||
body: JSON.stringify(body),
|
||
});
|
||
// 401, 429, 423 are typed errors — throw with code; caller checks instanceof
|
||
if (res.status === 401) throw new LoginError('invalid');
|
||
if (res.status === 429) throw new LoginError('rate-limit');
|
||
if (res.status === 423) throw new LoginError('locked');
|
||
if (!res.ok) throw new LoginError('server');
|
||
}
|
||
```
|
||
|
||
**Typed error class pattern** (client.ts lines 33–39 — `SessionExpiredError`):
|
||
```typescript
|
||
export class LoginError extends Error {
|
||
readonly name = 'LoginError';
|
||
constructor(public readonly code: 'invalid' | 'rate-limit' | 'locked' | 'server') {
|
||
super(`Login failed: ${code}`);
|
||
Object.setPrototypeOf(this, LoginError.prototype);
|
||
}
|
||
}
|
||
```
|
||
|
||
**`MeUser` interface extension** (client.ts lines 62–68) — add `hasLocalCredential`:
|
||
```typescript
|
||
export interface MeUser {
|
||
id: number;
|
||
displayName: string | null;
|
||
color: string;
|
||
isAdmin: boolean;
|
||
needsProviderSetup: boolean;
|
||
hasLocalCredential: boolean; // NEW
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### `apps/pwa/e2e/global-setup.ts` (modified) (test, batch)
|
||
|
||
**Analog:** itself — seed block (lines 119–148).
|
||
|
||
**Pattern to extend** — add after the existing `users` seed (lines 125–129) and `member_credentials` seed (lines 143–147):
|
||
```typescript
|
||
// Seed local_credentials for dev user (id=1) — Option C dev-bypass rework
|
||
// hashPassword is inlined (see Pitfall 11 — global-setup is plain Node.js, cannot import TS source)
|
||
// Pre-hash the dev password at known params and hard-code the encoded string, OR inline hashPassword:
|
||
import { scryptSync, randomBytes } from 'node:crypto';
|
||
function hashPasswordInline(password: string): string {
|
||
const salt = randomBytes(16);
|
||
const hash = scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||
return ['scrypt', 16384, 8, 1, salt.toString('base64url'), hash.toString('base64url')].join('$');
|
||
}
|
||
await conn.execute(
|
||
`INSERT INTO local_credentials (user_id, username, password_hash) VALUES (1, 'devuser', ?)
|
||
ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash)`,
|
||
[hashPasswordInline('devpass')]
|
||
);
|
||
```
|
||
|
||
**TRUNCATE extension** — add `local_credentials` to the existing TRUNCATE block (lines 106–109):
|
||
```typescript
|
||
await conn.execute('TRUNCATE TABLE local_credentials');
|
||
```
|
||
|
||
**Guard pattern** (global-setup.ts lines 34–44) — unchanged; existing `NODE_ENV === 'production'` and `DEV_AUTH_BYPASS !== 'true'` guards already cover the new seed.
|
||
|
||
---
|
||
|
||
## Shared Patterns
|
||
|
||
### Authentication middleware — `c.set('user', ...)` contract
|
||
**Source:** `apps/api/src/auth/devBypass.ts` (lines 30–48, 72–75)
|
||
**Apply to:** `localAuthMiddleware.ts`, all route files that read `c.get('user')`
|
||
|
||
The user object shape declared in `ContextVariableMap`:
|
||
```typescript
|
||
declare module 'hono' {
|
||
interface ContextVariableMap {
|
||
user: typeof DEV_USER; // { id, oidcIss, oidcSub, displayName, color }
|
||
}
|
||
}
|
||
```
|
||
`localAuthMiddleware` must produce a value assignable to this type. Import the type augmentation via `import '../auth/devBypass.js'` (side-effect import) in any file that reads `c.get('user')` — exactly as admin.ts (line 36) and me.ts (line 42) already do.
|
||
|
||
### noEchoHook — password routes
|
||
**Source:** `apps/api/src/routes/setup.ts` (lines 49–53), `apps/api/src/routes/admin.ts` (lines 70–74)
|
||
**Apply to:** `localAuth.ts` (login), `admin.ts` (create-member, reset-password), `me.ts` (change-password)
|
||
|
||
```typescript
|
||
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
||
if (!result.success) {
|
||
return c.json({ error: 'Invalid request' }, 400);
|
||
}
|
||
};
|
||
```
|
||
Never return `result.error` — Zod's error object echoes `issues[].received` which may contain the submitted password.
|
||
|
||
### Error response pattern
|
||
**Source:** `apps/api/src/routes/admin.ts` (lines 119–130), `apps/api/src/routes/me.ts` (lines 191–198)
|
||
**Apply to:** all new API routes
|
||
|
||
```typescript
|
||
try {
|
||
// ... business logic
|
||
} catch (err) {
|
||
if (err instanceof KnownError) {
|
||
return c.json({ error: 'descriptive message' }, 4xx);
|
||
}
|
||
console.error('[routeFile/POST /endpoint] Unexpected error:', err instanceof Error ? err.message : String(err));
|
||
return c.json({ error: 'Service unavailable' }, 503);
|
||
}
|
||
```
|
||
|
||
### DB transaction pattern
|
||
**Source:** `apps/api/src/routes/admin.ts` (lines 170–183)
|
||
**Apply to:** `admin.ts` — `POST /members` (must insert `users` + `local_credentials` atomically)
|
||
|
||
```typescript
|
||
const found = await db.transaction(async (tx) => {
|
||
// 1. INSERT users
|
||
// 2. INSERT local_credentials
|
||
return true;
|
||
});
|
||
```
|
||
|
||
### requireAdmin guard
|
||
**Source:** `apps/api/src/lib/requireAdmin.ts` (lines 25–47)
|
||
**Apply to:** all new `adminRouter.*` routes (already covered by `adminRouter.use('*', requireAdmin)` — no new work needed)
|
||
|
||
### Bottom sheet (dialog) pattern
|
||
**Source:** `apps/pwa/src/components/SettingsSheet.tsx` (lines 146–178), `apps/pwa/src/components/CredentialSheet.tsx` (lines 86–113)
|
||
**Apply to:** `SettingsSheet.tsx` additions (Surfaces 12, 13), `AdminPage.tsx` addition (Surface 11B)
|
||
|
||
Key attributes: `role="dialog"`, `aria-modal="true"`, `aria-label`, Escape-close listener, focus-heading-on-open, focus-return-to-trigger-on-close.
|
||
|
||
### TanStack Query `useMutation` + cache invalidation
|
||
**Source:** `apps/pwa/src/components/CredentialSheet.tsx` (lines 115–144)
|
||
**Apply to:** all new PWA mutation surfaces (login, create-member, reset-password, change-password, link-OIDC)
|
||
|
||
```typescript
|
||
const mutation = useMutation({
|
||
mutationFn: async () => { /* fetch call */ },
|
||
onSuccess: () => {
|
||
void queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
|
||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||
handleClose();
|
||
},
|
||
onError: () => {
|
||
setError(FAILURE_TEXT);
|
||
},
|
||
});
|
||
```
|
||
|
||
### Drizzle generate+migrate (never push)
|
||
**Source:** CONTEXT.md §Established Patterns + RESEARCH.md §Migration Workflow
|
||
**Apply to:** `local_credentials` migration only
|
||
|
||
```bash
|
||
pnpm --filter @familysync/api db:generate # → 0003_local_credentials.sql
|
||
pnpm --filter @familysync/api db:migrate
|
||
```
|
||
Review generated SQL before applying — must be purely additive (CREATE TABLE only).
|
||
|
||
---
|
||
|
||
## No Analog Found
|
||
|
||
All files have close analogs. No entries.
|
||
|
||
---
|
||
|
||
## Metadata
|
||
|
||
**Analog search scope:** `apps/api/src/` (auth/, routes/, db/, lib/), `apps/pwa/src/` (routes/, components/, api/), `apps/pwa/e2e/`
|
||
**Files read:** 18 source files
|
||
**Pattern extraction date:** 2026-06-17
|