38 KiB
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):
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:
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
// No npm deps — pure stdlib
Core pattern — PHC-encoded hash (from RESEARCH.md §Password Hashing, runtime-verified):
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):
import type { MiddlewareHandler } from 'hono';
import { setCookie } from 'hono/cookie';
New file extends to:
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:
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):
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:
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):
import type { MiddlewareHandler } from 'hono';
import { COLOR_PALETTE } from './user.js';
New file:
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):
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):
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):
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:
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):
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:
const noEchoHook = (result: { success: boolean }, c: Context) => {
if (!result.success) {
return c.json({ error: 'Invalid request' }, 400);
}
};
zValidator usage (admin.ts line 112):
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):
return c.json({ ok: true }, 200);
Error response pattern (admin.ts lines 119–130):
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:
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):
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:
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):
adminRouter.use('*', requireAdmin); // FIRST statement; never move this
noEchoHook pattern (admin.ts lines 70–74) — reuse existing:
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):
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:
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:
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:
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:
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):
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):
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:
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:
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:
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):
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):
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:
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):
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:
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:
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):
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):
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):
{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:
<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.
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:
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):
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):
const membersQuery = useQuery({
queryKey: ['admin', 'members'],
queryFn: fetchAdminMembers,
retry: false,
staleTime: 60 * 1000,
});
Invalidate on success (CredentialSheet.tsx lines 136–139):
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):
{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:
<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):
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):
useEffect(() => {
if (isOpen && headingRef.current) {
headingRef.current.focus();
}
}, [isOpen]);
Mutation error state pattern (CredentialSheet.tsx lines 141–144):
onError: () => {
setValidationError(FAILURE_TEXT);
},
Conditional row render — "Change password" row shown only when hasLocalCredential:
{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):
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:
// 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):
<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:
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):
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:
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):
// 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):
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:
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)
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
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)
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)
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
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