fix(19): satisfy CI fast-checks + secret scan
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0): - index.ts: disable no-unsafe-argument on the type-only Context mismatch when delegating to the OIDC handler inside the local-session skip wrapper - localAuth.ts: handleLogout is sync (no await) — drop async (require-await) - devBypass.ts: disable detect-possible-timing-attacks on the public well-known dev-placeholder string compare (not a secret comparison) - remove dead code / unused bindings flagged by no-unused-vars: makeTestApp (localSession.test), makeUnauthContext + BrowserContext import (login.spec), unused memberId (admin.test), unused txSelectCount counter (me.test) - localAuthMiddleware.test / me.test: fix unused + reflow-detached eslint-disable directives Format: prettier --write across the 20 Phase-19 files that were never formatted. Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key regex hit on "credential atomically, 409-equivalent"). Neither is a real secret. Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks), PWA 266/266, API 452/452. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91ab9d1f78
commit
b6490feff4
@@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
|
|||||||
[[allowlists]]
|
[[allowlists]]
|
||||||
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
|
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
|
||||||
paths = ['''apps/api/tests/routes/setup\.test\.ts''']
|
paths = ['''apps/api/tests/routes/setup\.test\.ts''']
|
||||||
|
|
||||||
|
[[allowlists]]
|
||||||
|
description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)"
|
||||||
|
paths = ['''apps/api/tests/auth/localSession\.test\.ts''']
|
||||||
|
|
||||||
|
[[allowlists]]
|
||||||
|
description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image"
|
||||||
|
paths = ['''\.planning/''']
|
||||||
|
|||||||
@@ -45,9 +45,14 @@ const KEY_LEN = 32;
|
|||||||
function hashPassword(password: string): string {
|
function hashPassword(password: string): string {
|
||||||
const salt = randomBytes(16);
|
const salt = randomBytes(16);
|
||||||
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
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(
|
return [
|
||||||
'$',
|
'scrypt',
|
||||||
);
|
SCRYPT_N,
|
||||||
|
SCRYPT_R,
|
||||||
|
SCRYPT_P,
|
||||||
|
salt.toString('base64url'),
|
||||||
|
hash.toString('base64url'),
|
||||||
|
].join('$');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
|
// ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
|
||||||
@@ -109,7 +114,9 @@ if (!dryRun && (!password || password.trim() === '')) {
|
|||||||
}
|
}
|
||||||
if (dryRun && !password) {
|
if (dryRun && !password) {
|
||||||
// In dry-run mode a placeholder password is acceptable — skip real validation
|
// In dry-run mode a placeholder password is acceptable — skip real validation
|
||||||
console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)');
|
console.log(
|
||||||
|
'[dry-run] Args validated: --username present, --dry-run active (no write will occur)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DB connection ─────────────────────────────────────────────────────────────────────────
|
// ── DB connection ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ export function devSessionCookieMiddleware(): MiddlewareHandler {
|
|||||||
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
|
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
|
||||||
// signature-valid session token minted under this known value is trivially forgeable
|
// signature-valid session token minted under this known value is trivially forgeable
|
||||||
// if the same secret ever leaks into a non-bypass environment.
|
// if the same secret ever leaks into a non-bypass environment.
|
||||||
|
// Not a security comparison — this matches against a PUBLIC well-known placeholder to
|
||||||
|
// emit a warning, so constant-time equality is irrelevant here.
|
||||||
|
// eslint-disable-next-line security/detect-possible-timing-attacks
|
||||||
if (secret === 'dev-secret-change-me-0000000000000000') {
|
if (secret === 'dev-secret-change-me-0000000000000000') {
|
||||||
console.warn(
|
console.warn(
|
||||||
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
|
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ app.get('/callback', async (c) => {
|
|||||||
return c.redirect('/?error=oidc-link-conflict');
|
return c.redirect('/?error=oidc-link-conflict');
|
||||||
}
|
}
|
||||||
// Unexpected error during link binding — log and continue with normal redirect.
|
// Unexpected error during link binding — log and continue with normal redirect.
|
||||||
console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err));
|
console.error(
|
||||||
|
'[callback] linkOidcToUser unexpected error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +196,9 @@ if (!devBypassActive) {
|
|||||||
await next();
|
await next();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
|
||||||
|
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
await oidcHandler(c, next);
|
await oidcHandler(c, next);
|
||||||
});
|
});
|
||||||
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
|
||||||
|
|||||||
@@ -140,77 +140,73 @@ const createMemberSchema = z.object({
|
|||||||
initialPassword: z.string().min(8),
|
initialPassword: z.string().min(8),
|
||||||
});
|
});
|
||||||
|
|
||||||
adminRouter.post(
|
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
|
||||||
'/members',
|
const { displayName, username, initialPassword } = c.req.valid('json');
|
||||||
zValidator('json', createMemberSchema, noEchoHook),
|
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
||||||
async (c) => {
|
|
||||||
const { displayName, username, initialPassword } = c.req.valid('json');
|
|
||||||
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
|
||||||
|
|
||||||
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
||||||
const usedRows = await db.select({ color: users.color }).from(users);
|
const usedRows = await db.select({ color: users.color }).from(users);
|
||||||
const usedColors = new Set(usedRows.map((r) => r.color));
|
const usedColors = new Set(usedRows.map((r) => r.color));
|
||||||
const color =
|
const color =
|
||||||
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
||||||
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
||||||
|
|
||||||
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
||||||
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
||||||
const initialPasswordHash = await hashPassword(initialPassword);
|
const initialPasswordHash = await hashPassword(initialPassword);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let newUserId: number;
|
let newUserId: number;
|
||||||
|
|
||||||
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
displayName,
|
displayName,
|
||||||
color,
|
color,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
claimed: false, // no OIDC identity bound yet
|
claimed: false, // no OIDC identity bound yet
|
||||||
})
|
})
|
||||||
.$returningId();
|
.$returningId();
|
||||||
newUserId = inserted.id;
|
newUserId = inserted.id;
|
||||||
|
|
||||||
// Insert local_credentials row with hashed initial password
|
// Insert local_credentials row with hashed initial password
|
||||||
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
||||||
await tx.insert(localCredentials).values({
|
await tx.insert(localCredentials).values({
|
||||||
userId: newUserId,
|
userId: newUserId,
|
||||||
username,
|
username,
|
||||||
passwordHash: initialPasswordHash,
|
passwordHash: initialPasswordHash,
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Return the new user's id (the PWA uses it to navigate to the member)
|
// Return the new user's id (the PWA uses it to navigate to the member)
|
||||||
return c.json({ id: newUserId! }, 201);
|
return c.json({ id: newUserId! }, 201);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
||||||
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
||||||
const isDup =
|
const isDup =
|
||||||
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
||||||
(err != null &&
|
(err != null &&
|
||||||
typeof err === 'object' &&
|
typeof err === 'object' &&
|
||||||
'code' in err &&
|
'code' in err &&
|
||||||
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
||||||
(err != null &&
|
(err != null &&
|
||||||
typeof err === 'object' &&
|
typeof err === 'object' &&
|
||||||
'cause' in err &&
|
'cause' in err &&
|
||||||
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
||||||
if (isDup) {
|
if (isDup) {
|
||||||
return c.json({ error: 'Username already in use' }, 409);
|
return c.json({ error: 'Username already in use' }, 409);
|
||||||
}
|
|
||||||
// Unexpected errors — log message only, never the body or password
|
|
||||||
console.error(
|
|
||||||
'[admin/POST /members] Unexpected error:',
|
|
||||||
err instanceof Error ? err.message : String(err),
|
|
||||||
);
|
|
||||||
return c.json({ error: 'Service unavailable' }, 503);
|
|
||||||
}
|
}
|
||||||
},
|
// Unexpected errors — log message only, never the body or password
|
||||||
);
|
console.error(
|
||||||
|
'[admin/POST /members] Unexpected error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/admin/members/:id/password
|
// POST /api/admin/members/:id/password
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
cred = found;
|
cred = found;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err));
|
console.error(
|
||||||
|
'[localAuth/POST /local/login] DB error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
return c.json({ error: 'Service unavailable' }, 503);
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +203,12 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
|||||||
|
|
||||||
if (!valid || !cred) {
|
if (!valid || !cred) {
|
||||||
// Increment failure counter (keyed on username)
|
// Increment failure counter (keyed on username)
|
||||||
const cur = loginAttempts.get(key) ?? { count: 0, lockedUntil: 0, lockedOut: false, lockedAt: 0 };
|
const cur = loginAttempts.get(key) ?? {
|
||||||
|
count: 0,
|
||||||
|
lockedUntil: 0,
|
||||||
|
lockedOut: false,
|
||||||
|
lockedAt: 0,
|
||||||
|
};
|
||||||
cur.count += 1;
|
cur.count += 1;
|
||||||
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
|
||||||
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
|
||||||
@@ -215,7 +223,10 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
|||||||
try {
|
try {
|
||||||
await issueLocalSessionCookie(c, cred.userId);
|
await issueLocalSessionCookie(c, cred.userId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err));
|
console.error(
|
||||||
|
'[localAuth/POST /local/login] Cookie issue error:',
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
return c.json({ error: 'Service unavailable' }, 503);
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
}
|
}
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
@@ -226,7 +237,7 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
|
|||||||
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
|
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function handleLogout(c: Context) {
|
function handleLogout(c: Context) {
|
||||||
clearLocalSessionCookie(c);
|
clearLocalSessionCookie(c);
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-47
@@ -105,7 +105,9 @@ meRouter.get('/', async (c) => {
|
|||||||
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
|
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
|
||||||
const devUser = c.get('user');
|
const devUser = c.get('user');
|
||||||
if (devUser) {
|
if (devUser) {
|
||||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id);
|
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
|
||||||
|
devUser.id,
|
||||||
|
);
|
||||||
return c.json({
|
return c.json({
|
||||||
user: {
|
user: {
|
||||||
id: devUser.id,
|
id: devUser.id,
|
||||||
@@ -141,7 +143,9 @@ meRouter.get('/', async (c) => {
|
|||||||
return c.json({ error: 'Could not resolve user' }, 500);
|
return c.json({ error: 'Could not resolve user' }, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id);
|
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
user: {
|
user: {
|
||||||
@@ -232,57 +236,53 @@ const mePasswordSchema = z.object({
|
|||||||
newPassword: z.string().min(8),
|
newPassword: z.string().min(8),
|
||||||
});
|
});
|
||||||
|
|
||||||
meRouter.post(
|
meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => {
|
||||||
'/password',
|
// T-19-07: ALWAYS resolve userId from session — never from body
|
||||||
zValidator('json', mePasswordSchema, meNoEchoHook),
|
const currentUserId = await resolveUserId(c);
|
||||||
async (c) => {
|
if (!currentUserId) {
|
||||||
// T-19-07: ALWAYS resolve userId from session — never from body
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
const currentUserId = await resolveUserId(c);
|
}
|
||||||
if (!currentUserId) {
|
|
||||||
return c.json({ error: 'Unauthorized' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { currentPassword, newPassword } = c.req.valid('json');
|
const { currentPassword, newPassword } = c.req.valid('json');
|
||||||
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
||||||
|
|
||||||
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
||||||
const [credRow] = await db
|
const [credRow] = await db
|
||||||
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
||||||
.from(localCredentials)
|
.from(localCredentials)
|
||||||
.where(eq(localCredentials.userId, currentUserId))
|
.where(eq(localCredentials.userId, currentUserId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!credRow) {
|
if (!credRow) {
|
||||||
return c.json({ error: 'No local credential found' }, 404);
|
return c.json({ error: 'No local credential found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// T-19-07: verify current password before any update (WR-03: async scrypt)
|
// T-19-07: verify current password before any update (WR-03: async scrypt)
|
||||||
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
|
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
|
||||||
if (!isCorrect) {
|
if (!isCorrect) {
|
||||||
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
|
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
|
||||||
// MutationCache treats any 401 as "session expired" and arms the re-auth
|
// MutationCache treats any 401 as "session expired" and arms the re-auth
|
||||||
// interstitial / login redirect — so a 401 here would force-log-out a user who
|
// interstitial / login redirect — so a 401 here would force-log-out a user who
|
||||||
// merely mistyped their current password. 403 is in-app authorization-failure and
|
// merely mistyped their current password. 403 is in-app authorization-failure and
|
||||||
// lets the client surface "current password incorrect" without dropping the session.
|
// lets the client surface "current password incorrect" without dropping the session.
|
||||||
return c.json({ error: 'Current password incorrect' }, 403);
|
return c.json({ error: 'Current password incorrect' }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db
|
await db
|
||||||
.update(localCredentials)
|
.update(localCredentials)
|
||||||
.set({ passwordHash: await hashPassword(newPassword) })
|
.set({ passwordHash: await hashPassword(newPassword) })
|
||||||
.where(eq(localCredentials.userId, currentUserId));
|
.where(eq(localCredentials.userId, currentUserId));
|
||||||
|
|
||||||
return c.json({ ok: true }, 200);
|
return c.json({ ok: true }, 200);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
'[me/POST /password] Unexpected error:',
|
'[me/POST /password] Unexpected error:',
|
||||||
err instanceof Error ? err.message : String(err),
|
err instanceof Error ? err.message : String(err),
|
||||||
);
|
);
|
||||||
return c.json({ error: 'Service unavailable' }, 503);
|
return c.json({ error: 'Service unavailable' }, 503);
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
|
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
|
||||||
|
|||||||
@@ -24,7 +24,13 @@
|
|||||||
|
|
||||||
import { afterEach } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
import { db } from '../src/db/client.js';
|
import { db } from '../src/db/client.js';
|
||||||
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js';
|
import {
|
||||||
|
lists,
|
||||||
|
listItems,
|
||||||
|
listShares,
|
||||||
|
pushSubscriptions,
|
||||||
|
localCredentials,
|
||||||
|
} from '../src/db/schema.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Truncate list and push tables in FK-safe order after each test.
|
* Truncate list and push tables in FK-safe order after each test.
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
||||||
app.use('/api/*', middleware());
|
app.use('/api/*', middleware());
|
||||||
|
|
||||||
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
let capturedUser: unknown = 'NOT_SET_SENTINEL';
|
||||||
@@ -147,7 +146,13 @@ describe('localAuthMiddleware', () => {
|
|||||||
const res = await app.request('/api/test');
|
const res = await app.request('/api/test');
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(capturedUser).toBeDefined();
|
expect(capturedUser).toBeDefined();
|
||||||
const u = capturedUser as { id: number; oidcIss: string; oidcSub: string; displayName: string | null; color: string };
|
const u = capturedUser as {
|
||||||
|
id: number;
|
||||||
|
oidcIss: string;
|
||||||
|
oidcSub: string;
|
||||||
|
displayName: string | null;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
expect(u.id).toBe(7);
|
expect(u.id).toBe(7);
|
||||||
expect(u.oidcIss).toBe('https://auth.example.com');
|
expect(u.oidcIss).toBe('https://auth.example.com');
|
||||||
expect(u.oidcSub).toBe('sub-abc');
|
expect(u.oidcSub).toBe('sub-abc');
|
||||||
|
|||||||
@@ -18,33 +18,6 @@ import { Hono } from 'hono';
|
|||||||
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
|
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
|
||||||
const TEST_USER_ID = 42;
|
const TEST_USER_ID = 42;
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Create a minimal Hono test app with an issue route and a verify route. */
|
|
||||||
function makeTestApp(secret: string | undefined) {
|
|
||||||
return {
|
|
||||||
setup: async () => {
|
|
||||||
// Import inside function to pick up modified env
|
|
||||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
|
||||||
'../../src/auth/localSession.js'
|
|
||||||
);
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
app.post('/issue', async (c) => {
|
|
||||||
await issueLocalSessionCookie(c, TEST_USER_ID);
|
|
||||||
return c.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/verify', async (c) => {
|
|
||||||
const userId = await verifyLocalSessionCookie(c);
|
|
||||||
return c.json({ userId });
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
||||||
let originalEnv: NodeJS.ProcessEnv;
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
|
|
||||||
@@ -60,9 +33,8 @@ describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Test 1: issue then verify round-trips userId', async () => {
|
it('Test 1: issue then verify round-trips userId', async () => {
|
||||||
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
|
const { issueLocalSessionCookie, verifyLocalSessionCookie } =
|
||||||
'../../src/auth/localSession.js'
|
await import('../../src/auth/localSession.js');
|
||||||
);
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.post('/issue', async (c) => {
|
app.post('/issue', async (c) => {
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '../../src/db/client.js';
|
import { db } from '../../src/db/client.js';
|
||||||
import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js';
|
import {
|
||||||
|
users,
|
||||||
|
memberCredentials,
|
||||||
|
calendars,
|
||||||
|
appConfig,
|
||||||
|
localCredentials,
|
||||||
|
} from '../../src/db/schema.js';
|
||||||
import { verifyPassword } from '../../src/auth/localCredentials.js';
|
import { verifyPassword } from '../../src/auth/localCredentials.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -949,7 +955,8 @@ describe('POST /api/admin/members', () => {
|
|||||||
|
|
||||||
it('Test 3: admin can reset any member password without knowing the current one', async () => {
|
it('Test 3: admin can reset any member password without knowing the current one', async () => {
|
||||||
const adminId = await seedUser('admin-reset-pw', true);
|
const adminId = await seedUser('admin-reset-pw', true);
|
||||||
const memberId = await seedUser('member-reset-target', false);
|
// Seeded for DB-state parity; this test creates its own member via the admin API below.
|
||||||
|
await seedUser('member-reset-target', false);
|
||||||
currentDevUserId = adminId;
|
currentDevUserId = adminId;
|
||||||
const app = await getApp();
|
const app = await getApp();
|
||||||
|
|
||||||
@@ -1025,7 +1032,9 @@ describe('POST /api/admin/members', () => {
|
|||||||
// GET /members should show hasLocalCredential:true for this member
|
// GET /members should show hasLocalCredential:true for this member
|
||||||
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
const body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> };
|
const body = (await getRes.json()) as {
|
||||||
|
members: Array<{ id: number; hasLocalCredential: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
const memberRow = body.members.find((m) => m.id === newMemberId);
|
const memberRow = body.members.find((m) => m.id === newMemberId);
|
||||||
expect(memberRow).toBeDefined();
|
expect(memberRow).toBeDefined();
|
||||||
|
|||||||
@@ -47,15 +47,13 @@ vi.mock('@hono/oidc-auth', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
devAuthBypass:
|
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
||||||
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||||
localAuthMiddleware:
|
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ vi.mock('../../src/db/client.js', () => ({
|
|||||||
select: vi.fn().mockImplementation(() => ({
|
select: vi.fn().mockImplementation(() => ({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockImplementation(() =>
|
limit: vi
|
||||||
Promise.resolve(mockCredRow ? [mockCredRow] : [])
|
.fn()
|
||||||
),
|
.mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
@@ -57,14 +57,12 @@ let issuedUserId: number | null = null;
|
|||||||
let clearSessionCalled = false;
|
let clearSessionCalled = false;
|
||||||
|
|
||||||
vi.mock('../../src/auth/localSession.js', () => ({
|
vi.mock('../../src/auth/localSession.js', () => ({
|
||||||
issueLocalSessionCookie: vi.fn().mockImplementation(
|
issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => {
|
||||||
(_c: unknown, userId: number) => {
|
issueSessionCalled = true;
|
||||||
issueSessionCalled = true;
|
issuedUserId = userId;
|
||||||
issuedUserId = userId;
|
// Simulate setting a cookie on the context
|
||||||
// Simulate setting a cookie on the context
|
return Promise.resolve();
|
||||||
return Promise.resolve();
|
}),
|
||||||
}
|
|
||||||
),
|
|
||||||
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
|
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
|
||||||
clearSessionCalled = true;
|
clearSessionCalled = true;
|
||||||
}),
|
}),
|
||||||
@@ -76,15 +74,13 @@ vi.mock('../../src/auth/localSession.js', () => ({
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||||
devAuthBypass:
|
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
||||||
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
|
||||||
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
|
||||||
localAuthMiddleware:
|
localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@hono/oidc-auth', () => ({
|
vi.mock('@hono/oidc-auth', () => ({
|
||||||
@@ -195,7 +191,9 @@ describe('POST /api/auth/local/login', () => {
|
|||||||
mockCredRow = undefined; // No credential row found
|
mockCredRow = undefined; // No credential row found
|
||||||
|
|
||||||
const app = await getApp();
|
const app = await getApp();
|
||||||
const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }));
|
const res = await app.fetch(
|
||||||
|
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
|
||||||
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = (await res.json()) as { error: string };
|
const body = (await res.json()) as { error: string };
|
||||||
@@ -212,14 +210,14 @@ describe('POST /api/auth/local/login', () => {
|
|||||||
// 5 failures to trigger the rate window
|
// 5 failures to trigger the rate window
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
const res = await app.fetch(
|
const res = await app.fetch(
|
||||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6th attempt from same IP → 429
|
// 6th attempt from same IP → 429
|
||||||
const res6 = await app.fetch(
|
const res6 = await app.fetch(
|
||||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1')
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
|
||||||
);
|
);
|
||||||
expect(res6.status).toBe(429);
|
expect(res6.status).toBe(429);
|
||||||
const body = (await res6.json()) as { error: string };
|
const body = (await res6.json()) as { error: string };
|
||||||
@@ -233,14 +231,12 @@ describe('POST /api/auth/local/login', () => {
|
|||||||
|
|
||||||
// 10 failures from same IP → lockout
|
// 10 failures from same IP → lockout
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
await app.fetch(
|
await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'));
|
||||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11th attempt → 423 (locked)
|
// 11th attempt → 423 (locked)
|
||||||
const res11 = await app.fetch(
|
const res11 = await app.fetch(
|
||||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
||||||
);
|
);
|
||||||
expect(res11.status).toBe(423);
|
expect(res11.status).toBe(423);
|
||||||
const body = (await res11.json()) as { error: string };
|
const body = (await res11.json()) as { error: string };
|
||||||
@@ -252,7 +248,7 @@ describe('POST /api/auth/local/login', () => {
|
|||||||
loginAttempts.delete('alice');
|
loginAttempts.delete('alice');
|
||||||
|
|
||||||
const resAfterReset = await app.fetch(
|
const resAfterReset = await app.fetch(
|
||||||
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
|
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
|
||||||
);
|
);
|
||||||
expect(resAfterReset.status).toBe(401);
|
expect(resAfterReset.status).toBe(401);
|
||||||
});
|
});
|
||||||
@@ -295,7 +291,7 @@ describe('POST /api/auth/local/login', () => {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
|
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
|
||||||
body: JSON.stringify({ username: 'mysecretusername' }),
|
body: JSON.stringify({ username: 'mysecretusername' }),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -319,7 +315,7 @@ describe('POST /api/auth/local/logout', () => {
|
|||||||
new Request('http://localhost/api/auth/local/logout', {
|
new Request('http://localhost/api/auth/local/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'x-forwarded-for': '1.2.3.4' },
|
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -336,7 +332,7 @@ describe('GET /api/auth/local/logout', () => {
|
|||||||
new Request('http://localhost/api/auth/local/logout', {
|
new Request('http://localhost/api/auth/local/logout', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'x-forwarded-for': '1.2.3.4' },
|
headers: { 'x-forwarded-for': '1.2.3.4' },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -290,16 +290,20 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
|||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
||||||
}),
|
}),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
// fallback for other selects
|
// fallback for other selects
|
||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -346,7 +350,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
|||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
|
||||||
}),
|
}),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -354,7 +360,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
|||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -386,13 +394,18 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
|
|||||||
it('Test 3: user with no local_credentials row → 404', async () => {
|
it('Test 3: user with no local_credentials row → 404', async () => {
|
||||||
const { db } = await import('../../src/db/client.js');
|
const { db } = await import('../../src/db/client.js');
|
||||||
|
|
||||||
vi.mocked(db.select).mockImplementation(() => ({
|
vi.mocked(db.select).mockImplementation(
|
||||||
from: vi.fn().mockReturnValue({
|
() =>
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
({
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
from: vi.fn().mockReturnValue({
|
||||||
}),
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
} as any));
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
|
||||||
const { app } = await import('../../src/index.js');
|
const { app } = await import('../../src/index.js');
|
||||||
const res = await app.request('/api/me/password', {
|
const res = await app.request('/api/me/password', {
|
||||||
@@ -457,7 +470,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
|||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -483,7 +498,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
|
|||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any;
|
} as any;
|
||||||
@@ -520,14 +537,14 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
|||||||
let deletedLocalCreds = false;
|
let deletedLocalCreds = false;
|
||||||
|
|
||||||
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
|
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
|
||||||
let txSelectCount = 0;
|
|
||||||
const mockTx = {
|
const mockTx = {
|
||||||
select: vi.fn().mockImplementation(() => {
|
select: vi.fn().mockImplementation(() => {
|
||||||
txSelectCount++;
|
|
||||||
return {
|
return {
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -545,18 +562,25 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mocked(db.select).mockImplementation(() => ({
|
vi.mocked(db.select).mockImplementation(
|
||||||
from: vi.fn().mockReturnValue({
|
() =>
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
|
({
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
from: vi.fn().mockReturnValue({
|
||||||
}),
|
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
} as any));
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
vi.mocked(db).transaction = vi
|
||||||
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
.fn()
|
||||||
await fn(mockTx);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
});
|
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||||
|
await fn(mockTx);
|
||||||
|
});
|
||||||
|
|
||||||
await linkOidcToUser(42, iss, sub);
|
await linkOidcToUser(42, iss, sub);
|
||||||
|
|
||||||
@@ -574,15 +598,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
|||||||
let deletedLocalCreds = false;
|
let deletedLocalCreds = false;
|
||||||
|
|
||||||
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
|
// Preflight SELECT finds a conflicting user (id=99, different from target=42)
|
||||||
vi.mocked(db.select).mockImplementation(() => ({
|
vi.mocked(db.select).mockImplementation(
|
||||||
from: vi.fn().mockReturnValue({
|
() =>
|
||||||
where: vi.fn().mockReturnValue({
|
({
|
||||||
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
|
from: vi.fn().mockReturnValue({
|
||||||
}),
|
where: vi.fn().mockReturnValue({
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
} as any));
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
|
||||||
// Transaction should NEVER be called on conflict
|
// Transaction should NEVER be called on conflict
|
||||||
const mockTx = {
|
const mockTx = {
|
||||||
@@ -593,10 +622,12 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
vi.mocked(db).transaction = vi
|
||||||
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
.fn()
|
||||||
await fn(mockTx);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
});
|
.mockImplementation(async (fn: (tx: any) => Promise<void>) => {
|
||||||
|
await fn(mockTx);
|
||||||
|
});
|
||||||
|
|
||||||
// Should throw OidcLinkConflictError, not proceed to transaction
|
// Should throw OidcLinkConflictError, not proceed to transaction
|
||||||
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
|
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
|
||||||
@@ -611,13 +642,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
|||||||
const { db } = await import('../../src/db/client.js');
|
const { db } = await import('../../src/db/client.js');
|
||||||
|
|
||||||
// Mock db — not needed for route shape test but avoids errors
|
// Mock db — not needed for route shape test but avoids errors
|
||||||
vi.mocked(db.select).mockImplementation(() => ({
|
vi.mocked(db.select).mockImplementation(
|
||||||
from: vi.fn().mockReturnValue({
|
() =>
|
||||||
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
|
({
|
||||||
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }),
|
from: vi.fn().mockReturnValue({
|
||||||
}),
|
where: vi
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
.fn()
|
||||||
} as any));
|
.mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
|
||||||
|
innerJoin: vi.fn().mockReturnValue({
|
||||||
|
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
|
);
|
||||||
|
|
||||||
const { app } = await import('../../src/index.js');
|
const { app } = await import('../../src/index.js');
|
||||||
const res = await app.request('/api/me/link-oidc', {
|
const res = await app.request('/api/me/link-oidc', {
|
||||||
@@ -630,7 +668,8 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
|
|||||||
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
|
const body = (await res.json()) as { authorizationUrl?: string; state?: string };
|
||||||
// The response must have at minimum a signedState field (or authorizationUrl)
|
// The response must have at minimum a signedState field (or authorizationUrl)
|
||||||
// — the exact shape depends on implementation; assert it's an object with a useful field
|
// — the exact shape depends on implementation; assert it's an object with a useful field
|
||||||
const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body;
|
const hasInitiationPayload =
|
||||||
|
'authorizationUrl' in body || 'state' in body || 'signedState' in body;
|
||||||
expect(hasInitiationPayload).toBe(true);
|
expect(hasInitiationPayload).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
* pnpm --filter @familysync/pwa test:e2e --grep "login"
|
* pnpm --filter @familysync/pwa test:e2e --grep "login"
|
||||||
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
|
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
|
||||||
*/
|
*/
|
||||||
import { test, expect, type BrowserContext } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
|
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
|
||||||
const SELECTORS = {
|
const SELECTORS = {
|
||||||
@@ -43,32 +43,6 @@ const SELECTORS = {
|
|||||||
errorMessage: '[role="status"]',
|
errorMessage: '[role="status"]',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Build an unauthenticated browser context by clearing all cookies and storage.
|
|
||||||
* The devSessionCookieMiddleware issues a new local-session cookie on each API
|
|
||||||
* request, so we need to clear the cookie from the BROWSER side. Navigating to
|
|
||||||
* a page that clears the cookie header is the reliable approach in Playwright.
|
|
||||||
*/
|
|
||||||
async function makeUnauthContext(
|
|
||||||
context: BrowserContext,
|
|
||||||
baseURL: string,
|
|
||||||
): Promise<void> {
|
|
||||||
// Clear all cookies (removes the local-session cookie set by prior API calls)
|
|
||||||
await context.clearCookies();
|
|
||||||
// Also clear localStorage/sessionStorage to avoid any cached auth state
|
|
||||||
const page = await context.newPage();
|
|
||||||
try {
|
|
||||||
// Navigate somewhere to gain origin access, then clear storage
|
|
||||||
await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {});
|
|
||||||
await page.evaluate(() => {
|
|
||||||
try { localStorage.clear(); } catch { /* cross-origin or unavailable */ }
|
|
||||||
try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ }
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await page.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only run these specs on the desktop profile. The login form is a standard web
|
// Only run these specs on the desktop profile. The login form is a standard web
|
||||||
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
|
// page (not PWA-specific) and Chromium handles cookies most consistently for this test.
|
||||||
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
|
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
|
||||||
@@ -78,9 +52,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
|
|||||||
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
|
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
|
||||||
);
|
);
|
||||||
|
|
||||||
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({
|
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
|
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
|
||||||
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
|
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
|
||||||
// injection regardless of the cookie, so visiting / lands on /calendar and a
|
// injection regardless of the cookie, so visiting / lands on /calendar and a
|
||||||
@@ -129,10 +101,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
|
|||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('correct devuser/devpass logs in and navigates out of /login', async ({
|
test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => {
|
||||||
page,
|
|
||||||
context,
|
|
||||||
}) => {
|
|
||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
|
|
||||||
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
||||||
|
|||||||
@@ -195,10 +195,7 @@ export default function App() {
|
|||||||
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
|
Phase 19: shown when the user is unauthenticated AND localEnabled === true.
|
||||||
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
|
The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
|
||||||
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
|
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
|
||||||
<Route
|
<Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
|
||||||
path="/login"
|
|
||||||
element={<LoginPage authMode={authModeQuery.data} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* All other routes are gated on setup completion */}
|
{/* All other routes are gated on setup completion */}
|
||||||
<Route
|
<Route
|
||||||
@@ -213,7 +210,10 @@ export default function App() {
|
|||||||
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
|
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
|
||||||
// Unauthenticated + localEnabled: redirect to /login
|
// Unauthenticated + localEnabled: redirect to /login
|
||||||
<Navigate to="/login" replace />
|
<Navigate to="/login" replace />
|
||||||
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? (
|
) : meQuery.isError &&
|
||||||
|
!meQuery.isLoading &&
|
||||||
|
!authModeQuery.data?.localEnabled &&
|
||||||
|
authModeQuery.data?.oidcEnabled ? (
|
||||||
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
|
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
|
||||||
// Use a render side-effect via useEffect isn't available here; use a helper element
|
// Use a render side-effect via useEffect isn't available here; use a helper element
|
||||||
<OidcRedirect />
|
<OidcRedirect />
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnab
|
|||||||
*
|
*
|
||||||
* Throws nothing on 200 OK — the local-session cookie is set by the server.
|
* Throws nothing on 200 OK — the local-session cookie is set by the server.
|
||||||
*/
|
*/
|
||||||
export async function fetchLocalLogin(body: {
|
export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const res = await fetch('/api/auth/local/login', {
|
const res = await fetch('/api/auth/local/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
|
|||||||
// Mock client so the test doesn't make real network calls.
|
// Mock client so the test doesn't make real network calls.
|
||||||
vi.mock('../api/client.js', () => ({
|
vi.mock('../api/client.js', () => ({
|
||||||
fetchMe: vi.fn().mockResolvedValue({
|
fetchMe: vi.fn().mockResolvedValue({
|
||||||
user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false },
|
user: {
|
||||||
|
id: 1,
|
||||||
|
displayName: 'Test',
|
||||||
|
color: '#4a90d9',
|
||||||
|
isAdmin: false,
|
||||||
|
needsProviderSetup: false,
|
||||||
|
hasLocalCredential: false,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
|
||||||
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
|
fetchChangePassword: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -500,10 +500,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
|
|||||||
|
|
||||||
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
|
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
|
||||||
{linkOidcOpen && (
|
{linkOidcOpen && (
|
||||||
<LinkOidcSheet
|
<LinkOidcSheet isOpen={linkOidcOpen} onClose={() => setLinkOidcOpen(false)} />
|
||||||
isOpen={linkOidcOpen}
|
|
||||||
onClose={() => setLinkOidcOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -935,7 +932,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
|
|||||||
color: 'var(--color-text-secondary, #6b7280)',
|
color: 'var(--color-text-secondary, #6b7280)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."}
|
{
|
||||||
|
"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Secondary note */}
|
{/* Secondary note */}
|
||||||
|
|||||||
@@ -1241,8 +1241,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isPending = resetMutation.isPending;
|
const isPending = resetMutation.isPending;
|
||||||
const submitDisabled =
|
const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
||||||
isPending || newPassword.length === 0 || confirmPassword.length === 0;
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -146,10 +146,7 @@ export function LoginPage({ authMode }: LoginPageProps) {
|
|||||||
const isLoading = loginMutation.isPending;
|
const isLoading = loginMutation.isPending;
|
||||||
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
|
const bothNonEmpty = username.trim().length > 0 && password.length > 0;
|
||||||
const submitDisabled =
|
const submitDisabled =
|
||||||
isLoading ||
|
isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked';
|
||||||
!bothNonEmpty ||
|
|
||||||
loginError === 'rate-limit' ||
|
|
||||||
loginError === 'locked';
|
|
||||||
|
|
||||||
// Derive whether inputs should show error state
|
// Derive whether inputs should show error state
|
||||||
const inputHasError = loginError === 'invalid';
|
const inputHasError = loginError === 'invalid';
|
||||||
|
|||||||
@@ -94,11 +94,11 @@
|
|||||||
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
|
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
|
||||||
* ───────────────────────────────────────────────────────────────────────── */
|
* ───────────────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */
|
||||||
--brand-logo-text: #ffffff; /* placeholder initials color */
|
--brand-logo-text: #ffffff; /* placeholder initials color */
|
||||||
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
|
||||||
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
|
||||||
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
|
||||||
|
|
||||||
/* ─────────────────────────────────────────────────────────────────────────
|
/* ─────────────────────────────────────────────────────────────────────────
|
||||||
* BREAKPOINTS (reference; use in @media queries)
|
* BREAKPOINTS (reference; use in @media queries)
|
||||||
|
|||||||
Reference in New Issue
Block a user