From 6b439102388b306ca7eb52b4a3129fa2b8e93bb5 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 16:11:46 +0800 Subject: [PATCH 01/15] Add test coverage and fix webauthn e2e tests to use real sessions - Add test:e2e:coverage script with Bun's built-in coverage support - Create bunfig.toml with coverage configuration (text + lcov reporters) - Fix webauthn tests to create real database sessions/login requests instead of mock context objects that bypass auth middleware - Add createUserAPIContext helper for cleaner test code - Update security tests to expect NOT_FOUND when accessing other user's passkeys Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + apps/api-server/bunfig.toml | 9 + apps/api-server/package.json | 2 +- .../src/__tests__/e2e/webauthn.test.ts | 215 +++++++++++------- db/schema.sql | 4 +- 5 files changed, 146 insertions(+), 87 deletions(-) create mode 100644 apps/api-server/bunfig.toml diff --git a/.gitignore b/.gitignore index 031c249..5437c4e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ devenv.local.nix # TypeScript *.tsbuildinfo +# Test coverage +coverage/ + # Debug npm-debug.log* yarn-debug.log* diff --git a/apps/api-server/bunfig.toml b/apps/api-server/bunfig.toml new file mode 100644 index 0000000..0dace52 --- /dev/null +++ b/apps/api-server/bunfig.toml @@ -0,0 +1,9 @@ +[test] +# Coverage reporters: text for console, lcov for CI/tooling integration +coverageReporter = ["text", "lcov"] + +# Output directory for lcov.info file +coverageDir = "coverage" + +# Don't count test files in coverage metrics +coverageSkipTestFiles = true diff --git a/apps/api-server/package.json b/apps/api-server/package.json index b16262f..d456074 100644 --- a/apps/api-server/package.json +++ b/apps/api-server/package.json @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint . --cache", "clean": "rm -rf dist .eslintcache", - "test:e2e": "bun test src/__tests__/e2e --no-parallel", + "test:e2e": "bun test src/__tests__/e2e --no-parallel --coverage", "test:unit": "bun test src/__tests__/unit" }, "dependencies": { diff --git a/apps/api-server/src/__tests__/e2e/webauthn.test.ts b/apps/api-server/src/__tests__/e2e/webauthn.test.ts index 0102999..996a414 100644 --- a/apps/api-server/src/__tests__/e2e/webauthn.test.ts +++ b/apps/api-server/src/__tests__/e2e/webauthn.test.ts @@ -9,15 +9,13 @@ import type { Database } from "@reviq/db-schema"; import type { Kysely } from "kysely"; -import type { - APIContext, - AuthenticatedContext, - LoginRequestContext, -} from "../../context.js"; -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { APIContext } from "../../context.js"; +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { call } from "@orpc/server"; import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; import { router } from "../../router.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.js"; import { getUserPasskeys } from "../../utils/webauthn.js"; import { KNOWN_AAGUIDS, TEST_RP } from "../helpers/test-constants.js"; import { @@ -28,6 +26,9 @@ import { truncateAllTables, } from "../helpers/test-db.js"; +/** Session expiry duration: 24 hours in milliseconds */ +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; + let db: Kysely | undefined; /** @@ -41,67 +42,93 @@ function getDb(): Kysely { } /** - * Create an API context (for public endpoints) + * Create an API context with optional session token */ -function createAPIContext(): APIContext { +function createAPIContext(sessionToken?: string): APIContext { + const reqHeaders = new Headers(); + if (sessionToken) { + reqHeaders.set("cookie", `${COOKIE_NAMES.SESSION_TOKEN}=${sessionToken}`); + } + return { db: getDb(), origin: TEST_RP.origin, allowedOrigins: [...TEST_RP.allowedOrigins], rpName: TEST_RP.rpName, - reqHeaders: new Headers(), + reqHeaders, resHeaders: new Headers(), }; } /** - * Create an authenticated context (for protected endpoints) + * Create a real session in the database and return the token */ -function createAuthenticatedContext( - userId: number, - email: string, -): AuthenticatedContext { - const now = new Date(); - return { - ...createAPIContext(), - user: { - id: userId, - email, - displayName: null, - emailVerifiedAt: null, - isSuperuser: false, - }, - session: { - id: "1", - trustedMode: false, - createdAt: now, - }, - auth: { - method: "session", - sessionId: "1", - expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), - createdAt: now, - }, - }; +async function createSession(userId: number): Promise { + const token = "test-session-" + String(Date.now()) + String(Math.random()); + const tokenHashValue = await hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); + + await getDb() + .insertInto("sessions") + .values({ + user_id: userId, + token_hash: tokenHashValue, + ip_address: "127.0.0.1", + user_agent: "test-agent", + expires_at: expiresAt, + trusted_mode: false, + }) + .execute(); + + return token; } /** - * Create a login request context (for login flow endpoints) + * Create a login request in the database and return ID and token */ -function createLoginRequestContext( +async function createLoginRequest( userId: number, email: string, -): LoginRequestContext { - return { - ...createAPIContext(), - loginRequestId: 1, - user: { - id: userId, +): Promise<{ id: number; token: string }> { + const token = "test-login-" + String(Date.now()) + String(Math.random()); + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + const result = await getDb() + .insertInto("login_requests") + .values({ + user_id: userId, email, - displayName: null, - emailVerifiedAt: null, - isSuperuser: false, - }, + token, + expires_at: expiresAt, + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { id: result.id, token }; +} + +/** + * Create an authenticated API context for a user (creates session + context) + */ +async function createUserAPIContext(userId: number): Promise { + const sessionToken = await createSession(userId); + return createAPIContext(sessionToken); +} + +/** + * Create an API context with login request cookie + */ +function createLoginRequestContext(loginToken: string): APIContext { + const reqHeaders = new Headers(); + reqHeaders.set("cookie", `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${loginToken}`); + + return { + db: getDb(), + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + reqHeaders, + resHeaders: new Headers(), }; } @@ -127,7 +154,7 @@ async function registerPasskey( authenticator: VirtualAuthenticator, ) { const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(userId, email); + const authCtx = await createUserAPIContext(userId); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -152,7 +179,8 @@ async function authenticate( email: string, authenticator: VirtualAuthenticator, ) { - const loginCtx = createLoginRequestContext(userId, email); + const { token: loginToken } = await createLoginRequest(userId, email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, @@ -234,8 +262,8 @@ describe("registration flow", () => { // Create credential with virtual authenticator const response = authenticator.createCredential(options); - // Verify registration via router - const authCtx = createAuthenticatedContext(user.id, user.email); + // Verify registration via router (requires authenticated session) + const authCtx = await createUserAPIContext(user.id); await call( router.auth.webauthn.verifyRegistration, { challengeId, response }, @@ -256,7 +284,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); // Register first passkey via router const { options: options1, challengeId: challengeId1 } = await call( @@ -299,7 +327,7 @@ describe("registration flow", () => { }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -325,7 +353,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); const { options, challengeId } = await call( router.auth.webauthn.createRegistrationOptions, @@ -355,7 +383,7 @@ describe("registration flow", () => { }); const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); const apiCtx = createAPIContext(); - const authCtx = createAuthenticatedContext(user.id, user.email); + const authCtx = await createUserAPIContext(user.id); // Create options via router const { options } = await call( @@ -399,7 +427,8 @@ describe("authentication flow", () => { ); // Create authentication options via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -427,7 +456,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -460,7 +490,8 @@ describe("authentication flow", () => { expect(firstPasskey.lastUsedAt).toBeNull(); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -489,7 +520,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Authenticate via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions, challengeId: authChallengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -522,7 +554,8 @@ describe("authentication flow", () => { await registerPasskey(user.id, user.email, authenticator); // Create auth options via router - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options: authOptions } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -585,7 +618,8 @@ describe("security tests", () => { authenticator.setSignCount(regResponse.id, 0); // Create a new authentication challenge - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -624,7 +658,8 @@ describe("security tests", () => { await registerPasskey(user.id, user.email, authenticator); // Create authentication challenge - const loginCtx = createLoginRequestContext(user.id, user.email); + const { token: loginToken } = await createLoginRequest(user.id, user.email); + const loginCtx = createLoginRequestContext(loginToken); const { options, challengeId } = await call( router.auth.webauthn.createAuthenticationOptions, undefined, @@ -755,7 +790,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator2); // List passkeys via router handler - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -806,7 +841,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -842,8 +877,8 @@ describe("passkey management", () => { await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user2.id, user2.email, auth2); - const ctx1 = createAuthenticatedContext(user1.id, user1.email); - const ctx2 = createAuthenticatedContext(user2.id, user2.email); + const ctx1 = await createUserAPIContext(user1.id); + const ctx2 = await createUserAPIContext(user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -853,12 +888,18 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to rename user2's passkey using user1's context (should not work) - await call( - router.me.passkeys.rename, - { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, - { context: ctx1 }, - ); + // Try to rename user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.rename, + { passkeyId: user2FirstPasskey.id, name: "Hacked Name" }, + { context: ctx1 }, + ); + throw new Error("Expected rename to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } // User2's passkey should be unchanged const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { @@ -880,7 +921,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -906,7 +947,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, auth1); await registerPasskey(user.id, user.email, auth2); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); let passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -937,7 +978,7 @@ describe("passkey management", () => { await registerPasskey(user.id, user.email, authenticator); - const ctx = createAuthenticatedContext(user.id, user.email); + const ctx = await createUserAPIContext(user.id); const passkeys = await call(router.me.passkeys.list, undefined, { context: ctx, }); @@ -978,8 +1019,8 @@ describe("passkey management", () => { await registerPasskey(user1.id, user1.email, auth1); await registerPasskey(user2.id, user2.email, auth2); - const ctx1 = createAuthenticatedContext(user1.id, user1.email); - const ctx2 = createAuthenticatedContext(user2.id, user2.email); + const ctx1 = await createUserAPIContext(user1.id); + const ctx2 = await createUserAPIContext(user2.id); const user2Passkeys = await call(router.me.passkeys.list, undefined, { context: ctx2, @@ -989,12 +1030,18 @@ describe("passkey management", () => { throw new Error("Expected user2 passkey to exist"); } - // Try to delete user2's passkey using user1's context (should not affect user2) - await call( - router.me.passkeys.delete, - { passkeyId: user2FirstPasskey.id }, - { context: ctx1 }, - ); + // Try to delete user2's passkey using user1's context (should throw NOT_FOUND) + try { + await call( + router.me.passkeys.delete, + { passkeyId: user2FirstPasskey.id }, + { context: ctx1 }, + ); + throw new Error("Expected delete to fail with NOT_FOUND"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Passkey not found"); + } // User2's passkey should still exist const user2PasskeysAfter = await call(router.me.passkeys.list, undefined, { diff --git a/db/schema.sql b/db/schema.sql index 90c915d..b0d5ee9 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf +\restrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices -- PostgreSQL database dump complete -- -\unrestrict NwR9NcSOK9D25dGgvUNdLvsNphDACAXsvkQ5NSmhpf6sLcFR570yQ96lhgCbCXf +\unrestrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu -- From 3b502d931928fb6cf0f70c8a3f2edf7571a38e19 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 16:23:36 +0800 Subject: [PATCH 02/15] Add comprehensive e2e tests for sessions and devices with 100% coverage - Add tests for me.sessions (list, revoke, revokeAll) - Add tests for me.devices (getInfo, trust, listTrusted, untrust, revokeAll) - Refactor router to use nested namespaces (me.sessions.*, me.devices.*) - Add createUserAPIContext helper that returns { context, token } - Add createDevice helper for device tests - Update createSession to return { token, sessionId } Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/__tests__/e2e/me.test.ts | 694 ++++++++++++++++++- apps/api-server/src/router.ts | 20 +- db/schema.sql | 4 +- 3 files changed, 677 insertions(+), 41 deletions(-) diff --git a/apps/api-server/src/__tests__/e2e/me.test.ts b/apps/api-server/src/__tests__/e2e/me.test.ts index 4caa9d3..95aedf5 100644 --- a/apps/api-server/src/__tests__/e2e/me.test.ts +++ b/apps/api-server/src/__tests__/e2e/me.test.ts @@ -8,6 +8,14 @@ * - me.updateProfile - update profile fields * - me.setPassword - set/change password * - me.delete - delete account + * - me.sessions.list - list all sessions + * - me.sessions.revoke - revoke a session + * - me.sessions.revokeAll - revoke all sessions except current + * - me.devices.getInfo - get current device info + * - me.devices.trust - trust current device + * - me.devices.listTrusted - list trusted devices + * - me.devices.untrust - untrust a device + * - me.devices.revokeAll - revoke all trusted devices */ import type { Database } from "@reviq/db-schema"; @@ -59,13 +67,19 @@ function getDb(): Kysely { function createAPIContext(options?: { sessionToken?: string; apiKey?: string; + deviceFingerprint?: string; }): APIContext { const reqHeaders = new Headers(); + const cookies: string[] = []; + if (options?.sessionToken) { - reqHeaders.set( - "cookie", - `${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`, - ); + cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); + } + if (options?.deviceFingerprint) { + cookies.push(`${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`); + } + if (cookies.length > 0) { + reqHeaders.set("cookie", cookies.join("; ")); } if (options?.apiKey) { reqHeaders.set("x-api-key", options.apiKey); @@ -82,26 +96,78 @@ function createAPIContext(options?: { } /** - * Create a real session in the database and return the token + * Create a real session in the database and return the token and session ID */ -async function createSession(userId: number): Promise { +async function createSession( + userId: number, + options?: { ipAddress?: string; userAgent?: string }, +): Promise<{ token: string; sessionId: number }> { const token = "test-session-" + String(Date.now()) + String(Math.random()); const tokenHashValue = await hashToken(token); const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); - await getDb() + const result = await getDb() .insertInto("sessions") .values({ user_id: userId, token_hash: tokenHashValue, - ip_address: "127.0.0.1", - user_agent: "test-agent", + ip_address: options?.ipAddress ?? "127.0.0.1", + user_agent: options?.userAgent ?? "test-agent", expires_at: expiresAt, trusted_mode: false, }) - .execute(); + .returning("id") + .executeTakeFirstOrThrow(); - return token; + return { token, sessionId: Number(result.id) }; +} + +/** + * Create an authenticated API context for a user (creates session + context) + */ +async function createUserAPIContext( + userId: number, + options?: { deviceFingerprint?: string }, +): Promise<{ context: APIContext; token: string }> { + const { token } = await createSession(userId); + const context = createAPIContext({ + sessionToken: token, + deviceFingerprint: options?.deviceFingerprint, + }); + return { context, token }; +} + +/** + * Create a device in the database and return the fingerprint + */ +async function createDevice( + userId: number, + options?: { + fingerprint?: string; + isTrusted?: boolean; + name?: string; + userAgent?: string; + }, +): Promise<{ fingerprint: string; deviceId: number }> { + const fingerprint = + options?.fingerprint ?? + "test-fp-" + String(Date.now()) + String(Math.random()); + + const result = await getDb() + .insertInto("user_devices") + .values({ + user_id: userId, + device_fingerprint: fingerprint, + is_trusted: options?.isTrusted ?? false, + name: options?.name ?? null, + user_agent: options?.userAgent ?? "Mozilla/5.0 Test Browser", + ip_address: "127.0.0.1", + last_used_at: new Date(), + }) + .returning("id") + .executeTakeFirstOrThrow(); + + return { fingerprint, deviceId: Number(result.id) }; } /** @@ -159,7 +225,7 @@ describe("me.get", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -188,7 +254,7 @@ describe("me.get", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -204,7 +270,7 @@ describe("me.get", () => { passwordHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -218,7 +284,7 @@ describe("me.get", () => { isSuperuser: true, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.get, undefined, { context }); @@ -234,7 +300,7 @@ describe("me.authStatus", () => { displayName: "Session User", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); const result = await call(router.me.authStatus, undefined, { context }); @@ -280,7 +346,7 @@ describe("me.setupProfile", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -316,7 +382,7 @@ describe("me.setupProfile", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -346,7 +412,7 @@ describe("me.updateProfile", () => { displayName: "Original Name", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -372,7 +438,7 @@ describe("me.updateProfile", () => { displayName: "Original", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -411,7 +477,7 @@ describe("me.updateProfile", () => { .where("id", "=", user.id) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -442,7 +508,7 @@ describe("me.updateProfile", () => { displayName: "Stay Same", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.updateProfile, {}, { context }); @@ -463,7 +529,7 @@ describe("me.setPassword", () => { email: "nopass@example.com", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); // Use a strong password @@ -492,7 +558,7 @@ describe("me.setPassword", () => { passwordHash: oldHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call( @@ -520,7 +586,7 @@ describe("me.setPassword", () => { passwordHash: oldHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( @@ -541,7 +607,7 @@ describe("me.setPassword", () => { passwordHash: oldHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( @@ -561,7 +627,7 @@ describe("me.setPassword", () => { email: "weak@example.com", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); // Password must be at least 8 chars to pass schema validation @@ -588,7 +654,7 @@ describe("me.delete", () => { passwordHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.delete, { password }, { context }); @@ -608,7 +674,7 @@ describe("me.delete", () => { email: "nopassdelete@example.com", }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( @@ -623,7 +689,7 @@ describe("me.delete", () => { passwordHash, }); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await expect( @@ -650,7 +716,7 @@ describe("me.delete", () => { }) .execute(); - const sessionToken = await createSession(user.id); + const { token: sessionToken } = await createSession(user.id); const context = createAPIContext({ sessionToken }); await call(router.me.delete, { password }, { context }); @@ -665,3 +731,569 @@ describe("me.delete", () => { expect(tokens).toHaveLength(0); }); }); + +// ===== Session Management Tests ===== + +describe("me.sessions.list", () => { + test("returns all sessions for user", async () => { + const user = await createTestUser(getDb(), { + email: "sessions@example.com", + }); + + // Create multiple sessions + const { token: sessionToken1, sessionId: id1 } = await createSession( + user.id, + { ipAddress: "192.168.1.1", userAgent: "Chrome/1.0" }, + ); + await createSession(user.id, { + ipAddress: "192.168.1.2", + userAgent: "Firefox/1.0", + }); + await createSession(user.id, { + ipAddress: "192.168.1.3", + userAgent: "Safari/1.0", + }); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(3); + // Sessions should be ordered by created_at desc + expect(sessions[0]?.userAgent).toBe("Safari/1.0"); + expect(sessions[1]?.userAgent).toBe("Firefox/1.0"); + expect(sessions[2]?.userAgent).toBe("Chrome/1.0"); + }); + + test("marks current session with isCurrent flag", async () => { + const user = await createTestUser(getDb(), { + email: "current@example.com", + }); + + const { token: sessionToken1, sessionId: id1 } = await createSession( + user.id, + ); + const { sessionId: id2 } = await createSession(user.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(2); + const current = sessions.find((s) => s.id === id1); + const other = sessions.find((s) => s.id === id2); + expect(current?.isCurrent).toBe(true); + expect(other?.isCurrent).toBe(false); + }); + + test("returns session metadata correctly", async () => { + const user = await createTestUser(getDb(), { + email: "metadata@example.com", + }); + + // Create session and update with location data + const { token: sessionToken, sessionId } = await createSession(user.id, { + ipAddress: "8.8.8.8", + userAgent: "TestAgent/1.0", + }); + + await getDb() + .updateTable("sessions") + .set({ + city: "San Francisco", + region: "CA", + country: "US", + trusted_mode: true, + }) + .where("id", "=", String(sessionId)) + .execute(); + + const context = createAPIContext({ sessionToken }); + const sessions = await call(router.me.sessions.list, undefined, { + context, + }); + + expect(sessions).toHaveLength(1); + const session = sessions[0]; + expect(session?.ip).toBe("8.8.8.8"); + expect(session?.userAgent).toBe("TestAgent/1.0"); + expect(session?.city).toBe("San Francisco"); + expect(session?.region).toBe("CA"); + expect(session?.country).toBe("US"); + expect(session?.trustedMode).toBe(true); + expect(session?.createdAt).toBeInstanceOf(Date); + expect(session?.revokedAt).toBeNull(); + }); +}); + +describe("me.sessions.revoke", () => { + test("revokes another session successfully", async () => { + const user = await createTestUser(getDb(), { + email: "revoke@example.com", + }); + + const { token: sessionToken1 } = await createSession(user.id); + const { sessionId: sessionId2 } = await createSession(user.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }); + + // Verify session is revoked + const session = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId2)) + .executeTakeFirstOrThrow(); + + expect(session.revoked_at).not.toBeNull(); + }); + + test("fails to revoke current session", async () => { + const user = await createTestUser(getDb(), { + email: "revokecurrent@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.sessions.revoke, { sessionId }, { context }), + ).rejects.toThrow("Cannot revoke current session"); + }); + + test("fails to revoke non-existent session", async () => { + const user = await createTestUser(getDb(), { + email: "revokenotfound@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.sessions.revoke, { sessionId: 999999 }, { context }), + ).rejects.toThrow("Session not found"); + }); + + test("fails to revoke already revoked session", async () => { + const user = await createTestUser(getDb(), { + email: "revokeagain@example.com", + }); + + const { token: sessionToken1 } = await createSession(user.id); + const { sessionId: sessionId2 } = await createSession(user.id); + + // Revoke the session directly + await getDb() + .updateTable("sessions") + .set({ revoked_at: new Date() }) + .where("id", "=", String(sessionId2)) + .execute(); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await expect( + call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), + ).rejects.toThrow("Session not found"); + }); + + test("fails to revoke another user's session", async () => { + const user1 = await createTestUser(getDb(), { + email: "user1@example.com", + }); + const user2 = await createTestUser(getDb(), { + email: "user2@example.com", + }); + + const { token: sessionToken1 } = await createSession(user1.id); + const { sessionId: sessionId2 } = await createSession(user2.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await expect( + call(router.me.sessions.revoke, { sessionId: sessionId2 }, { context }), + ).rejects.toThrow("Session not found"); + }); +}); + +describe("me.sessions.revokeAll", () => { + test("revokes all sessions except current", async () => { + const user = await createTestUser(getDb(), { + email: "revokeall@example.com", + }); + + const { token: sessionToken1, sessionId: id1 } = await createSession( + user.id, + ); + const { sessionId: id2 } = await createSession(user.id); + const { sessionId: id3 } = await createSession(user.id); + + const context = createAPIContext({ sessionToken: sessionToken1 }); + await call(router.me.sessions.revokeAll, undefined, { context }); + + // Verify current session is NOT revoked + const currentSession = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(id1)) + .executeTakeFirstOrThrow(); + expect(currentSession.revoked_at).toBeNull(); + + // Verify other sessions ARE revoked + const otherSessions = await getDb() + .selectFrom("sessions") + .select(["id", "revoked_at"]) + .where("id", "in", [String(id2), String(id3)]) + .execute(); + + for (const session of otherSessions) { + expect(session.revoked_at).not.toBeNull(); + } + }); + + test("does nothing when only current session exists", async () => { + const user = await createTestUser(getDb(), { + email: "onlyone@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + // Should not throw + await call(router.me.sessions.revokeAll, undefined, { context }); + + // Current session should still be valid + const session = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId)) + .executeTakeFirstOrThrow(); + expect(session.revoked_at).toBeNull(); + }); +}); + +// ===== Device Management Tests ===== + +describe("me.devices.getInfo", () => { + test("returns device info for current device", async () => { + const user = await createTestUser(getDb(), { + email: "deviceinfo@example.com", + }); + + const { fingerprint, deviceId } = await createDevice(user.id, { + name: "My MacBook", + isTrusted: true, + userAgent: "Safari/17.0", + }); + + // Update with location data + await getDb() + .updateTable("user_devices") + .set({ + ip_address: "1.2.3.4", + city: "New York", + region: "NY", + country: "US", + }) + .where("id", "=", String(deviceId)) + .execute(); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: fingerprint, + }); + + const info = await call(router.me.devices.getInfo, undefined, { context }); + + expect(info.id).toBe(deviceId); + expect(info.name).toBe("My MacBook"); + expect(info.ip).toBe("1.2.3.4"); + expect(info.city).toBe("New York"); + expect(info.region).toBe("NY"); + expect(info.country).toBe("US"); + expect(info.isTrusted).toBe(true); + expect(info.lastUsedAt).toBeInstanceOf(Date); + }); + + test("returns default name from user agent when name is null", async () => { + const user = await createTestUser(getDb(), { + email: "defaultname@example.com", + }); + + const { fingerprint } = await createDevice(user.id, { + userAgent: "Mozilla/5.0 (Macintosh)", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: fingerprint, + }); + + const info = await call(router.me.devices.getInfo, undefined, { context }); + + expect(info.name).toBe("Mozilla device"); + }); + + test("fails without device fingerprint", async () => { + const user = await createTestUser(getDb(), { + email: "nofingerprint@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.getInfo, undefined, { context }), + ).rejects.toThrow("No device fingerprint found"); + }); + + test("fails when device does not exist", async () => { + const user = await createTestUser(getDb(), { + email: "nodevice@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: "nonexistent-fingerprint", + }); + + await expect( + call(router.me.devices.getInfo, undefined, { context }), + ).rejects.toThrow("Device not found"); + }); +}); + +describe("me.devices.trust", () => { + test("trusts current device with name", async () => { + const user = await createTestUser(getDb(), { + email: "trustdevice@example.com", + }); + + const { fingerprint, deviceId } = await createDevice(user.id, { + isTrusted: false, + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: fingerprint, + }); + + await call( + router.me.devices.trust, + { name: "My Work Laptop" }, + { context }, + ); + + // Verify device is trusted with the new name + const device = await getDb() + .selectFrom("user_devices") + .select(["is_trusted", "name"]) + .where("id", "=", String(deviceId)) + .executeTakeFirstOrThrow(); + + expect(device.is_trusted).toBe(true); + expect(device.name).toBe("My Work Laptop"); + }); + + test("fails without device fingerprint", async () => { + const user = await createTestUser(getDb(), { + email: "trustnofp@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.trust, { name: "Test" }, { context }), + ).rejects.toThrow("No device fingerprint found"); + }); + + test("fails when device does not exist", async () => { + const user = await createTestUser(getDb(), { + email: "trustnodevice@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ + sessionToken, + deviceFingerprint: "nonexistent", + }); + + await expect( + call(router.me.devices.trust, { name: "Test" }, { context }), + ).rejects.toThrow("Device not found"); + }); +}); + +describe("me.devices.listTrusted", () => { + test("returns only trusted devices", async () => { + const user = await createTestUser(getDb(), { + email: "listtrusted@example.com", + }); + + // Create trusted and untrusted devices + await createDevice(user.id, { isTrusted: true, name: "Trusted 1" }); + await createDevice(user.id, { isTrusted: true, name: "Trusted 2" }); + await createDevice(user.id, { isTrusted: false, name: "Untrusted" }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(2); + expect(devices.map((d) => d.name).sort()).toEqual([ + "Trusted 1", + "Trusted 2", + ]); + expect(devices.every((d) => d.isTrusted)).toBe(true); + }); + + test("returns empty list when no trusted devices", async () => { + const user = await createTestUser(getDb(), { + email: "notrusted@example.com", + }); + + await createDevice(user.id, { isTrusted: false }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(0); + }); + + test("returns default name when device name is null", async () => { + const user = await createTestUser(getDb(), { + email: "defaulttrusted@example.com", + }); + + await createDevice(user.id, { + isTrusted: true, + name: undefined, + userAgent: "Chrome/120", + }); + + // Set name to null explicitly + await getDb() + .updateTable("user_devices") + .set({ name: null }) + .where("user_id", "=", user.id) + .execute(); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + const devices = await call(router.me.devices.listTrusted, undefined, { + context, + }); + + expect(devices).toHaveLength(1); + expect(devices[0]?.name).toBe("Unknown device"); + }); +}); + +describe("me.devices.untrust", () => { + test("untrusts device by ID", async () => { + const user = await createTestUser(getDb(), { + email: "untrust@example.com", + }); + + const { deviceId } = await createDevice(user.id, { + isTrusted: true, + name: "Trusted Device", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await call(router.me.devices.untrust, { deviceId }, { context }); + + // Verify device is untrusted + const device = await getDb() + .selectFrom("user_devices") + .select(["is_trusted"]) + .where("id", "=", String(deviceId)) + .executeTakeFirstOrThrow(); + + expect(device.is_trusted).toBe(false); + }); + + test("fails to untrust non-existent device", async () => { + const user = await createTestUser(getDb(), { + email: "untrustnotfound@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.untrust, { deviceId: 999999 }, { context }), + ).rejects.toThrow("Device not found"); + }); + + test("fails to untrust another user's device", async () => { + const user1 = await createTestUser(getDb(), { + email: "untrustuser1@example.com", + }); + const user2 = await createTestUser(getDb(), { + email: "untrustuser2@example.com", + }); + + const { deviceId } = await createDevice(user2.id, { isTrusted: true }); + + const { token: sessionToken } = await createSession(user1.id); + const context = createAPIContext({ sessionToken }); + + await expect( + call(router.me.devices.untrust, { deviceId }, { context }), + ).rejects.toThrow("Device not found"); + }); +}); + +describe("me.devices.revokeAll", () => { + test("untrusts all devices", async () => { + const user = await createTestUser(getDb(), { + email: "revokealldevices@example.com", + }); + + const { deviceId: id1 } = await createDevice(user.id, { isTrusted: true }); + const { deviceId: id2 } = await createDevice(user.id, { isTrusted: true }); + const { deviceId: id3 } = await createDevice(user.id, { isTrusted: false }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + await call(router.me.devices.revokeAll, undefined, { context }); + + // All devices should be untrusted + const devices = await getDb() + .selectFrom("user_devices") + .select(["id", "is_trusted"]) + .where("user_id", "=", user.id) + .execute(); + + expect(devices).toHaveLength(3); + expect(devices.every((d) => d.is_trusted === false)).toBe(true); + }); + + test("works when no devices exist", async () => { + const user = await createTestUser(getDb(), { + email: "revokenodevices@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + const context = createAPIContext({ sessionToken }); + + // Should not throw + await call(router.me.devices.revokeAll, undefined, { context }); + }); +}); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index 043125a..eca1c48 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -284,14 +284,18 @@ export const router = os.router({ rename: renamePasskey, delete: deletePasskey, }, - listSessions, - revokeSession, - revokeAllSessions, - getDeviceInfo, - trustDevice, - listTrustedDevices, - untrustDevice, - revokeAllTrustedDevices, + sessions: { + list: listSessions, + revoke: revokeSession, + revokeAll: revokeAllSessions, + }, + devices: { + getInfo: getDeviceInfo, + trust: trustDevice, + listTrusted: listTrustedDevices, + untrust: untrustDevice, + revokeAll: revokeAllTrustedDevices, + }, }, orgs: { list: orgsList, diff --git a/db/schema.sql b/db/schema.sql index b0d5ee9..fcda6b6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,4 +1,4 @@ -\restrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu +\restrict JcXyipc16dugUGJvd2oDJgA4cUi3A29rdzMF11XH6GPR94bG05YyvDzwhuyfGSd -- Dumped from database version 17.7 -- Dumped by pg_dump version 17.7 @@ -1084,7 +1084,7 @@ ALTER TABLE ONLY public.user_devices -- PostgreSQL database dump complete -- -\unrestrict KXTb98GlQCetYfS0eRd7LzGbBIiTxg53JFiqnSln3PIIhE3DD10jqFdLLY3AKZu +\unrestrict JcXyipc16dugUGJvd2oDJgA4cUi3A29rdzMF11XH6GPR94bG05YyvDzwhuyfGSd -- From 9f4c6ac0b905b1442054f18a5a2b1778bca1f33d Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 16:55:09 +0800 Subject: [PATCH 03/15] Redirect auth flows to / instead of /performance After login, email verification, trust device, and profile setup, users are now redirected to / which handles routing to their first org's dashboard. Co-Authored-By: Claude Opus 4.5 --- apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte | 2 +- .../src/routes/auth/setup/user/+page.svelte | 4 ++-- .../src/routes/auth/trust-device/+page.svelte | 2 +- apps/publisher-dashboard/src/routes/auth/verify/+page.svelte | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte b/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte index 375692b..e1a4164 100644 --- a/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte +++ b/apps/publisher-dashboard/src/routes/auth/confirm/+page.svelte @@ -58,7 +58,7 @@ const statusQuery = createQuery(() => ({ $effect(() => { if (statusQuery.data?.status === "completed") { clearLoginFlowState(); - goto(statusQuery.data.redirectTo || "/performance"); + goto(statusQuery.data.redirectTo || "/"); } }); diff --git a/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte b/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte index 7cfee3b..ee55cc8 100644 --- a/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte +++ b/apps/publisher-dashboard/src/routes/auth/setup/user/+page.svelte @@ -22,7 +22,7 @@ const userQuery = createQuery(() => ({ // Redirect if user doesn't need setup $effect(() => { if (userQuery.data && !userQuery.data.needsSetup) { - goto("/performance"); + goto("/"); } }); @@ -68,7 +68,7 @@ async function handleSubmit(e: Event) { }); toast.success("Profile setup complete!"); - goto("/performance"); + goto("/"); } catch (e) { error = e instanceof Error ? e.message : "Failed to save profile"; } finally { diff --git a/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte b/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte index e404f64..37db80c 100644 --- a/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte +++ b/apps/publisher-dashboard/src/routes/auth/trust-device/+page.svelte @@ -52,7 +52,7 @@ async function handleTrust() { try { await api.me.trustDevice({ name: deviceName.trim() }); toast.success("Device trusted successfully!"); - goto("/performance"); + goto("/"); } catch (e) { error = e instanceof Error ? e.message : "Failed to trust device"; } finally { diff --git a/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte b/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte index 95bea6d..c2c1c51 100644 --- a/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte +++ b/apps/publisher-dashboard/src/routes/auth/verify/+page.svelte @@ -31,7 +31,7 @@ async function verifyEmail(): Promise { try { await api.auth.verifyEmail({ token }); toast.success("Email verified successfully!"); - goto("/performance"); + goto("/"); } catch (e) { error = e instanceof Error ? e.message : "Verification failed"; } finally { From 39863bd9475038e3f013f38bc65f6c1d20716938 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 17:11:22 +0800 Subject: [PATCH 04/15] Add org invites section to dashboard with accept/decline flow Backend: - Add me.invites endpoints (list, get, accept, decline) to API contract - Create invites procedures for fetching user's pending invites - Only show invites if email matches and is verified - Refactor me routes into me/_routes.ts for consistency Frontend: - Add pending invitations section to /dashboard page - Create /account/org-invites/[inviteId] page for accept/decline - Show invite details (org, role, inviter, dates) - Redirect to org dashboard after accepting Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/procedures/me/_routes.ts | 53 ++++ .../src/procedures/me/auth-status.ts | 41 +++ apps/api-server/src/procedures/me/get.ts | 38 +++ apps/api-server/src/procedures/me/index.ts | 6 + apps/api-server/src/procedures/me/invites.ts | 211 +++++++++++++++ .../src/procedures/me/setup-profile.ts | 24 ++ apps/api-server/src/router.ts | 142 +--------- .../org-invites/[inviteId]/+page.svelte | 243 ++++++++++++++++++ .../src/routes/dashboard/+page.svelte | 168 +++++++++--- packages/api-contract/src/contract.ts | 15 ++ packages/api-contract/src/schemas/user.ts | 19 ++ 11 files changed, 779 insertions(+), 181 deletions(-) create mode 100644 apps/api-server/src/procedures/me/_routes.ts create mode 100644 apps/api-server/src/procedures/me/auth-status.ts create mode 100644 apps/api-server/src/procedures/me/get.ts create mode 100644 apps/api-server/src/procedures/me/invites.ts create mode 100644 apps/api-server/src/procedures/me/setup-profile.ts create mode 100644 apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte diff --git a/apps/api-server/src/procedures/me/_routes.ts b/apps/api-server/src/procedures/me/_routes.ts new file mode 100644 index 0000000..7a1c531 --- /dev/null +++ b/apps/api-server/src/procedures/me/_routes.ts @@ -0,0 +1,53 @@ +/** + * Me routes - consolidated exports for os.router() + */ + +import { meAuthStatus } from "./auth-status.js"; +import { meDelete } from "./delete.js"; +import { + getDeviceInfo, + listTrustedDevices, + revokeAllTrustedDevices, + trustDevice, + untrustDevice, +} from "./devices.js"; +import { meGet } from "./get.js"; +import { + acceptInvite, + declineInvite, + getInvite, + listInvites, +} from "./invites.js"; +import { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js"; +import { listSessions, revokeAllSessions, revokeSession } from "./sessions.js"; +import { setPassword } from "./set-password.js"; +import { setupProfile } from "./setup-profile.js"; +import { updateProfile } from "./update-profile.js"; + +export const meRoutes = { + get: meGet, + authStatus: meAuthStatus, + setupProfile, + updateProfile, + delete: meDelete, + setPassword, + passkeys: { + list: listPasskeys, + rename: renamePasskey, + delete: deletePasskey, + }, + invites: { + list: listInvites, + get: getInvite, + accept: acceptInvite, + decline: declineInvite, + }, + listSessions, + revokeSession, + revokeAllSessions, + getDeviceInfo, + trustDevice, + listTrustedDevices, + untrustDevice, + revokeAllTrustedDevices, +}; diff --git a/apps/api-server/src/procedures/me/auth-status.ts b/apps/api-server/src/procedures/me/auth-status.ts new file mode 100644 index 0000000..6bbf857 --- /dev/null +++ b/apps/api-server/src/procedures/me/auth-status.ts @@ -0,0 +1,41 @@ +/** + * Get current user auth status + */ + +import { authMiddleware, os } from "../base.js"; + +export const meAuthStatus = os.me.authStatus + .use(authMiddleware) + .handler(async ({ context }) => { + const user = await context.db + .selectFrom("users") + .select([ + "id", + "email", + "display_name", + "full_name", + "phone_number", + "avatar_url", + "email_verified_at", + "is_superuser", + "password_hash", + ]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + return { + user: { + id: user.id, + email: user.email, + displayName: user.display_name, + fullName: user.full_name, + phoneNumber: user.phone_number, + avatarUrl: user.avatar_url, + emailVerified: user.email_verified_at !== null, + needsSetup: user.display_name === null, + isSuperuser: user.is_superuser, + hasPassword: user.password_hash !== null, + }, + auth: context.auth, + }; + }); diff --git a/apps/api-server/src/procedures/me/get.ts b/apps/api-server/src/procedures/me/get.ts new file mode 100644 index 0000000..ecd705e --- /dev/null +++ b/apps/api-server/src/procedures/me/get.ts @@ -0,0 +1,38 @@ +/** + * Get current user profile + */ + +import { authMiddleware, os } from "../base.js"; + +export const meGet = os.me.get + .use(authMiddleware) + .handler(async ({ context }) => { + const user = await context.db + .selectFrom("users") + .select([ + "id", + "email", + "display_name", + "full_name", + "phone_number", + "avatar_url", + "email_verified_at", + "is_superuser", + "password_hash", + ]) + .where("id", "=", context.user.id) + .executeTakeFirstOrThrow(); + + return { + id: user.id, + email: user.email, + displayName: user.display_name, + fullName: user.full_name, + phoneNumber: user.phone_number, + avatarUrl: user.avatar_url, + emailVerified: user.email_verified_at !== null, + needsSetup: user.display_name === null, + isSuperuser: user.is_superuser, + hasPassword: user.password_hash !== null, + }; + }); diff --git a/apps/api-server/src/procedures/me/index.ts b/apps/api-server/src/procedures/me/index.ts index 4c993c8..2592fb3 100644 --- a/apps/api-server/src/procedures/me/index.ts +++ b/apps/api-server/src/procedures/me/index.ts @@ -10,6 +10,12 @@ export { trustDevice, untrustDevice, } from "./devices.js"; +export { + acceptInvite, + declineInvite, + getInvite, + listInvites, +} from "./invites.js"; export { deletePasskey, listPasskeys, renamePasskey } from "./passkeys.js"; export { listSessions, diff --git a/apps/api-server/src/procedures/me/invites.ts b/apps/api-server/src/procedures/me/invites.ts new file mode 100644 index 0000000..0176f96 --- /dev/null +++ b/apps/api-server/src/procedures/me/invites.ts @@ -0,0 +1,211 @@ +/** + * User invite procedures - list, get, decline invites for the current user + */ + +import { ORPCError } from "@orpc/server"; +import { authMiddleware, os } from "../base.js"; + +/** + * List pending invites for the current user + * Only returns invites where the user's email matches and email is verified + */ +export const listInvites = os.me.invites.list + .use(authMiddleware) + .handler(async ({ context }) => { + // Only show invites if email is verified + if (!context.user.emailVerifiedAt) { + return []; + } + + // Get non-expired invites matching user's email + const invites = await context.db + .selectFrom("org_invites") + .innerJoin("orgs", "orgs.id", "org_invites.org_id") + .innerJoin("users", "users.id", "org_invites.invited_by") + .where("org_invites.email", "=", context.user.email.toLowerCase()) + .where("org_invites.expires_at", ">", new Date()) + .select([ + "org_invites.id", + "org_invites.role", + "org_invites.created_at", + "org_invites.expires_at", + "orgs.id as org_id", + "orgs.slug as org_slug", + "orgs.display_name as org_display_name", + "orgs.logo_url as org_logo_url", + "users.display_name as inviter_name", + "users.email as inviter_email", + ]) + .orderBy("org_invites.created_at", "desc") + .execute(); + + return invites.map((i) => ({ + id: i.id, + org: { + id: i.org_id, + slug: i.org_slug, + displayName: i.org_display_name, + logoUrl: i.org_logo_url, + }, + role: i.role, + invitedBy: i.inviter_name ?? i.inviter_email, + createdAt: i.created_at, + expiresAt: i.expires_at, + })); + }); + +/** + * Get a specific invite by ID + * Only returns if the invite belongs to the current user's email + */ +export const getInvite = os.me.invites.get + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { inviteId } = input; + + // Only show invite if email is verified + if (!context.user.emailVerifiedAt) { + throw new ORPCError("FORBIDDEN", { + message: "Please verify your email to view invitations", + }); + } + + // Get the invite matching user's email + const invite = await context.db + .selectFrom("org_invites") + .innerJoin("orgs", "orgs.id", "org_invites.org_id") + .innerJoin("users", "users.id", "org_invites.invited_by") + .where("org_invites.id", "=", inviteId) + .where("org_invites.email", "=", context.user.email.toLowerCase()) + .where("org_invites.expires_at", ">", new Date()) + .select([ + "org_invites.id", + "org_invites.role", + "org_invites.created_at", + "org_invites.expires_at", + "orgs.id as org_id", + "orgs.slug as org_slug", + "orgs.display_name as org_display_name", + "orgs.logo_url as org_logo_url", + "users.display_name as inviter_name", + "users.email as inviter_email", + ]) + .executeTakeFirst(); + + if (!invite) { + throw new ORPCError("NOT_FOUND", { + message: "Invitation not found or expired", + }); + } + + return { + id: invite.id, + org: { + id: invite.org_id, + slug: invite.org_slug, + displayName: invite.org_display_name, + logoUrl: invite.org_logo_url, + }, + role: invite.role, + invitedBy: invite.inviter_name ?? invite.inviter_email, + createdAt: invite.created_at, + expiresAt: invite.expires_at, + }; + }); + +/** + * Accept an invite by ID + * Adds user to org and deletes the invite + */ +export const acceptInvite = os.me.invites.accept + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { inviteId } = input; + + // Only allow accepting if email is verified + if (!context.user.emailVerifiedAt) { + throw new ORPCError("FORBIDDEN", { + message: "Please verify your email to accept invitations", + }); + } + + // Get the invite matching user's email + const invite = await context.db + .selectFrom("org_invites") + .where("id", "=", inviteId) + .where("email", "=", context.user.email.toLowerCase()) + .where("expires_at", ">", new Date()) + .select(["id", "org_id", "role"]) + .executeTakeFirst(); + + if (!invite) { + throw new ORPCError("NOT_FOUND", { + message: "Invitation not found or expired", + }); + } + + try { + // Accept the invite in a transaction + await context.db.transaction().execute(async (trx) => { + // Add user as a member + await trx + .insertInto("org_members") + .values({ + org_id: invite.org_id, + user_id: context.user.id, + role: invite.role, + }) + .execute(); + + // Delete the invite + await trx + .deleteFrom("org_invites") + .where("id", "=", invite.id) + .execute(); + }); + } catch (error) { + // Handle unique constraint violation (user is already a member) + if ( + error instanceof Error && + error.message.includes("org_members_org_id_user_id_key") + ) { + // Clean up the invite since user is already a member + await context.db + .deleteFrom("org_invites") + .where("id", "=", invite.id) + .execute(); + + throw new ORPCError("CONFLICT", { + message: "You are already a member of this organization", + }); + } + throw error; + } + + return { success: true }; + }); + +/** + * Decline an invite + * Deletes the invite if it belongs to the current user's email + */ +export const declineInvite = os.me.invites.decline + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { inviteId } = input; + + // Delete the invite only if it matches user's email + const result = await context.db + .deleteFrom("org_invites") + .where("id", "=", inviteId) + .where("email", "=", context.user.email.toLowerCase()) + .executeTakeFirst(); + + if (!result.numDeletedRows || result.numDeletedRows === 0n) { + throw new ORPCError("NOT_FOUND", { + message: "Invitation not found", + }); + } + + return { success: true }; + }); diff --git a/apps/api-server/src/procedures/me/setup-profile.ts b/apps/api-server/src/procedures/me/setup-profile.ts new file mode 100644 index 0000000..da083ff --- /dev/null +++ b/apps/api-server/src/procedures/me/setup-profile.ts @@ -0,0 +1,24 @@ +/** + * Setup user profile (initial setup after signup) + */ + +import { authMiddleware, os } from "../base.js"; + +export const setupProfile = os.me.setupProfile + .use(authMiddleware) + .handler(async ({ input, context }) => { + const { displayName, fullName, phoneNumber } = input; + + await context.db + .updateTable("users") + .set({ + display_name: displayName, + full_name: fullName ?? null, + phone_number: phoneNumber ?? null, + updated_at: new Date(), + }) + .where("id", "=", context.user.id) + .execute(); + + return { success: true }; + }); diff --git a/apps/api-server/src/router.ts b/apps/api-server/src/router.ts index e4de764..839df1b 100644 --- a/apps/api-server/src/router.ts +++ b/apps/api-server/src/router.ts @@ -15,26 +15,7 @@ import { loginRequestMiddleware, os, } from "./procedures/base.js"; -import { meDelete } from "./procedures/me/delete.js"; -import { - getDeviceInfo, - listTrustedDevices, - revokeAllTrustedDevices, - trustDevice, - untrustDevice, -} from "./procedures/me/devices.js"; -import { - deletePasskey, - listPasskeys, - renamePasskey, -} from "./procedures/me/passkeys.js"; -import { - listSessions, - revokeAllSessions, - revokeSession, -} from "./procedures/me/sessions.js"; -import { setPassword } from "./procedures/me/set-password.js"; -import { updateProfile } from "./procedures/me/update-profile.js"; +import { meRoutes } from "./procedures/me/_routes.js"; import { invitesAccept, invitesCancel, @@ -164,105 +145,6 @@ const verifyAuthentication = os.auth.webauthn.verifyAuthentication return { success: true }; }); -// Me procedures -const meGet = os.me.get.use(authMiddleware).handler(async ({ context }) => { - const user = await context.db - .selectFrom("users") - .select([ - "id", - "email", - "display_name", - "full_name", - "phone_number", - "avatar_url", - "email_verified_at", - "is_superuser", - "password_hash", - ]) - .where("id", "=", context.user.id) - .executeTakeFirstOrThrow(); - - return { - id: user.id, - email: user.email, - displayName: user.display_name, - fullName: user.full_name, - phoneNumber: user.phone_number, - avatarUrl: user.avatar_url, - emailVerified: user.email_verified_at !== null, - needsSetup: user.display_name === null, - isSuperuser: user.is_superuser, - hasPassword: user.password_hash !== null, - }; -}); - -const meAuthStatus = os.me.authStatus - .use(authMiddleware) - .handler(async ({ context }) => { - const user = await context.db - .selectFrom("users") - .select([ - "id", - "email", - "display_name", - "full_name", - "phone_number", - "avatar_url", - "email_verified_at", - "is_superuser", - "password_hash", - ]) - .where("id", "=", context.user.id) - .executeTakeFirstOrThrow(); - - return { - user: { - id: user.id, - email: user.email, - displayName: user.display_name, - fullName: user.full_name, - phoneNumber: user.phone_number, - avatarUrl: user.avatar_url, - emailVerified: user.email_verified_at !== null, - needsSetup: user.display_name === null, - isSuperuser: user.is_superuser, - hasPassword: user.password_hash !== null, - }, - auth: context.auth, - }; - }); - -const setupProfile = os.me.setupProfile - .use(authMiddleware) - .handler(async ({ input, context }) => { - const { displayName, fullName, phoneNumber } = input; - - await context.db - .updateTable("users") - .set({ - display_name: displayName, - full_name: fullName ?? null, - phone_number: phoneNumber ?? null, - updated_at: new Date(), - }) - .where("id", "=", context.user.id) - .execute(); - - return { success: true }; - }); - -// Me procedures imported from ./procedures/me/* -// - updateProfile, setPassword, meDelete -// - listPasskeys, renamePasskey, deletePasskey -// - listSessions, revokeSession, revokeAllSessions -// - getDeviceInfo, trustDevice, listTrustedDevices, untrustDevice, revokeAllTrustedDevices - -// Orgs procedures - imported from ./procedures/orgs/index.js -// - orgsList, orgsCreate, orgsGet, orgsUpdate, orgsDelete, orgsLeave -// - membersList, membersUpdateRole, membersRemove -// - invitesList, invitesCreate, invitesCancel, invitesAccept -// - sitesList - // Build the router export const router = os.router({ auth: { @@ -283,27 +165,7 @@ export const router = os.router({ verifyAuthentication, }, }, - me: { - get: meGet, - authStatus: meAuthStatus, - setupProfile, - updateProfile, - delete: meDelete, - setPassword, - passkeys: { - list: listPasskeys, - rename: renamePasskey, - delete: deletePasskey, - }, - listSessions, - revokeSession, - revokeAllSessions, - getDeviceInfo, - trustDevice, - listTrustedDevices, - untrustDevice, - revokeAllTrustedDevices, - }, + me: meRoutes, orgs: { list: orgsList, create: orgsCreate, diff --git a/apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte b/apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte new file mode 100644 index 0000000..8d87bc0 --- /dev/null +++ b/apps/publisher-dashboard/src/routes/account/org-invites/[inviteId]/+page.svelte @@ -0,0 +1,243 @@ + + + + Organization Invitation | Publisher Dashboard + + +
+ + + + {#if inviteQuery.isPending} +
+ +

Loading invitation...

+
+ {:else if inviteQuery.error} + + + + {inviteQuery.error instanceof Error ? inviteQuery.error.message : "Failed to load invitation"} + + + + {:else if inviteQuery.data} + {@const invite = inviteQuery.data} + + +
+ {#if invite.org.logoUrl} + {invite.org.displayName} logo + {:else} +
+ +
+ {/if} +
+ {invite.org.displayName} + + You've been invited to join this organization + +
+
+
+ + +
+
+
+ +
+
+

Role

+

{formatRole(invite.role)}

+
+
+
+
+ +
+
+

Invited by

+

{invite.invitedBy}

+
+
+
+
+ +
+
+

Sent on

+

{formatDate(new Date(invite.createdAt))}

+
+
+
+
+ +
+
+

Expires on

+

+ {formatDate(new Date(invite.expiresAt))} +

+
+
+
+ + {#if isExpiringSoon(new Date(invite.expiresAt))} + + + + This invitation will expire soon. Accept it before {formatDate(new Date(invite.expiresAt))} to join the organization. + + + {/if} + + + + +
+ + acceptMutation.mutate()} + > + + Accept & Join + +
+
+
+ {/if} +
diff --git a/apps/publisher-dashboard/src/routes/dashboard/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/+page.svelte index ee36f22..0ae4aa4 100644 --- a/apps/publisher-dashboard/src/routes/dashboard/+page.svelte +++ b/apps/publisher-dashboard/src/routes/dashboard/+page.svelte @@ -1,18 +1,27 @@ @@ -64,7 +86,61 @@ function formatDate(date: Date): string { -
+
+ + {#if invitesQuery.data && invitesQuery.data.length > 0} + + {/if} + + {#if orgsQuery.isPending}
@@ -79,8 +155,8 @@ function formatDate(date: Date): string { {orgsQuery.error instanceof Error ? orgsQuery.error.message : "Failed to load organizations"}

- {:else if orgsQuery.data && orgsQuery.data.length === 0} - + {:else if orgsQuery.data && orgsQuery.data.length === 0 && (!invitesQuery.data || invitesQuery.data.length === 0)} +
@@ -93,47 +169,57 @@ function formatDate(date: Date): string {

+ {:else if orgsQuery.data && orgsQuery.data.length === 0 && invitesQuery.data && invitesQuery.data.length > 0} + +
+ Accept an invitation above to join an organization. +
{:else if orgsQuery.data} -
- {#each orgsQuery.data as org (org.id)} - - - -
- - {#if org.logoUrl} - {org.displayName} logo - {:else} - diff --git a/packages/api-contract/src/contract.ts b/packages/api-contract/src/contract.ts index 59ed053..ebf251f 100644 --- a/packages/api-contract/src/contract.ts +++ b/packages/api-contract/src/contract.ts @@ -45,6 +45,7 @@ import { setupProfileInputSchema, trustDeviceInputSchema, updateProfileInputSchema, + userInviteOutputSchema, userProfileSchema, } from "./schemas/user.js"; @@ -147,6 +148,20 @@ export const contract = oc.router({ .output(successResponseSchema), }), + // Org invites for the current user + invites: oc.router({ + list: oc.output(z.array(userInviteOutputSchema)), + get: oc + .input(z.object({ inviteId: z.number() })) + .output(userInviteOutputSchema), + accept: oc + .input(z.object({ inviteId: z.number() })) + .output(successResponseSchema), + decline: oc + .input(z.object({ inviteId: z.number() })) + .output(successResponseSchema), + }), + // Sessions & devices listSessions: oc.output(z.array(sessionOutputSchema)), revokeSession: oc diff --git a/packages/api-contract/src/schemas/user.ts b/packages/api-contract/src/schemas/user.ts index 7efd4c1..cd45242 100644 --- a/packages/api-contract/src/schemas/user.ts +++ b/packages/api-contract/src/schemas/user.ts @@ -1,5 +1,6 @@ import * as z from "zod"; import { nonEmptyString, optionalString, phoneSchema } from "./common.js"; +import { orgRoleSchema } from "./org.js"; /** * User profile schema @@ -132,3 +133,21 @@ export const authStatusOutputSchema = z.object({ sessionAuthStatusSchema, ]), }); + +/** + * User invite output schema + * Returned by me.invites.list - includes org info for the user's pending invites + */ +export const userInviteOutputSchema = z.object({ + id: z.number(), + org: z.object({ + id: z.number(), + slug: z.string(), + displayName: z.string(), + logoUrl: z.string().nullable(), + }), + role: orgRoleSchema, + invitedBy: z.string(), + createdAt: z.date(), + expiresAt: z.date(), +}); From d779aa794c492d343bad2d38a787757452cb5151 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 17:17:43 +0800 Subject: [PATCH 05/15] Add email verification banner for unverified users Shows a warning banner at the top of dashboard pages when the user's email is not verified, with a button to resend the verification email. Co-Authored-By: Claude Opus 4.5 --- .../components/layout/dashboard-layout.svelte | 11 +++ .../layout/email-verification-banner.svelte | 69 +++++++++++++++++++ .../src/lib/components/layout/index.ts | 1 + 3 files changed, 81 insertions(+) create mode 100644 apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/dashboard-layout.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard-layout.svelte index 2f2472a..44342a6 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/dashboard-layout.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/dashboard-layout.svelte @@ -1,8 +1,11 @@
@@ -20,6 +28,9 @@ let { title, children, class: className }: Props = $props();
+ {#if userQuery.data && !userQuery.data.emailVerified} + + {/if}
diff --git a/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte b/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte new file mode 100644 index 0000000..bdc15a6 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte @@ -0,0 +1,69 @@ + + +
+
+ +

+ Please verify your email address at + {email} +

+
+ + +
diff --git a/apps/publisher-dashboard/src/lib/components/layout/index.ts b/apps/publisher-dashboard/src/lib/components/layout/index.ts index 5ff51f1..44d5210 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/index.ts +++ b/apps/publisher-dashboard/src/lib/components/layout/index.ts @@ -1,4 +1,5 @@ export { default as AppHeader } from "./app-header.svelte"; export { default as AppSidebar } from "./app-sidebar.svelte"; export { default as DashboardLayout } from "./dashboard-layout.svelte"; +export { default as EmailVerificationBanner } from "./email-verification-banner.svelte"; export { default as MobileNav } from "./mobile-nav.svelte"; From 967e8904a7e030262a3dfd89d846d5f54bc84bd7 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 04:18:05 -0500 Subject: [PATCH 06/15] lint fix --- .../src/lib/components/layout/email-verification-banner.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte b/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte index bdc15a6..189378d 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte @@ -1,8 +1,8 @@ + +
+
+ + + +

{title}

+ +
+ + +
diff --git a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-layout.svelte b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-layout.svelte new file mode 100644 index 0000000..0f37356 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-layout.svelte @@ -0,0 +1,32 @@ + + +
+ + + +
+ + +
+
+ {@render children()} +
+
+
+
diff --git a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte new file mode 100644 index 0000000..a66238f --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte @@ -0,0 +1,189 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + + +
+
+ + + +
+ Admin Panel +
+
+ + +
+
diff --git a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte new file mode 100644 index 0000000..3161c52 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte @@ -0,0 +1,228 @@ + + + diff --git a/apps/publisher-dashboard/src/lib/components/layout/admin/index.ts b/apps/publisher-dashboard/src/lib/components/layout/admin/index.ts new file mode 100644 index 0000000..c0d9636 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/layout/admin/index.ts @@ -0,0 +1,4 @@ +export { default as AdminHeader } from "./admin-header.svelte"; +export { default as AdminLayout } from "./admin-layout.svelte"; +export { default as AdminMobileNav } from "./admin-mobile-nav.svelte"; +export { default as AdminSidebar } from "./admin-sidebar.svelte"; diff --git a/apps/publisher-dashboard/src/lib/components/layout/app-header.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-header.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/app-header.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/app-header.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/app-sidebar.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/dashboard-layout.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/dashboard-layout.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/dashboard-layout.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/dashboard-layout.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/email-verification-banner.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/email-verification-banner.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/email-verification-banner.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/dashboard/index.ts b/apps/publisher-dashboard/src/lib/components/layout/dashboard/index.ts new file mode 100644 index 0000000..2a18177 --- /dev/null +++ b/apps/publisher-dashboard/src/lib/components/layout/dashboard/index.ts @@ -0,0 +1,7 @@ +export { default as AppHeader } from "./app-header.svelte"; +export { default as AppSidebar } from "./app-sidebar.svelte"; +export { default as DashboardLayout } from "./dashboard-layout.svelte"; +export { default as EmailVerificationBanner } from "./email-verification-banner.svelte"; +export { default as MobileNav } from "./mobile-nav.svelte"; +export { default as OrgSwitcher } from "./org-switcher.svelte"; +export { default as UserMenu } from "./user-menu.svelte"; diff --git a/apps/publisher-dashboard/src/lib/components/layout/mobile-nav.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/mobile-nav.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/mobile-nav.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/mobile-nav.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/org-switcher.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/org-switcher.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/org-switcher.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/org-switcher.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/user-menu.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/user-menu.svelte similarity index 100% rename from apps/publisher-dashboard/src/lib/components/layout/user-menu.svelte rename to apps/publisher-dashboard/src/lib/components/layout/dashboard/user-menu.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/index.ts b/apps/publisher-dashboard/src/lib/components/layout/index.ts index 44d5210..197efe5 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/index.ts +++ b/apps/publisher-dashboard/src/lib/components/layout/index.ts @@ -1,5 +1,18 @@ -export { default as AppHeader } from "./app-header.svelte"; -export { default as AppSidebar } from "./app-sidebar.svelte"; -export { default as DashboardLayout } from "./dashboard-layout.svelte"; -export { default as EmailVerificationBanner } from "./email-verification-banner.svelte"; -export { default as MobileNav } from "./mobile-nav.svelte"; +// Dashboard layout components +export { + AppHeader, + AppSidebar, + DashboardLayout, + EmailVerificationBanner, + MobileNav, + OrgSwitcher, + UserMenu, +} from "./dashboard/index.js"; + +// Admin layout components +export { + AdminHeader, + AdminLayout, + AdminMobileNav, + AdminSidebar, +} from "./admin/index.js"; diff --git a/apps/publisher-dashboard/src/routes/account/+layout.svelte b/apps/publisher-dashboard/src/routes/account/+layout.svelte index 4720447..6e50421 100644 --- a/apps/publisher-dashboard/src/routes/account/+layout.svelte +++ b/apps/publisher-dashboard/src/routes/account/+layout.svelte @@ -1,7 +1,7 @@ diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte index a87f035..0a58f1a 100644 --- a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte @@ -12,7 +12,7 @@ import { getContext } from "svelte"; import { toast } from "svelte-sonner"; import { goto } from "$app/navigation"; import { api } from "$lib/api/client"; -import DashboardLayout from "$lib/components/layout/dashboard-layout.svelte"; +import { DashboardLayout } from "$lib/components/layout"; import { ConfirmDialog } from "$lib/components/org"; import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Button } from "$lib/components/ui/button"; From b93f5e0b69f0144d3014017f9437a7145e72dc30 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 04:52:00 -0500 Subject: [PATCH 09/15] lint --- .../layout/admin/admin-mobile-nav.svelte | 8 ++++++-- .../components/layout/admin/admin-sidebar.svelte | 8 ++++++-- .../src/lib/components/layout/index.ts | 16 ++++++++-------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte index a66238f..da36420 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-mobile-nav.svelte @@ -26,11 +26,15 @@ const user = $derived(userQuery.data); // Generate initials from display name or email const initials = $derived.by(() => { - if (!user) return "??"; + if (!user) { + return "??"; + } if (user.displayName) { const parts = user.displayName.split(" "); if (parts.length >= 2) { - return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); + return ( + parts[0].charAt(0) + parts[parts.length - 1].charAt(0) + ).toUpperCase(); } return user.displayName.slice(0, 2).toUpperCase(); } diff --git a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte index 3161c52..e92335e 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/admin/admin-sidebar.svelte @@ -22,11 +22,15 @@ const user = $derived(userQuery.data); // Generate initials from display name or email const initials = $derived.by(() => { - if (!user) return "??"; + if (!user) { + return "??"; + } if (user.displayName) { const parts = user.displayName.split(" "); if (parts.length >= 2) { - return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); + return ( + parts[0].charAt(0) + parts[parts.length - 1].charAt(0) + ).toUpperCase(); } return user.displayName.slice(0, 2).toUpperCase(); } diff --git a/apps/publisher-dashboard/src/lib/components/layout/index.ts b/apps/publisher-dashboard/src/lib/components/layout/index.ts index 197efe5..4174201 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/index.ts +++ b/apps/publisher-dashboard/src/lib/components/layout/index.ts @@ -1,4 +1,12 @@ // Dashboard layout components + +// Admin layout components +export { + AdminHeader, + AdminLayout, + AdminMobileNav, + AdminSidebar, +} from "./admin/index.js"; export { AppHeader, AppSidebar, @@ -8,11 +16,3 @@ export { OrgSwitcher, UserMenu, } from "./dashboard/index.js"; - -// Admin layout components -export { - AdminHeader, - AdminLayout, - AdminMobileNav, - AdminSidebar, -} from "./admin/index.js"; From 74b26818ca5b3e81c2807e7e40377a7851b56eb5 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 17:55:39 +0800 Subject: [PATCH 10/15] Add comprehensive e2e tests for all auth procedures Tests cover all login scenarios from docs/initial-app.md: - Signup with password and passkey - Password login with trusted device (immediate completion) - Password login with untrusted device (email confirmation) - Full passkey authentication flow - User with no auth methods (stays pending) - Non-existent email (anti-enumeration with fake token) - Email verification and resend flows - Password reset with session revocation - Logout All auth procedures now have 100% function coverage. 127 tests passing across 3 e2e test files. Co-Authored-By: Claude Opus 4.5 --- .../api-server/src/__tests__/e2e/auth.test.ts | 1985 +++++++++++++++++ 1 file changed, 1985 insertions(+) create mode 100644 apps/api-server/src/__tests__/e2e/auth.test.ts diff --git a/apps/api-server/src/__tests__/e2e/auth.test.ts b/apps/api-server/src/__tests__/e2e/auth.test.ts new file mode 100644 index 0000000..4faa751 --- /dev/null +++ b/apps/api-server/src/__tests__/e2e/auth.test.ts @@ -0,0 +1,1985 @@ +/** + * End-to-end tests for Auth procedures + * + * These tests cover ALL login scenarios from docs/initial-app.md: + * + * SIGNUP FLOWS: + * - Signup with password + * - Signup with passkey + * + * LOGIN FLOWS (from docs/initial-app.md Step 2: Authentication): + * - Has passkey → full passkey authentication flow + * - Has password + trusted device → immediate completion + * - Has password + new device → requires email confirmation + * - No password AND no passkey → polling stays pending + * - Non-existent email → anti-enumeration (fake token, returns pending) + * + * OTHER FLOWS: + * - Email verification flow + * - Password reset flow (with session revocation) + * - Logout + * + * Procedures tested: + * - auth.signup - create account with password or passkey + * - auth.createLoginRequest - first step of login flow + * - auth.loginPassword - password verification + * - auth.loginPasswordConfirm - email confirmation for untrusted devices + * - auth.loginIfRequestIsCompleted - poll for login completion + * - auth.webauthn.createRegistrationOptions - passkey registration + * - auth.webauthn.verifyRegistration - passkey registration verification + * - auth.webauthn.createAuthenticationOptions - passkey authentication + * - auth.webauthn.verifyAuthentication - passkey authentication verification + * - auth.verifyEmail - verify email token + * - auth.resendVerificationEmail - resend verification + * - auth.forgotPassword - request password reset + * - auth.resetPassword - reset with token + * - auth.logout - revoke session + */ + +import type { Database } from "@reviq/db-schema"; +import type { Kysely } from "kysely"; +import type { APIContext } from "../../context.js"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { call } from "@orpc/server"; +import { VirtualAuthenticator } from "@reviq/virtual-authenticator"; +import { router } from "../../router.js"; +import { COOKIE_NAMES } from "../../utils/cookies.js"; +import { hashToken } from "../../utils/crypto.js"; +import { hashPassword } from "../../utils/password.js"; +import { TEST_RP } from "../helpers/test-constants.js"; +import { + createTestDb, + createTestUser, + destroyTestDb, + runMigrations, + truncateAllTables, +} from "../helpers/test-db.js"; + +/** Session expiry duration: 24 hours in milliseconds */ +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; + +/** Login request expiry: 15 minutes */ +const LOGIN_REQUEST_EXPIRY_MS = 15 * 60 * 1000; + +let db: Kysely | undefined; + +function getDb(): Kysely { + if (!db) { + throw new Error("Database not initialized"); + } + return db; +} + +/** + * Create an API context with optional cookies + */ +function createAPIContext(options?: { + sessionToken?: string; + loginRequestToken?: string; + deviceFingerprint?: string; +}): APIContext { + const reqHeaders = new Headers(); + const cookies: string[] = []; + + if (options?.sessionToken) { + cookies.push(`${COOKIE_NAMES.SESSION_TOKEN}=${options.sessionToken}`); + } + if (options?.loginRequestToken) { + cookies.push( + `${COOKIE_NAMES.LOGIN_REQUEST_TOKEN}=${options.loginRequestToken}`, + ); + } + if (options?.deviceFingerprint) { + cookies.push( + `${COOKIE_NAMES.DEVICE_FINGERPRINT}=${options.deviceFingerprint}`, + ); + } + if (cookies.length > 0) { + reqHeaders.set("cookie", cookies.join("; ")); + } + + return { + db: getDb(), + origin: TEST_RP.origin, + allowedOrigins: [...TEST_RP.allowedOrigins], + rpName: TEST_RP.rpName, + reqHeaders, + resHeaders: new Headers(), + }; +} + +/** + * Extract cookie value from response headers + */ +function getCookieFromResponse( + headers: Headers, + cookieName: string, +): string | null { + const setCookies = headers.getSetCookie(); + for (const cookie of setCookies) { + if (cookie.startsWith(`${cookieName}=`)) { + const parts = cookie.split(";")[0]?.split("="); + const value = parts?.[1] ?? ""; + // Check if it's a deletion (empty value or max-age=0) + if (cookie.includes("Max-Age=0") || value === "") { + return null; + } + return value; + } + } + return null; +} + +/** + * Assert a value is not null/undefined and return it with narrowed type. + * Use this instead of non-null assertions (!) to satisfy linter. + */ +function assertDefined( + value: T | null | undefined, + message = "Expected value to be defined", +): T { + if (value == null) { + throw new Error(message); + } + return value; +} + +/** + * Create a session for a user and return the token + */ +async function createSession( + userId: number, + options?: { deviceId?: bigint }, +): Promise<{ token: string; sessionId: number }> { + const token = `test-session-${String(Date.now())}${String(Math.random())}`; + const tokenHashValue = await hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS); + + const result = await getDb() + .insertInto("sessions") + .values({ + user_id: userId, + device_id: options?.deviceId ? String(options.deviceId) : null, + token_hash: tokenHashValue, + trusted_mode: false, + expires_at: expiresAt, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return { token, sessionId: Number(result.id) }; +} + +/** + * Create a login request for a user + */ +async function createLoginRequest( + userId: number, + email: string, + options?: { + deviceFingerprint?: string; + completedAt?: Date | null; + expiresAt?: Date; + }, +): Promise<{ token: string; id: number }> { + const token = `login_test-${String(Date.now())}${String(Math.random())}`; + const expiresAt = + options?.expiresAt ?? new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS); + + const result = await getDb() + .insertInto("login_requests") + .values({ + user_id: userId, + email, + token, + device_fingerprint: options?.deviceFingerprint ?? "test-fingerprint", + expires_at: expiresAt, + completed_at: options?.completedAt ?? null, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return { token, id: Number(result.id) }; +} + +/** + * Create a trusted device for a user + */ +async function createTrustedDevice( + userId: number, + fingerprint: string, +): Promise { + const result = await getDb() + .insertInto("user_devices") + .values({ + user_id: userId, + device_fingerprint: fingerprint, + is_trusted: true, + user_agent: "Test Browser", + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + + return BigInt(result.id); +} + +/** + * Create an email verification token + */ +async function createEmailVerification( + userId: number, + options?: { expiresAt?: Date }, +): Promise { + const token = `verify-${String(Date.now())}${String(Math.random())}`; + const expiresAt = + options?.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000); + + await getDb() + .insertInto("email_verifications") + .values({ + user_id: userId, + token, + expires_at: expiresAt, + }) + .execute(); + + return token; +} + +/** + * Create a password reset token + */ +async function createPasswordReset( + userId: number, + options?: { expiresAt?: Date; usedAt?: Date | null }, +): Promise { + const token = `reset-${String(Date.now())}${String(Math.random())}`; + const expiresAt = options?.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000); + + await getDb() + .insertInto("password_resets") + .values({ + user_id: userId, + token, + expires_at: expiresAt, + used_at: options?.usedAt ?? null, + }) + .execute(); + + return token; +} + +// Test setup +beforeAll(async () => { + await runMigrations(); + db = createTestDb(); +}); + +afterAll(async () => { + if (db) { + await destroyTestDb(db); + } +}); + +beforeEach(async () => { + await truncateAllTables(getDb()); +}); + +// ============================================================================= +// auth.signup tests +// ============================================================================= + +describe("auth.signup", () => { + test("creates user with valid password", async () => { + const ctx = createAPIContext(); + + const result = await call( + router.auth.signup, + { email: "newuser@example.com", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify user was created + const user = await getDb() + .selectFrom("users") + .selectAll() + .where("email", "=", "newuser@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + expect(user?.password_hash).not.toBeNull(); + expect(user?.email_verified_at).toBeNull(); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify session was created in DB + const sessions = await getDb() + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(sessions.length).toBe(1); + + // Verify email verification token was created + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + expect(verifications.length).toBe(1); + }); + + test("normalizes email to lowercase", async () => { + const ctx = createAPIContext(); + + await call( + router.auth.signup, + { email: "UPPERCASE@EXAMPLE.COM", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ); + + const user = await getDb() + .selectFrom("users") + .select(["email"]) + .where("email", "=", "uppercase@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + }); + + test("rejects weak password", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { email: "weak@example.com", password: "password" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); + + test("rejects duplicate email (anti-enumeration)", async () => { + // Create existing user + await createTestUser(getDb(), { email: "existing@example.com" }); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { email: "existing@example.com", password: "StrongP@ssw0rd123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Unable to create account"); + }); + + test("rejects signup without password or passkey", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { email: "noauth@example.com" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); + + test("creates user with passkey", async () => { + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const ctx = createAPIContext(); + + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "passkeyuser@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential with virtual authenticator + const response = authenticator.createCredential(options); + + // Step 3: Signup with passkey + const signupCtx = createAPIContext(); + const result = await call( + router.auth.signup, + { + email: "passkeyuser@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ); + + expect(result.success).toBe(true); + + // Verify user was created + const user = await getDb() + .selectFrom("users") + .selectAll() + .where("email", "=", "passkeyuser@example.com") + .executeTakeFirst(); + + expect(user).toBeDefined(); + expect(user?.password_hash).toBeNull(); // No password for passkey signup + expect(user?.email_verified_at).toBeNull(); + + // Verify passkey was stored + const passkeys = await getDb() + .selectFrom("passkeys") + .selectAll() + .where("user_id", "=", assertDefined(user).id) + .execute(); + + expect(passkeys.length).toBe(1); + expect(passkeys[0]?.name).toBeDefined(); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + signupCtx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify webauthn challenge was deleted + const challenges = await getDb() + .selectFrom("webauthn_challenges") + .selectAll() + .where("id", "=", String(challengeId)) + .execute(); + expect(challenges.length).toBe(0); + }); + + test("rejects passkey signup with expired challenge", async () => { + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const ctx = createAPIContext(); + + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "expiredchallenge@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential + const response = authenticator.createCredential(options); + + // Step 3: Expire the challenge by updating created_at + await getDb() + .updateTable("webauthn_challenges") + .set({ created_at: new Date(Date.now() - 20 * 60 * 1000) }) // 20 minutes ago + .where("id", "=", String(challengeId)) + .execute(); + + // Step 4: Try to signup with expired challenge + const signupCtx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { + email: "expiredchallenge@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ), + ).rejects.toThrow("Registration timed out"); + }); + + test("rejects passkey signup with invalid response", async () => { + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const ctx = createAPIContext(); + + // Step 1: Create registration options + const { options, challengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: "invalidresponse@example.com" }, + { context: ctx }, + ); + + // Step 2: Create credential + const response = authenticator.createCredential(options); + + // Step 3: Tamper with the response + response.response.clientDataJSON = "dGFtcGVyZWQ"; // "tampered" in base64 + + // Step 4: Try to signup with invalid response + const signupCtx = createAPIContext(); + + await expect( + call( + router.auth.signup, + { + email: "invalidresponse@example.com", + passkeyInfo: { challengeId, response }, + }, + { context: signupCtx }, + ), + ).rejects.toThrow("Failed to register your device"); + + // Verify challenge was deleted (cleanup on error) + const challenges = await getDb() + .selectFrom("webauthn_challenges") + .selectAll() + .where("id", "=", String(challengeId)) + .execute(); + expect(challenges.length).toBe(0); + }); +}); + +// ============================================================================= +// auth.createLoginRequest tests +// ============================================================================= + +describe("auth.createLoginRequest", () => { + test("returns auth methods for existing user with password", async () => { + await createTestUser(getDb(), { + email: "haspassword@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "haspassword@example.com" }, + { context: ctx }, + ); + + expect(result.hasPassword).toBe(true); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + expect(result.email).toBe("haspassword@example.com"); + + // Verify login request was created + const loginRequests = await getDb() + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(1); + + // Verify login request token cookie was set + const token = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(token).not.toBeNull(); + expect(token).toStartWith("login_"); + }); + + test("detects trusted device", async () => { + const user = await createTestUser(getDb(), { + email: "trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "trusted-device-fp"; + await createTrustedDevice(user.id, fingerprint); + + const ctx = createAPIContext({ deviceFingerprint: fingerprint }); + const result = await call( + router.auth.createLoginRequest, + { email: "trusted@example.com" }, + { context: ctx }, + ); + + expect(result.isTrustedDevice).toBe(true); + }); + + test("returns fake response for non-existent user (anti-enumeration)", async () => { + const ctx = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "nonexistent@example.com" }, + { context: ctx }, + ); + + // Should return all false (same as user without any auth methods) + expect(result.hasPassword).toBe(false); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + + // Should still set a login request token cookie (fake one) + const token = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(token).not.toBeNull(); + + // Should NOT create a login request in DB + const loginRequests = await getDb() + .selectFrom("login_requests") + .selectAll() + .execute(); + expect(loginRequests.length).toBe(0); + }); + + test("normalizes email to lowercase", async () => { + await createTestUser(getDb(), { + email: "lowercase@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "LOWERCASE@EXAMPLE.COM" }, + { context: ctx }, + ); + + expect(result.hasPassword).toBe(true); + }); + + test("generates device fingerprint if not present", async () => { + await createTestUser(getDb(), { + email: "nofingerprint@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const ctx = createAPIContext(); // No device fingerprint + await call( + router.auth.createLoginRequest, + { email: "nofingerprint@example.com" }, + { context: ctx }, + ); + + // Should set device fingerprint cookie + const fingerprint = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.DEVICE_FINGERPRINT, + ); + expect(fingerprint).not.toBeNull(); + }); +}); + +// ============================================================================= +// auth.loginPassword tests +// ============================================================================= + +describe("auth.loginPassword", () => { + test("completes login immediately for trusted device", async () => { + const user = await createTestUser(getDb(), { + email: "trustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "trusted-login-fp"; + await createTrustedDevice(user.id, fingerprint); + + const { token: loginToken } = await createLoginRequest( + user.id, + "trustedlogin@example.com", + { deviceFingerprint: fingerprint }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + + const result = await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was marked as completed + const loginRequest = await getDb() + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).not.toBeNull(); + }); + + test("sends email for untrusted device (does not complete immediately)", async () => { + const user = await createTestUser(getDb(), { + email: "untrustedlogin@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "untrusted-login-fp"; + const { token: loginToken } = await createLoginRequest( + user.id, + "untrustedlogin@example.com", + { deviceFingerprint: fingerprint }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + + const result = await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was NOT marked as completed (needs email confirmation) + const loginRequest = await getDb() + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).toBeNull(); + }); + + test("rejects invalid password", async () => { + const user = await createTestUser(getDb(), { + email: "wrongpass@example.com", + passwordHash: await hashPassword("CorrectPassword123!"), + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "wrongpass@example.com", + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "WrongPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + + test("rejects expired login request", async () => { + const user = await createTestUser(getDb(), { + email: "expired@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "expired@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Login request has expired"); + }); + + test("rejects when no login request token cookie", async () => { + const ctx = createAPIContext(); // No login request token + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + + test("rejects fake/invalid login request token", async () => { + const ctx = createAPIContext({ loginRequestToken: "fake-token-12345" }); + + await expect( + call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); + + test("rejects user without password set", async () => { + const user = await createTestUser(getDb(), { + email: "nopassword@example.com", + // No password hash + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "nopassword@example.com", + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid email or password"); + }); +}); + +// ============================================================================= +// auth.loginPasswordConfirm tests +// ============================================================================= + +describe("auth.loginPasswordConfirm", () => { + test("marks login request as completed with valid token", async () => { + const user = await createTestUser(getDb(), { + email: "confirm@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "confirm@example.com", + ); + + const ctx = createAPIContext(); + const result = await call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify login request was marked as completed + const loginRequest = await getDb() + .selectFrom("login_requests") + .select(["completed_at"]) + .where("token", "=", loginToken) + .executeTakeFirst(); + + expect(loginRequest?.completed_at).not.toBeNull(); + }); + + test("is idempotent for already completed requests", async () => { + const user = await createTestUser(getDb(), { + email: "idempotent@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "idempotent@example.com", + { completedAt: new Date() }, // Already completed + ); + + const ctx = createAPIContext(); + const result = await call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + }); + + test("rejects invalid token", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); + + test("rejects expired token", async () => { + const user = await createTestUser(getDb(), { + email: "expiredconfirm@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "expiredconfirm@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.loginPasswordConfirm, + { token: loginToken }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired confirmation link"); + }); +}); + +// ============================================================================= +// auth.loginIfRequestIsCompleted tests +// ============================================================================= + +describe("auth.loginIfRequestIsCompleted", () => { + test("returns pending for incomplete login request", async () => { + const user = await createTestUser(getDb(), { + email: "pending@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "pending@example.com", + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); + + test("returns expired for expired login request", async () => { + const user = await createTestUser(getDb(), { + email: "expiredpoll@example.com", + }); + + const { token: loginToken } = await createLoginRequest( + user.id, + "expiredpoll@example.com", + { expiresAt: new Date(Date.now() - 1000) }, // Expired + ); + + const ctx = createAPIContext({ loginRequestToken: loginToken }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("expired"); + }); + + test("creates session and returns completed for completed request", async () => { + const user = await createTestUser(getDb(), { + email: "completed@example.com", + }); + + const fingerprint = "completed-fp"; + const { token: loginToken, id: loginRequestId } = await createLoginRequest( + user.id, + "completed@example.com", + { deviceFingerprint: fingerprint, completedAt: new Date() }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/auth/trust-device"); // Not trusted yet + + // Verify session was created + const sessions = await getDb() + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(sessions.length).toBe(1); + expect(sessions[0]?.trusted_mode).toBe(true); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + + // Verify login request was deleted + const loginRequest = await getDb() + .selectFrom("login_requests") + .selectAll() + .where("id", "=", String(loginRequestId)) + .executeTakeFirst(); + expect(loginRequest).toBeUndefined(); + + // Verify user device was created + const devices = await getDb() + .selectFrom("user_devices") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(devices.length).toBe(1); + }); + + test("redirects to dashboard if device is already trusted", async () => { + const user = await createTestUser(getDb(), { + email: "alreadytrusted@example.com", + }); + + const fingerprint = "already-trusted-fp"; + await createTrustedDevice(user.id, fingerprint); + + const { token: loginToken } = await createLoginRequest( + user.id, + "alreadytrusted@example.com", + { deviceFingerprint: fingerprint, completedAt: new Date() }, + ); + + const ctx = createAPIContext({ + loginRequestToken: loginToken, + deviceFingerprint: fingerprint, + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("completed"); + expect(result.redirectTo).toBe("/dashboard"); + }); + + test("returns pending for fake/non-existent token", async () => { + const ctx = createAPIContext({ loginRequestToken: "fake-token-xyz" }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); + + test("returns pending when no cookie present", async () => { + const ctx = createAPIContext(); // No login request token + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); + + test("returns pending when device fingerprint is missing", async () => { + const user = await createTestUser(getDb(), { + email: "nofp@example.com", + }); + + // Create login request without device fingerprint + const token = `login_test-${String(Date.now())}`; + await getDb() + .insertInto("login_requests") + .values({ + user_id: user.id, + email: "nofp@example.com", + token, + device_fingerprint: null, // No fingerprint + expires_at: new Date(Date.now() + LOGIN_REQUEST_EXPIRY_MS), + completed_at: new Date(), + }) + .execute(); + + const ctx = createAPIContext({ loginRequestToken: token }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx }, + ); + + expect(result.status).toBe("pending"); + }); +}); + +// ============================================================================= +// auth.verifyEmail tests +// ============================================================================= + +describe("auth.verifyEmail", () => { + test("verifies email with valid token", async () => { + const user = await createTestUser(getDb(), { + email: "verify@example.com", + }); + + const token = await createEmailVerification(user.id); + + const ctx = createAPIContext(); + const result = await call( + router.auth.verifyEmail, + { token }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify user's email_verified_at was set + const updatedUser = await getDb() + .selectFrom("users") + .select(["email_verified_at"]) + .where("id", "=", user.id) + .executeTakeFirst(); + + expect(updatedUser?.email_verified_at).not.toBeNull(); + + // Verify verification record was deleted + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); + }); + + test("rejects invalid token", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.verifyEmail, + { token: "invalid-token" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired token"); + }); + + test("rejects expired token and cleans up", async () => { + const user = await createTestUser(getDb(), { + email: "expiredverify@example.com", + }); + + const token = await createEmailVerification(user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const ctx = createAPIContext(); + + await expect( + call(router.auth.verifyEmail, { token }, { context: ctx }), + ).rejects.toThrow("Invalid or expired token"); + + // Verify expired token was cleaned up + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); + }); +}); + +// ============================================================================= +// auth.resendVerificationEmail tests +// ============================================================================= + +describe("auth.resendVerificationEmail", () => { + test("creates new verification token for unverified user", async () => { + const user = await createTestUser(getDb(), { + email: "resend@example.com", + }); + + const { token: sessionToken } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + const result = await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + expect(result.success).toBe(true); + + // Verify new verification token was created + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(1); + }); + + test("deletes old verification tokens before creating new one", async () => { + const user = await createTestUser(getDb(), { + email: "resendold@example.com", + }); + + // Create existing verification + await createEmailVerification(user.id); + + const { token: sessionToken } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + // Should still have only 1 verification (old one deleted, new one created) + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(1); + }); + + test("returns success for already verified user (no-op)", async () => { + const user = await createTestUser(getDb(), { + email: "alreadyverified@example.com", + emailVerifiedAt: new Date(), + }); + + const { token: sessionToken } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + const result = await call(router.auth.resendVerificationEmail, undefined, { + context: ctx, + }); + + expect(result.success).toBe(true); + + // No verification token should be created + const verifications = await getDb() + .selectFrom("email_verifications") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(verifications.length).toBe(0); + }); + + test("requires authentication", async () => { + const ctx = createAPIContext(); // No session + + await expect( + call(router.auth.resendVerificationEmail, undefined, { context: ctx }), + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// auth.forgotPassword tests +// ============================================================================= + +describe("auth.forgotPassword", () => { + test("creates password reset token for existing user", async () => { + const user = await createTestUser(getDb(), { + email: "forgot@example.com", + }); + + const ctx = createAPIContext(); + const result = await call( + router.auth.forgotPassword, + { email: "forgot@example.com" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify password reset token was created + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); + }); + + test("returns success for non-existent user (anti-enumeration)", async () => { + const ctx = createAPIContext(); + const result = await call( + router.auth.forgotPassword, + { email: "nonexistent@example.com" }, + { context: ctx }, + ); + + // Should still return success (anti-enumeration) + expect(result.success).toBe(true); + + // No password reset should be created + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .execute(); + expect(resets.length).toBe(0); + }); + + test("deletes existing password reset tokens before creating new one", async () => { + const user = await createTestUser(getDb(), { + email: "forgotold@example.com", + }); + + // Create existing reset token + await createPasswordReset(user.id); + + const ctx = createAPIContext(); + await call( + router.auth.forgotPassword, + { email: "forgotold@example.com" }, + { context: ctx }, + ); + + // Should have only 1 reset token (old one deleted) + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); + }); + + test("normalizes email to lowercase", async () => { + const user = await createTestUser(getDb(), { + email: "forgotcase@example.com", + }); + + const ctx = createAPIContext(); + await call( + router.auth.forgotPassword, + { email: "FORGOTCASE@EXAMPLE.COM" }, + { context: ctx }, + ); + + // Should find the user and create reset token + const resets = await getDb() + .selectFrom("password_resets") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + expect(resets.length).toBe(1); + }); +}); + +// ============================================================================= +// auth.resetPassword tests +// ============================================================================= + +describe("auth.resetPassword", () => { + test("resets password with valid token", async () => { + const user = await createTestUser(getDb(), { + email: "reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + const token = await createPasswordReset(user.id); + + const ctx = createAPIContext(); + const result = await call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ); + + expect(result.success).toBe(true); + + // Verify password was updated (can't directly verify hash, but check updated_at) + const updatedUser = await getDb() + .selectFrom("users") + .select(["password_hash", "updated_at"]) + .where("id", "=", user.id) + .executeTakeFirst(); + + expect(updatedUser?.password_hash).not.toBeNull(); + + // Verify reset token was marked as used + const reset = await getDb() + .selectFrom("password_resets") + .select(["used_at"]) + .where("token", "=", token) + .executeTakeFirst(); + + expect(reset?.used_at).not.toBeNull(); + }); + + test("revokes all sessions after password reset", async () => { + const user = await createTestUser(getDb(), { + email: "resetrevoke@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + // Create some sessions + await createSession(user.id); + await createSession(user.id); + + const token = await createPasswordReset(user.id); + + const ctx = createAPIContext(); + await call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ); + + // Verify all sessions were revoked + const sessions = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("user_id", "=", user.id) + .execute(); + + for (const session of sessions) { + expect(session.revoked_at).not.toBeNull(); + } + }); + + test("rejects invalid token", async () => { + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token: "invalid-token", newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Invalid or expired reset token"); + }); + + test("rejects expired token", async () => { + const user = await createTestUser(getDb(), { + email: "resetexpired@example.com", + }); + + const token = await createPasswordReset(user.id, { + expiresAt: new Date(Date.now() - 1000), // Expired + }); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has expired"); + }); + + test("rejects already used token", async () => { + const user = await createTestUser(getDb(), { + email: "resetused@example.com", + }); + + const token = await createPasswordReset(user.id, { + usedAt: new Date(), // Already used + }); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "NewStrongP@ssw0rd!" }, + { context: ctx }, + ), + ).rejects.toThrow("Reset token has already been used"); + }); + + test("rejects weak password", async () => { + const user = await createTestUser(getDb(), { + email: "resetweak@example.com", + }); + + const token = await createPasswordReset(user.id); + + const ctx = createAPIContext(); + + await expect( + call( + router.auth.resetPassword, + { token, newPassword: "weak" }, + { context: ctx }, + ), + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// auth.logout tests +// ============================================================================= + +describe("auth.logout", () => { + test("revokes current session", async () => { + const user = await createTestUser(getDb(), { + email: "logout@example.com", + }); + + const { token: sessionToken, sessionId } = await createSession(user.id); + + const ctx = createAPIContext({ sessionToken }); + const result = await call(router.auth.logout, undefined, { context: ctx }); + + expect(result.success).toBe(true); + + // Verify session was revoked + const session = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("id", "=", String(sessionId)) + .executeTakeFirst(); + + expect(session?.revoked_at).not.toBeNull(); + + // Verify session cookie was deleted + const setCookies = ctx.resHeaders.getSetCookie(); + const sessionCookie = setCookies.find((c) => + c.startsWith(`${COOKIE_NAMES.SESSION_TOKEN}=`), + ); + expect(sessionCookie).toContain("Max-Age=0"); + }); + + test("requires authentication", async () => { + const ctx = createAPIContext(); // No session + + await expect( + call(router.auth.logout, undefined, { context: ctx }), + ).rejects.toThrow(); + }); +}); + +// ============================================================================= +// End-to-end login scenarios from docs/initial-app.md +// ============================================================================= + +describe("End-to-end login scenarios", () => { + test("Scenario: Password login with trusted device (immediate completion)", async () => { + // Setup: User with password and trusted device + const user = await createTestUser(getDb(), { + email: "e2e-trusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "e2e-trusted-device"; + await createTrustedDevice(user.id, fingerprint); + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-trusted@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPassword).toBe(true); + expect(loginRequestResult.isTrustedDevice).toBe(true); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Step 2: Login with password (should complete immediately for trusted device) + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx2 }, + ); + + // Step 3: Poll for completion + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(completedResult.status).toBe("completed"); + expect(completedResult.redirectTo).toBe("/dashboard"); // Already trusted + + // Verify session was created + const sessionToken = getCookieFromResponse( + ctx3.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + }); + + test("Scenario: Password login with untrusted device (requires email confirmation)", async () => { + // Setup: User with password but no trusted device + await createTestUser(getDb(), { + email: "e2e-untrusted@example.com", + passwordHash: await hashPassword("TestPassword123!"), + }); + + const fingerprint = "e2e-untrusted-device"; + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-untrusted@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPassword).toBe(true); + expect(loginRequestResult.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Step 2: Login with password (should NOT complete - needs email confirmation) + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.loginPassword, + { password: "TestPassword123!" }, + { context: ctx2 }, + ); + + // Step 3: Poll should return pending (email not confirmed yet) + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const pendingResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(pendingResult.status).toBe("pending"); + + // Step 4: User clicks email confirmation link + const ctx4 = createAPIContext(); + await call( + router.auth.loginPasswordConfirm, + { token: assertDefined(loginToken) }, + { context: ctx4 }, + ); + + // Step 5: Poll should now return completed + const ctx5 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx5 }, + ); + + expect(completedResult.status).toBe("completed"); + expect(completedResult.redirectTo).toBe("/auth/trust-device"); // Not yet trusted + }); + + test("Scenario: Login attempt with non-existent email (anti-enumeration)", async () => { + // Step 1: Create login request for non-existent email + const ctx1 = createAPIContext(); + const result = await call( + router.auth.createLoginRequest, + { email: "doesnotexist@example.com" }, + { context: ctx1 }, + ); + + // Should return all false (indistinguishable from user without auth methods) + expect(result.hasPassword).toBe(false); + expect(result.hasPasskey).toBe(false); + expect(result.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); // Still get a token (fake) + + // Step 2: Trying to login with password should fail + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + }); + await expect( + call( + router.auth.loginPassword, + { password: "AnyPassword123!" }, + { context: ctx2 }, + ), + ).rejects.toThrow("Invalid email or password"); + + // Step 3: Polling should return pending until expired + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + }); + const pollResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx3 }, + ); + + expect(pollResult.status).toBe("pending"); // Fake token - always pending + }); + + test("Scenario: Complete password reset flow", async () => { + // Setup: User with existing password and sessions + const user = await createTestUser(getDb(), { + email: "e2e-reset@example.com", + passwordHash: await hashPassword("OldPassword123!"), + }); + + await createSession(user.id); + await createSession(user.id); + + // Step 1: Request password reset + const ctx1 = createAPIContext(); + await call( + router.auth.forgotPassword, + { email: "e2e-reset@example.com" }, + { context: ctx1 }, + ); + + // Get the token from DB (in real flow, this would be from email) + const reset = await getDb() + .selectFrom("password_resets") + .select(["token"]) + .where("user_id", "=", user.id) + .executeTakeFirst(); + + // Step 2: Reset password + const ctx2 = createAPIContext(); + await call( + router.auth.resetPassword, + { token: assertDefined(reset).token, newPassword: "NewSecureP@ss123!" }, + { context: ctx2 }, + ); + + // Verify all old sessions were revoked + const sessions = await getDb() + .selectFrom("sessions") + .select(["revoked_at"]) + .where("user_id", "=", user.id) + .execute(); + + for (const session of sessions) { + expect(session.revoked_at).not.toBeNull(); + } + + // Step 3: Login with new password should work + const ctx3 = createAPIContext(); + await call( + router.auth.createLoginRequest, + { email: "e2e-reset@example.com" }, + { context: ctx3 }, + ); + + const loginToken = getCookieFromResponse( + ctx3.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + + // Mark login as completed (simulate trusted device or email confirmation) + await getDb() + .updateTable("login_requests") + .set({ completed_at: new Date() }) + .where("token", "=", assertDefined(loginToken)) + .execute(); + + const ctx4 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + }); + const result = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx4 }, + ); + + expect(result.status).toBe("completed"); + }); + + test("Scenario: Passkey login flow (full e2e)", async () => { + // Setup: User with passkey + const user = await createTestUser(getDb(), { + email: "e2e-passkey-login@example.com", + }); + + const authenticator = new VirtualAuthenticator({ origin: TEST_RP.origin }); + const fingerprint = "e2e-passkey-device"; + + // Create a session for passkey registration (registration requires auth) + const { token: regSessionToken, sessionId: regSessionId } = + await createSession(user.id); + + // Create registration options + const regOptionsCtx = createAPIContext({ + sessionToken: regSessionToken, + deviceFingerprint: fingerprint, + }); + const { options: regOptions, challengeId: regChallengeId } = await call( + router.auth.webauthn.createRegistrationOptions, + { email: user.email }, + { context: regOptionsCtx }, + ); + + // Create credential with virtual authenticator + const regResponse = authenticator.createCredential(regOptions); + + // Verify registration + const verifyRegCtx = createAPIContext({ + sessionToken: regSessionToken, + deviceFingerprint: fingerprint, + }); + await call( + router.auth.webauthn.verifyRegistration, + { challengeId: regChallengeId, response: regResponse }, + { context: verifyRegCtx }, + ); + + // Clean up registration session + await getDb() + .deleteFrom("sessions") + .where("id", "=", String(regSessionId)) + .execute(); + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-passkey-login@example.com" }, + { context: ctx1 }, + ); + + expect(loginRequestResult.hasPasskey).toBe(true); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); + + // Step 2: Create authentication options + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const { options: authOptions, challengeId: authChallengeId } = await call( + router.auth.webauthn.createAuthenticationOptions, + undefined, + { context: ctx2 }, + ); + + expect(authOptions.allowCredentials).toHaveLength(1); + + // Step 3: Authenticate with passkey + const authResponse = authenticator.getAssertion(authOptions); + + const ctx3 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + await call( + router.auth.webauthn.verifyAuthentication, + { challengeId: authChallengeId, response: authResponse }, + { context: ctx3 }, + ); + + // Step 4: Poll for completion - should be completed now + const ctx4 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const completedResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx4 }, + ); + + expect(completedResult.status).toBe("completed"); + // Passkey login creates a trusted session, but device is not yet trusted + // So user is redirected to trust-device screen + expect(completedResult.redirectTo).toBe("/auth/trust-device"); + + // Verify session was created with trusted_mode = true + const sessions = await getDb() + .selectFrom("sessions") + .selectAll() + .where("user_id", "=", user.id) + .execute(); + + expect(sessions.length).toBe(1); + expect(sessions[0]?.trusted_mode).toBe(true); + + // Verify session cookie was set + const sessionToken = getCookieFromResponse( + ctx4.resHeaders, + COOKIE_NAMES.SESSION_TOKEN, + ); + expect(sessionToken).not.toBeNull(); + }); + + test("Scenario: User with no auth methods (no password, no passkey)", async () => { + // Setup: User without any auth methods set up + // This simulates a user who was created but never completed setup + await createTestUser(getDb(), { + email: "e2e-no-auth@example.com", + // No password hash + }); + + const fingerprint = "e2e-no-auth-device"; + + // Step 1: Create login request + const ctx1 = createAPIContext({ deviceFingerprint: fingerprint }); + const loginRequestResult = await call( + router.auth.createLoginRequest, + { email: "e2e-no-auth@example.com" }, + { context: ctx1 }, + ); + + // Should indicate no auth methods available + expect(loginRequestResult.hasPassword).toBe(false); + expect(loginRequestResult.hasPasskey).toBe(false); + expect(loginRequestResult.isTrustedDevice).toBe(false); + + const loginToken = getCookieFromResponse( + ctx1.resHeaders, + COOKIE_NAMES.LOGIN_REQUEST_TOKEN, + ); + expect(loginToken).not.toBeNull(); + + // Step 2: Poll should return pending (no way to complete login) + const ctx2 = createAPIContext({ + loginRequestToken: assertDefined(loginToken), + deviceFingerprint: fingerprint, + }); + const pendingResult = await call( + router.auth.loginIfRequestIsCompleted, + undefined, + { context: ctx2 }, + ); + + expect(pendingResult.status).toBe("pending"); + + // According to docs: "Shows 'Check your email' but no email sent, polling will expire" + // The login request exists but can never be completed since there's no auth method + + // Verify login request exists but is not completed + const loginRequest = await getDb() + .selectFrom("login_requests") + .selectAll() + .where("token", "=", assertDefined(loginToken)) + .executeTakeFirst(); + + expect(loginRequest).toBeDefined(); + expect(loginRequest?.completed_at).toBeNull(); + }); +}); From 319edf70db06edf363711ea417bd4f623711db5a Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 18:08:21 +0800 Subject: [PATCH 11/15] Fix IP address not being set on sessions from localhost The extractClientIP() function only checked proxy headers (X-Forwarded-For, CF-Connecting-IP, etc.) which don't exist when running locally without a proxy. Changes: - Add clientIP field to APIContext - Use Bun's server.requestIP() to get client IP from direct socket connection - Update getGeoInfo() to accept fallback IP parameter - Pass context.clientIP to getGeoInfo() in auth procedures Now sessions will have IP address set even for local development (::1 or 127.0.0.1). Co-Authored-By: Claude Opus 4.5 --- apps/api-server/src/context.ts | 2 ++ apps/api-server/src/index.ts | 7 ++++++- .../src/procedures/auth/create-login-request.ts | 2 +- .../src/procedures/auth/login-if-completed.ts | 2 +- apps/api-server/src/procedures/auth/signup.ts | 2 +- apps/api-server/src/utils/geo.ts | 11 +++++++++-- 6 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/api-server/src/context.ts b/apps/api-server/src/context.ts index 4e4458b..ac030e6 100644 --- a/apps/api-server/src/context.ts +++ b/apps/api-server/src/context.ts @@ -21,6 +21,8 @@ export interface APIContext { reqHeaders: Headers; /** Response headers (for setting cookies) */ resHeaders: Headers; + /** Client IP address from direct connection (fallback when no proxy headers) */ + clientIP?: string | null; } /** diff --git a/apps/api-server/src/index.ts b/apps/api-server/src/index.ts index 15eb2d9..e2a8926 100644 --- a/apps/api-server/src/index.ts +++ b/apps/api-server/src/index.ts @@ -39,7 +39,7 @@ const rpName = Bun.env.RP_NAME ?? DEFAULT_RP_NAME; Bun.serve({ port, - async fetch(request) { + async fetch(request, server) { const url = new URL(request.url); if (url.pathname.startsWith("/api/v1/rpc")) { @@ -50,6 +50,10 @@ Bun.serve({ // Create response headers for setting cookies const resHeaders = new Headers(); + // Get client IP from Bun's server (fallback for when no proxy headers) + const socketInfo = server.requestIP(request); + const clientIP = socketInfo?.address ?? null; + const context: APIContext = { db, origin, @@ -57,6 +61,7 @@ Bun.serve({ rpName, reqHeaders: request.headers, resHeaders, + clientIP, }; const { response } = await handler.handle(request, { diff --git a/apps/api-server/src/procedures/auth/create-login-request.ts b/apps/api-server/src/procedures/auth/create-login-request.ts index 0cc0935..551bdb7 100644 --- a/apps/api-server/src/procedures/auth/create-login-request.ts +++ b/apps/api-server/src/procedures/auth/create-login-request.ts @@ -102,7 +102,7 @@ export const createLoginRequest = os.auth.createLoginRequest.handler( const hasPassword = user.password_hash !== null; // Get geo info and user agent - const geo = getGeoInfo(context.reqHeaders); + const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); // Create login request with secure token diff --git a/apps/api-server/src/procedures/auth/login-if-completed.ts b/apps/api-server/src/procedures/auth/login-if-completed.ts index 513429b..cf438e1 100644 --- a/apps/api-server/src/procedures/auth/login-if-completed.ts +++ b/apps/api-server/src/procedures/auth/login-if-completed.ts @@ -86,7 +86,7 @@ export const loginIfRequestIsCompleted = } // Get current request info - const geo = getGeoInfo(context.reqHeaders); + const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); // Upsert user device diff --git a/apps/api-server/src/procedures/auth/signup.ts b/apps/api-server/src/procedures/auth/signup.ts index 3015c8f..b2efabf 100644 --- a/apps/api-server/src/procedures/auth/signup.ts +++ b/apps/api-server/src/procedures/auth/signup.ts @@ -225,7 +225,7 @@ export const signup = os.auth.signup.handler(async ({ input, context }) => { } // Get geo info and user agent for session creation - const geo = getGeoInfo(context.reqHeaders); + const geo = getGeoInfo(context.reqHeaders, context.clientIP); const userAgent = getUserAgent(context.reqHeaders); let userId: number; diff --git a/apps/api-server/src/utils/geo.ts b/apps/api-server/src/utils/geo.ts index abccdb9..4c24374 100644 --- a/apps/api-server/src/utils/geo.ts +++ b/apps/api-server/src/utils/geo.ts @@ -126,9 +126,16 @@ export const lookupGeoFromIP = ( /** * Extract geolocation info from request headers. * Uses Cloudflare headers when available, falls back to GeoIP database lookup. + * + * @param headers - Request headers to extract proxy IP headers from + * @param fallbackIP - Optional fallback IP from direct socket connection (e.g., from Bun's server.requestIP) */ -export const getGeoInfo = (headers: Headers): GeoInfo => { - const ip = extractClientIP(headers); +export const getGeoInfo = ( + headers: Headers, + fallbackIP?: string | null, +): GeoInfo => { + // Try proxy headers first, then fall back to direct connection IP + const ip = extractClientIP(headers) ?? fallbackIP ?? null; // Try Cloudflare geo headers first const cfCountry = headers.get("CF-IPCountry"); From d486e2444eea4ff48572df19b2d0196a26a72a51 Mon Sep 17 00:00:00 2001 From: RevIQ Date: Sat, 10 Jan 2026 18:09:17 +0800 Subject: [PATCH 12/15] Add org settings layout with responsive nav and member management - Create SettingsLayout component with left sidebar nav (desktop) and horizontal scroll nav (mobile) - Add settings gear icon to sidebar (Lucide icon, only in org context) - Fix home icon highlighting to only match exact org home path - Create /settings/members route with full member management - Create /settings/sites placeholder route - Update general settings to use new SettingsLayout Co-Authored-By: Claude Opus 4.5 --- .../layout/dashboard/app-sidebar.svelte | 37 +- .../src/lib/components/layout/index.ts | 5 +- .../lib/components/layout/settings/index.ts | 1 + .../layout/settings/settings-layout.svelte | 115 +++++ .../dashboard/[slug]/settings/+page.svelte | 17 +- .../[slug]/settings/members/+page.svelte | 453 ++++++++++++++++++ .../[slug]/settings/sites/+page.svelte | 40 ++ 7 files changed, 649 insertions(+), 19 deletions(-) create mode 100644 apps/publisher-dashboard/src/lib/components/layout/settings/index.ts create mode 100644 apps/publisher-dashboard/src/lib/components/layout/settings/settings-layout.svelte create mode 100644 apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/members/+page.svelte create mode 100644 apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte diff --git a/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte b/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte index 051aef0..0dd45dc 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte +++ b/apps/publisher-dashboard/src/lib/components/layout/dashboard/app-sidebar.svelte @@ -1,4 +1,5 @@ + + +
+ + + + +
+ {@render children()} +
+
+
diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte index 0a58f1a..7065f54 100644 --- a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/+page.svelte @@ -12,7 +12,7 @@ import { getContext } from "svelte"; import { toast } from "svelte-sonner"; import { goto } from "$app/navigation"; import { api } from "$lib/api/client"; -import { DashboardLayout } from "$lib/components/layout"; +import { SettingsLayout } from "$lib/components/layout"; import { ConfirmDialog } from "$lib/components/org"; import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Button } from "$lib/components/ui/button"; @@ -175,7 +175,7 @@ async function executeConfirmAction() { Settings | Publisher Dashboard
- + {#if isLoading || orgQuery.isPending}
@@ -192,7 +192,7 @@ async function executeConfirmAction() {

{:else} -
+
{#if canManageOrg} @@ -295,18 +295,9 @@ async function executeConfirmAction() { {/if} - -
{/if} - + +import { + AlertCircle, + Clock, + Loader2, + UserPlus, + Users, + X, +} from "@lucide/svelte"; +import { createQuery, useQueryClient } from "@tanstack/svelte-query"; +import { getContext } from "svelte"; +import { toast } from "svelte-sonner"; +import { api } from "$lib/api/client"; +import { SettingsLayout } from "$lib/components/layout"; +import { ConfirmDialog, RoleBadge } from "$lib/components/org"; +import { Button } from "$lib/components/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "$lib/components/ui/card"; +import { Input } from "$lib/components/ui/input"; +import { Label } from "$lib/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from "$lib/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "$lib/components/ui/table"; + +/** + * Members management settings page + */ + +// Types from API contract +type OrgMemberOutput = Awaited< + ReturnType +>[number]; +type OrgInviteOutput = Awaited< + ReturnType +>[number]; +type UserProfile = Awaited>; + +// Get org context from layout +const orgContext = getContext<{ + slug: string; + userQuery: { data: UserProfile | undefined }; + membersQuery: { data: OrgMemberOutput[] | undefined; isPending: boolean }; + currentUserRole: "owner" | "admin" | "member" | null; + canManageOrg: boolean; + isOwner: boolean; + isLoading: boolean; + error: Error | null; +}>("orgContext"); + +const slug = $derived(orgContext.slug); +const userData = $derived(orgContext.userQuery.data); +const membersData = $derived(orgContext.membersQuery.data); +const currentUserRole = $derived(orgContext.currentUserRole); +const canManageOrg = $derived(orgContext.canManageOrg); +const isOwner = $derived(orgContext.isOwner); +const isLoading = $derived(orgContext.isLoading); +const error = $derived(orgContext.error); +const currentUserId = $derived(userData?.id); + +const queryClient = useQueryClient(); + +// Fetch invites (only for admins+) +const invitesQuery = createQuery(() => ({ + queryKey: ["org", slug, "invites"], + queryFn: () => api.orgs.invites.list({ slug }), + enabled: !!slug && canManageOrg, +})); + +// Invite form state +let inviteEmail = $state(""); +let inviteRole = $state<"member" | "admin" | "owner">("member"); +let isInviting = $state(false); + +// Confirmation dialog state +let confirmDialogOpen = $state(false); +let confirmDialogTitle = $state(""); +let confirmDialogDescription = $state(""); +let confirmDialogVariant = $state<"default" | "destructive">("destructive"); +let confirmAction = $state<() => Promise>(() => Promise.resolve()); +let isConfirmLoading = $state(false); + +/** + * Send invite to email + */ +async function handleInvite() { + if (!inviteEmail.trim()) { + toast.error("Please enter an email address"); + return; + } + + isInviting = true; + try { + await api.orgs.invites.create({ + slug, + email: inviteEmail.trim(), + role: inviteRole, + }); + toast.success("Invitation sent!"); + inviteEmail = ""; + inviteRole = "member"; + await queryClient.invalidateQueries({ queryKey: ["org", slug, "invites"] }); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to send invitation"); + } finally { + isInviting = false; + } +} + +/** + * Cancel a pending invite + */ +async function handleCancelInvite(inviteId: number, email: string) { + confirmDialogTitle = "Cancel Invitation"; + confirmDialogDescription = `Are you sure you want to cancel the invitation to ${email}?`; + confirmDialogVariant = "destructive"; + confirmAction = async () => { + try { + await api.orgs.invites.cancel({ slug, inviteId }); + toast.success("Invitation cancelled"); + await queryClient.invalidateQueries({ + queryKey: ["org", slug, "invites"], + }); + } catch (e) { + toast.error( + e instanceof Error ? e.message : "Failed to cancel invitation", + ); + } + }; + confirmDialogOpen = true; +} + +/** + * Update member role + */ +async function handleUpdateRole( + userId: number, + newRole: "owner" | "admin" | "member", +) { + try { + await api.orgs.members.updateRole({ slug, userId, role: newRole }); + toast.success("Role updated"); + await queryClient.invalidateQueries({ queryKey: ["org", slug, "members"] }); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to update role"); + } +} + +/** + * Remove member + */ +async function handleRemoveMember( + userId: number, + displayName: string | null, + email: string, +) { + confirmDialogTitle = "Remove Member"; + confirmDialogDescription = `Are you sure you want to remove ${displayName || email} from this organization?`; + confirmDialogVariant = "destructive"; + confirmAction = async () => { + try { + await api.orgs.members.remove({ slug, userId }); + toast.success("Member removed"); + await queryClient.invalidateQueries({ + queryKey: ["org", slug, "members"], + }); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to remove member"); + } + }; + confirmDialogOpen = true; +} + +/** + * Execute confirm action + */ +async function executeConfirmAction() { + isConfirmLoading = true; + try { + await confirmAction(); + confirmDialogOpen = false; + } finally { + isConfirmLoading = false; + } +} + +/** + * Format relative time + */ +function formatRelativeTime(date: Date): string { + const now = new Date(); + const diff = date.getTime() - now.getTime(); + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); + + if (days < 0) return "Expired"; + if (days === 0) return "Today"; + if (days === 1) return "Tomorrow"; + return `${days} days`; +} + +/** + * Check if user can remove a member + */ +function canRemoveMember(memberRole: string, memberId: number): boolean { + if (memberId === currentUserId) return false; + if (isOwner) return true; + if (currentUserRole === "admin" && memberRole === "member") return true; + return false; +} + +/** + * Get available roles for invite based on current user's role + */ +const availableInviteRoles = $derived.by(() => { + if (isOwner) return ["member", "admin", "owner"] as const; + if (currentUserRole === "admin") return ["member", "admin"] as const; + return ["member"] as const; +}); + + + + Members | Publisher Dashboard + + + + {#if isLoading} +
+ +

Loading members...

+
+ {:else if error} +
+ +

+ {error instanceof Error ? error.message : "Failed to load members"} +

+
+ {:else} +
+ + {#if canManageOrg} + + + + + Invite Member + + + +
{ e.preventDefault(); handleInvite(); }} class="flex flex-col gap-4 sm:flex-row sm:items-end"> +
+ + +
+
+ + +
+ +
+
+
+ {/if} + + + {#if canManageOrg && invitesQuery.data && invitesQuery.data.length > 0} + + + + + Pending Invitations ({invitesQuery.data.length}) + + + + + + + Email + Role + Invited by + Expires + + + + + {#each invitesQuery.data as invite (invite.id)} + + {invite.email} + + {invite.invitedBy} + + {formatRelativeTime(new Date(invite.expiresAt))} + + + + + + {/each} + +
+
+
+ {/if} + + + + + + + Members ({membersData?.length ?? 0}) + + + + {#if membersData && membersData.length > 0} + + + + Member + Role + Joined + {#if canManageOrg} + + {/if} + + + + {#each membersData as member (member.id)} + {@const isCurrentUser = member.userId === currentUserId} + + +
+
+ {(member.displayName || member.email).charAt(0).toUpperCase()} +
+
+

+ {member.displayName || member.email} + {#if isCurrentUser} + (You) + {/if} +

+ {#if member.displayName} +

{member.email}

+ {/if} +
+
+
+ + {#if isOwner && !isCurrentUser} + + {:else} + + {/if} + + + {new Date(member.createdAt).toLocaleDateString()} + + {#if canManageOrg} + + {#if canRemoveMember(member.role, member.userId)} + + {/if} + + {/if} +
+ {/each} +
+
+ {:else} +

No members yet

+ {/if} +
+
+
+ {/if} +
+ + + confirmDialogOpen = false} +/> diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte new file mode 100644 index 0000000..919f1db --- /dev/null +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/sites/+page.svelte @@ -0,0 +1,40 @@ + + + + Sites | Publisher Dashboard + + + + + + + + Sites + + + Manage your connected websites and domains. + + + +
+
+ +
+

Coming Soon

+

+ Site management features are currently in development. +

+
+
+
+
From 587d17c39cffd6e39cb1d8cbe1f32d35f089a180 Mon Sep 17 00:00:00 2001 From: igm Date: Sat, 10 Jan 2026 18:25:22 +0800 Subject: [PATCH 13/15] Update README with comprehensive project documentation - Add tech stack overview (frontend, backend, shared packages) - Document project structure with directory tree - Expand setup instructions with manual development option - Add scripts reference table - Document features (auth, organizations, dashboard) - Add frontend routes overview - Document API structure and namespaces Co-Authored-By: Claude Opus 4.5 --- README.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/README.md b/README.md index f5ebf77..21b7fcf 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,52 @@ # Reviq Publisher Dashboard +A modern publisher dashboard for managing organizations, members, and sites. Built as a monorepo with SvelteKit frontend and oRPC API server. + +## Tech Stack + +### Frontend (`apps/publisher-dashboard`) +- **SvelteKit** with Svelte 5 (runes) +- **Tailwind CSS v4** for styling +- **TanStack Query** for data fetching +- **bits-ui** for accessible UI primitives +- **Lucide** for icons +- **WebAuthn/Passkeys** for passwordless authentication + +### Backend (`apps/api-server`) +- **Bun** runtime +- **oRPC** for type-safe API (contract-first) +- **Kysely** for type-safe SQL queries +- **PostgreSQL** database +- **Postmark** for transactional emails + +### Shared Packages +- `@reviq/api-contract` - Shared API contract (oRPC) +- `@reviq/db` - Database client and queries +- `@reviq/db-schema` - Database schema and codegen +- `@reviq/utils` - Shared utilities + +## Project Structure + +``` +publisher-dashboard/ +├── apps/ +│ ├── api-server/ # Backend API server +│ ├── cli/ # CLI tools +│ └── publisher-dashboard/ # SvelteKit frontend +├── packages/ +│ ├── api-contract/ # Shared oRPC contract +│ ├── db/ # Database client +│ ├── db-schema/ # DB schema & codegen +│ ├── testing/ # Test utilities +│ └── utils/ # Shared utilities +└── db/ # Database migrations +``` + ## Setup ### Prerequisites +- [Bun](https://bun.sh/) v1.1.42+ - [devenv](https://devenv.sh/) for development environment management ### Environment Variables @@ -29,9 +72,87 @@ devenv up This starts: - PostgreSQL database - Publisher dashboard dev server (port 6827) +- API server - Package build watcher The database is automatically initialized with: - Database: `reviq-dashboard` - User: `reviq` - Password: `reviq` + +### Manual Development + +If not using devenv, start services individually: + +```bash +# Install dependencies +bun install + +# Build packages first +bun run build:packages + +# Start dev server +bun run dev +``` + +## Scripts + +| Script | Description | +|--------|-------------| +| `bun run dev` | Start all dev servers | +| `bun run build` | Build all packages and apps | +| `bun run typecheck` | Run TypeScript type checking | +| `bun run lint` | Run Biome and ESLint | +| `bun run lint:fix` | Fix linting issues | +| `bun run test` | Run tests | +| `bun run db:codegen` | Generate database types | + +## Features + +### Authentication +- Passwordless login with passkeys (WebAuthn) +- Email verification +- Session management with device tracking + +### Organizations +- Create and manage organizations +- Member management with roles (owner, admin, member) +- Invite members via email +- Organization settings + +### Dashboard +- Organization switcher +- Performance metrics +- Reports (coming soon) +- Site management (coming soon) + +## Architecture + +### Frontend Routes + +``` +/ # Landing page +/login # Login page +/dashboard # Organization list +/dashboard/[slug] # Organization home +/dashboard/[slug]/performance # Performance metrics +/dashboard/[slug]/reports # Reports (placeholder) +/dashboard/[slug]/settings # Organization settings + ├── /members # Member management + └── /sites # Sites (placeholder) +/account # User account settings + ├── /security # Security settings + └── /sessions # Active sessions +/admin # Admin panel +``` + +### API Structure + +The API uses oRPC with a contract-first approach. Routes are defined in `@reviq/api-contract` and implemented in `apps/api-server`. + +Key API namespaces: +- `auth` - Authentication (passkeys, sessions) +- `me` - Current user profile +- `orgs` - Organization management +- `orgs.members` - Member management +- `orgs.invites` - Invitation management From 1b46fc0eccfc090e7a037871e163cd091f3114f5 Mon Sep 17 00:00:00 2001 From: igm Date: Sat, 10 Jan 2026 05:27:39 -0500 Subject: [PATCH 14/15] delete unsued --- docs/reference-webauthn.ts | 300 ------------------------------------- 1 file changed, 300 deletions(-) delete mode 100644 docs/reference-webauthn.ts diff --git a/docs/reference-webauthn.ts b/docs/reference-webauthn.ts deleted file mode 100644 index 8680c80..0000000 --- a/docs/reference-webauthn.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { - AuthenticationResponseJSON, - PublicKeyCredentialCreationOptionsJSON, - PublicKeyCredentialRequestOptionsJSON, - RegistrationResponseJSON, -} from "@simplewebauthn/types"; -import { - generateAuthenticationOptions, - generateRegistrationOptions, - verifyAuthenticationResponse, - verifyRegistrationResponse, -} from "@simplewebauthn/server"; -import { TRPCError } from "@trpc/server"; -import { uniq } from "lodash-es"; - -const KNOWN_AAGUIDS: Record = { - "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4": "Google Password Manager", - "adce0002-35bc-c60a-648b-0b25f1f05503": "Chrome on Mac", - "08987058-cadc-4b81-b6e1-30de50dcbe96": "Windows Hello", - "9ddd1817-af5a-4672-a2b9-3e3dd95000a9": "Windows Hello", - "6028b017-b1d4-4c02-b4b3-afcdafc96bb2": "Windows Hello", - "dd4ec289-e01d-41c9-bb89-70fa845d4bf2": "iCloud Keychain (Managed)", - "531126d6-e717-415c-9320-3d9aa6981239": "Dashlane", - "bada5566-a7aa-401f-bd96-45619a55120d": "1Password", - "b84e4048-15dc-4dd0-8640-f4f60813c8af": "NordPass", - "0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6": "Keeper", - "891494da-2c90-4d31-a9cd-4eab0aed1309": "Sésame", - "f3809540-7f14-49c1-a8b3-8f813b225541": "Enpass", - "b5397666-4885-aa6b-cebf-e52262a439a2": "Chromium Browser", - "771b48fd-d3d4-4f74-9232-fc157ab0507a": "Edge on Mac", - "39a5647e-1853-446c-a1f6-a79bae9f5bc7": "IDmelon", - "d548826e-79b4-db40-a3d8-11116f7e8349": "Bitwarden", - "fbfc3007-154e-4ecc-8c0b-6e020557d7bd": "iCloud Keychain", - "53414d53-554e-4700-0000-000000000000": "Samsung Pass", - "66a0ccb3-bd6a-191f-ee06-e375c50b9846": "Thales Bio iOS SDK", - "8836336a-f590-0921-301d-46427531eee6": "Thales Bio Android SDK", - "cd69adb5-3c7a-deb9-3177-6800ea6cb72a": "Thales PIN Android SDK", - "17290f1e-c212-34d0-1423-365d729f09d9": "Thales PIN iOS SDK", - "50726f74-6f6e-5061-7373-50726f746f6e": "Proton Pass", - "fdb141b2-5d84-443e-8a35-4698c205a502": "KeePassXC", - "cc45f64e-52a2-451b-831a-4edd8022a202": "ToothPic Passkey Provider", - "bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0": "iPasswords", - "b35a26b2-8f6e-4697-ab1d-d44db4da28c6": "Zoho Vault", - "b78a0a55-6ef8-d246-a042-ba0f6d55050c": "LastPass", - "de503f9c-21a4-4f76-b4b7-558eb55c6f89": "Devolutions", -}; - -export const getRPInfo = ( - ctx: APIContext, -): { - rpName: string; - rpID: string; - origins: string[]; -} => { - // RP must always be the frontend URL. - const rpID = ctx.origin.includes("oval.ph") - ? "oval.ph" - : new URL(ctx.origin).hostname; - const origins = uniq( - ctx.env.ALLOWED_WEBAUTHN_ORIGINS.split(",").map((o) => new URL(o).origin), - ); - return { - rpName: `Oval Business${rpID !== "oval.ph" ? ` (${rpID})` : ""}`, - rpID, - origins, - }; -}; - -export const getUserPasskeys = async (ctx: APIContext, userId: string) => { - const userPasskeys = await fetchPasskeyQuery(ctx.db) - .where("passkeys.user_id", "=", userId) - .execute(); - return userPasskeys.map(parsePasskey); -}; - -export const createRegistrationOptions = async ( - ctx: ProtectedAPIContext, -): Promise<{ - options: PublicKeyCredentialCreationOptionsJSON; - challengeId: PublicId<"passkey_challenges">; -}> => { - const { rpID, rpName } = getRPInfo(ctx); - const userPasskeys = await getUserPasskeys(ctx, ctx.user.id); - const options: PublicKeyCredentialCreationOptionsJSON = - await generateRegistrationOptions({ - rpName, - rpID, - userName: ctx.user.display_name, - // Don't prompt users for additional information about the authenticator - // (Recommended for smoother UX) - attestationType: "direct", - // Prevent users from re-registering existing authenticators - excludeCredentials: userPasskeys.map((passkey) => ({ - id: passkey.credentialId, - // Optional - transports: passkey.transports ?? undefined, - })), - // See "Guiding use of authenticators via authenticatorSelection" below - authenticatorSelection: { - // Defaults - residentKey: "preferred", - userVerification: "preferred", - // Optional - authenticatorAttachment: "platform", - }, - }); - const { public_id } = await ctx.db - .insertInto("passkey_challenges") - .values({ - options: JSON.stringify(options), - }) - .returning("public_id") - .executeTakeFirstOrThrow(); - return { - options, - challengeId: public_id, - }; -}; -export const verifyRegistration = async ( - ctx: ProtectedAPIContext, - { - challengeId, - response, - }: { - challengeId: PublicId<"passkey_challenges">; - response: RegistrationResponseJSON; - }, -): Promise => { - const { rpID, origins } = getRPInfo(ctx); - const optionsRaw = await ctx.db - .selectFrom("passkey_challenges") - .where("public_id", "=", challengeId) - .select("options") - .executeTakeFirst(); - if (!optionsRaw) { - throw new TRPCError({ - code: "TIMEOUT", - message: "Registration timed out. Please try again.", - }); - } - const options = - optionsRaw.options as unknown as PublicKeyCredentialCreationOptionsJSON; - - let verification; - try { - verification = await verifyRegistrationResponse({ - response, - expectedChallenge: options.challenge, - expectedOrigin: origins, - expectedRPID: rpID, - }); - } catch (error) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `Invalid registration response. Please try again. ${(error as { message?: string }).message ?? ""}`, - }); - } finally { - await ctx.db - .deleteFrom("passkey_challenges") - .where("public_id", "=", challengeId) - .execute(); - } - - const { verified, registrationInfo } = verification; - if (!(verified && registrationInfo)) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Unable to verify your device.", - }); - } - - const { credential, credentialDeviceType, credentialBackedUp } = - registrationInfo; - - const guidName = KNOWN_AAGUIDS[registrationInfo.aaguid]; - - const insert: PasskeyInsert = { - credentialId: credential.id, - webAuthnUserId: options.user.id, - counter: BigInt(credential.counter), - deviceType: credentialDeviceType, - backupStatus: credentialBackedUp, - transports: response.response.transports ?? null, - rpid: rpID, - name: `${guidName ?? "Key"} registered at ${formatDateTime(new Date())}`, - publicKey: credential.publicKey, - }; - - await ctx.db - .insertInto("passkeys") - .values( - passkeyToInsert(insert, { - rawUserId: ctx.user.id, - }), - ) - .execute(); -}; - -export const createAuthenticationOptions = async ( - ctx: APIContext, - userId: string, -): Promise<{ - options: PublicKeyCredentialRequestOptionsJSON; - challengeId: PublicId<"passkey_challenges">; -}> => { - const { rpID } = getRPInfo(ctx); - const userPasskeys = await getUserPasskeys(ctx, userId); - const options = await generateAuthenticationOptions({ - rpID, - // Require users to use a previously-registered authenticator - allowCredentials: userPasskeys.map((passkey) => ({ - id: passkey.credentialId, - transports: passkey.transports ?? undefined, - })), - }); - const { public_id: challengeId } = await ctx.db - .insertInto("passkey_challenges") - .values({ - options: JSON.stringify(options), - }) - .returning("public_id") - .executeTakeFirstOrThrow(); - return { - options, - challengeId: challengeId, - }; -}; - -export const verifyAuthentication = async ( - ctx: APIContext, - { - userId, - challengeId, - response, - }: { - userId: string; - challengeId: PublicId<"passkey_challenges">; - response: AuthenticationResponseJSON; - }, -): Promise => { - const { rpID, origins } = getRPInfo(ctx); - const optionsRaw = await ctx.db - .selectFrom("passkey_challenges") - .where("public_id", "=", challengeId) - .select("options") - .executeTakeFirst(); - if (!optionsRaw) { - throw new TRPCError({ - code: "TIMEOUT", - message: "Registration timed out. Please try again.", - }); - } - const options = - optionsRaw.options as unknown as PublicKeyCredentialRequestOptionsJSON; - try { - const userPasskeys = await getUserPasskeys(ctx, userId); - const passkey = userPasskeys.find( - (passkey) => passkey.credentialId === response.id, - ); - if (!passkey) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Unknown passkey.", - }); - } - - const verification = await verifyAuthenticationResponse({ - response, - expectedChallenge: options.challenge, - expectedOrigin: origins, - expectedRPID: rpID, - credential: { - id: passkey.credentialId, - publicKey: passkey.publicKey, - counter: Number.parseInt(passkey.counter.toString(), 10), - transports: passkey.transports ?? undefined, - }, - }); - - if (!verification.verified) { - return false; - } - - await ctx.db - .updateTable("passkeys") - .set((eb) => ({ - counter: verification.authenticationInfo.newCounter.toString(), - last_used_at: eb.fn("NOW"), - })) - .where("passkeys.id", "=", passkey.id) - .execute(); - } finally { - await ctx.db - .deleteFrom("passkey_challenges") - .where("public_id", "=", challengeId) - .execute(); - } - - return true; -}; From 48ffba6c5f085c3fa162e0f101a0058c0073b11b Mon Sep 17 00:00:00 2001 From: igm Date: Sat, 10 Jan 2026 18:49:49 +0800 Subject: [PATCH 15/15] Apply linting fixes to layout components Co-Authored-By: Claude Opus 4.5 --- .../src/lib/components/layout/index.ts | 1 - .../[slug]/settings/members/+page.svelte | 32 ++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/publisher-dashboard/src/lib/components/layout/index.ts b/apps/publisher-dashboard/src/lib/components/layout/index.ts index 0f77b26..7f8b83c 100644 --- a/apps/publisher-dashboard/src/lib/components/layout/index.ts +++ b/apps/publisher-dashboard/src/lib/components/layout/index.ts @@ -14,6 +14,5 @@ export { OrgSwitcher, UserMenu, } from "./dashboard/index.js"; - // Settings layout components export { SettingsLayout } from "./settings/index.js"; diff --git a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/members/+page.svelte b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/members/+page.svelte index 638a1ab..3f0a611 100644 --- a/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/members/+page.svelte +++ b/apps/publisher-dashboard/src/routes/dashboard/[slug]/settings/members/+page.svelte @@ -206,9 +206,15 @@ function formatRelativeTime(date: Date): string { const diff = date.getTime() - now.getTime(); const days = Math.ceil(diff / (1000 * 60 * 60 * 24)); - if (days < 0) return "Expired"; - if (days === 0) return "Today"; - if (days === 1) return "Tomorrow"; + if (days < 0) { + return "Expired"; + } + if (days === 0) { + return "Today"; + } + if (days === 1) { + return "Tomorrow"; + } return `${days} days`; } @@ -216,9 +222,15 @@ function formatRelativeTime(date: Date): string { * Check if user can remove a member */ function canRemoveMember(memberRole: string, memberId: number): boolean { - if (memberId === currentUserId) return false; - if (isOwner) return true; - if (currentUserRole === "admin" && memberRole === "member") return true; + if (memberId === currentUserId) { + return false; + } + if (isOwner) { + return true; + } + if (currentUserRole === "admin" && memberRole === "member") { + return true; + } return false; } @@ -226,8 +238,12 @@ function canRemoveMember(memberRole: string, memberId: number): boolean { * Get available roles for invite based on current user's role */ const availableInviteRoles = $derived.by(() => { - if (isOwner) return ["member", "admin", "owner"] as const; - if (currentUserRole === "admin") return ["member", "admin"] as const; + if (isOwner) { + return ["member", "admin", "owner"] as const; + } + if (currentUserRole === "admin") { + return ["member", "admin"] as const; + } return ["member"] as const; });