diff --git a/backend/oss-migrations/20260606_oss_schema_diff.sql b/backend/oss-migrations/20260606_oss_schema_diff.sql index 6020985..2664620 100644 --- a/backend/oss-migrations/20260606_oss_schema_diff.sql +++ b/backend/oss-migrations/20260606_oss_schema_diff.sql @@ -10,6 +10,7 @@ alter table public.user_profiles add column if not exists title_model text, + add column if not exists mfa_on_login boolean not null default false, add column if not exists quote_model text; -- --------------------------------------------------------------------------- diff --git a/backend/schema.sql b/backend/schema.sql index 359efcf..7ab1464 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -19,6 +19,7 @@ create table if not exists public.user_profiles ( title_model text, tabular_model text not null default 'gemini-3-flash-preview', quote_model text, + mfa_on_login boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); diff --git a/backend/src/index.ts b/backend/src/index.ts index e8732f2..cbc91c3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -72,6 +72,18 @@ const uploadLimiter = makeLimiter({ message: "Too many upload requests. Please try again later.", }); +const exportLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_EXPORT_MAX", 10), + message: "Too many export requests. Please try again later.", +}); + +const dataDeleteLimiter = makeLimiter({ + windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)), + max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20), + message: "Too many data deletion requests. Please try again later.", +}); + function jsonLimitForPath(path: string): string { return "50mb"; } @@ -117,6 +129,13 @@ app.post("/chat/:chatId/generate-title", chatCreateLimiter); app.post("/single-documents", uploadLimiter); app.post("/single-documents/:documentId/versions", uploadLimiter); app.post("/projects/:projectId/documents", uploadLimiter); +app.get("/user/export", exportLimiter); +app.get("/user/chats/export", exportLimiter); +app.get("/user/tabular-reviews/export", exportLimiter); +app.delete("/user/account", dataDeleteLimiter); +app.delete("/user/chats", dataDeleteLimiter); +app.delete("/user/projects", dataDeleteLimiter); +app.delete("/user/tabular-reviews", dataDeleteLimiter); app.use((req, res, next) => express.json({ limit: jsonLimitForPath(req.path) })(req, res, next), diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index 5eeb9ae..5f8d98e 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -37,6 +37,7 @@ import { type LlmMessage, type OpenAIToolSchema, } from "./llm"; +import { safeErrorMessage } from "./safeError"; const STANDARD_FONT_DATA_URL = (() => { try { @@ -4172,8 +4173,7 @@ export async function runLLMStream(params: { throw new AssistantStreamAbortError(fullText, events); } flushPartialTurn(); - const message = - err instanceof Error && err.message ? err.message : "Stream error"; + const message = safeErrorMessage(err, "Stream error"); events.push({ type: "error", message }); throw new AssistantStreamError(message, fullText, events); } diff --git a/backend/src/lib/convert.ts b/backend/src/lib/convert.ts index 056f6b8..69df35a 100644 --- a/backend/src/lib/convert.ts +++ b/backend/src/lib/convert.ts @@ -1,25 +1,81 @@ import JSZip from "jszip"; +import fs from "node:fs"; +import path from "node:path"; let _convert: | ((buf: Buffer, ext: string, filter: undefined) => Promise) | null = null; +let _sofficeBinaryPaths: string[] | null = null; + +function executablePath(filePath: string) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function resolveSofficeBinaryPaths(): string[] { + if (_sofficeBinaryPaths) return _sofficeBinaryPaths; + + const candidates = new Set(); + for (const envName of [ + "SOFFICE_BINARY_PATH", + "LIBREOFFICE_BINARY_PATH", + "LIBRE_OFFICE_EXE", + ]) { + const value = process.env[envName]?.trim(); + if (value) candidates.add(value); + } + + const pathDirs = (process.env.PATH ?? "") + .split(path.delimiter) + .filter(Boolean); + for (const dir of pathDirs) { + candidates.add(path.join(dir, "soffice")); + candidates.add(path.join(dir, "libreoffice")); + } + + for (const filePath of [ + "/usr/bin/libreoffice", + "/usr/bin/soffice", + "/snap/bin/libreoffice", + "/opt/libreoffice/program/soffice", + "/opt/libreoffice7.6/program/soffice", + ]) { + candidates.add(filePath); + } + + _sofficeBinaryPaths = [...candidates].filter(executablePath); + return _sofficeBinaryPaths; +} async function getConvert() { if (!_convert) { const libre = await import("libreoffice-convert"); - const convert = libre.default.convert.bind(libre.default) as ( + const convertWithOptions = libre.default.convertWithOptions.bind( + libre.default, + ) as ( buf: Buffer, ext: string, filter: undefined, + options: { sofficeBinaryPaths?: string[] }, callback?: (err: Error | null, result: Buffer) => void, ) => Promise | void; _convert = (buf, ext, filter) => new Promise((resolve, reject) => { try { - const maybePromise = convert(buf, ext, filter, (err, result) => { - if (err) reject(err); - else resolve(result); - }); + const maybePromise = convertWithOptions( + buf, + ext, + filter, + { sofficeBinaryPaths: resolveSofficeBinaryPaths() }, + (err, result) => { + if (err) reject(err); + else resolve(result); + }, + ); if (maybePromise && typeof maybePromise.then === "function") { maybePromise.then(resolve, reject); } @@ -67,6 +123,11 @@ export async function normalizeDocxZipPaths(buffer: Buffer): Promise { * Throws if LibreOffice is not installed or conversion fails. */ export async function docxToPdf(buffer: Buffer): Promise { + if (resolveSofficeBinaryPaths().length === 0) { + throw new Error( + "LibreOffice/soffice binary was not found. Ensure Railway uses backend/nixpacks.toml or set SOFFICE_BINARY_PATH/LIBREOFFICE_BINARY_PATH.", + ); + } const convert = await getConvert(); const normalized = await normalizeDocxZipPaths(buffer); return convert(normalized, ".pdf", undefined); diff --git a/backend/src/lib/safeError.ts b/backend/src/lib/safeError.ts new file mode 100644 index 0000000..a92e257 --- /dev/null +++ b/backend/src/lib/safeError.ts @@ -0,0 +1,59 @@ +const SECRET_CONTEXT_PATTERNS = [ + /(Incorrect API key provided:\s*)([^.\s]+)(\.?)/gi, + /(api[_ -]?key|x-api-key|token|secret|authorization|bearer)\s*(?:provided\s*)?(?:is|:|=)\s*["']?([A-Za-z0-9._\-]{6,})["']?/gi, +]; + +const PROVIDER_KEY_PATTERNS = [ + /\bsk-[A-Za-z0-9_\-]{12,}\b/g, + /\bsk-ant-[A-Za-z0-9_\-]{12,}\b/g, + /\bsk-or-[A-Za-z0-9_\-]{12,}\b/g, + /\bAIza[A-Za-z0-9_\-]{20,}\b/g, +]; + +export function redactSensitiveText(value: string): string { + let redacted = value; + for (const pattern of SECRET_CONTEXT_PATTERNS) { + redacted = redacted.replace(pattern, (match, ...groups: string[]) => { + if (match.toLowerCase().startsWith("incorrect api key provided:")) { + return `${groups[0]}[redacted]${groups[2] ?? ""}`; + } + const secret = groups[1]; + return secret ? match.replace(secret, "[redacted]") : match; + }); + } + for (const pattern of PROVIDER_KEY_PATTERNS) { + redacted = redacted.replace(pattern, "[redacted]"); + } + return redacted; +} + +export function safeErrorMessage( + error: unknown, + fallback = "Unexpected error", +): string { + const message = + error instanceof Error && error.message + ? error.message + : typeof error === "string" + ? error + : fallback; + return redactSensitiveText(message); +} + +export function safeErrorLog(error: unknown): { + name: string | null; + message: string; + stack?: string; +} { + if (error instanceof Error) { + return { + name: error.name || null, + message: redactSensitiveText(error.message || "Unexpected error"), + stack: error.stack ? redactSensitiveText(error.stack) : undefined, + }; + } + return { + name: null, + message: safeErrorMessage(error), + }; +} diff --git a/backend/src/lib/userDataCleanup.ts b/backend/src/lib/userDataCleanup.ts new file mode 100644 index 0000000..8f222c6 --- /dev/null +++ b/backend/src/lib/userDataCleanup.ts @@ -0,0 +1,339 @@ +import { createServerSupabase } from "./supabase"; +import { deleteFile, listFiles } from "./storage"; + +type Db = ReturnType; + +const DELETE_BATCH_SIZE = 500; + +function uniqueStrings(values: Array): string[] { + return [...new Set(values.filter((value): value is string => !!value))]; +} + +function chunks(values: T[], size = DELETE_BATCH_SIZE): T[][] { + const result: T[][] = []; + for (let i = 0; i < values.length; i += size) { + result.push(values.slice(i, i + size)); + } + return result; +} + +async function throwIfError( + error: T, + context: string, +) { + if (error) throw new Error(`${context}: ${error.message ?? "unknown error"}`); +} + +async function deleteByIds(db: Db, table: string, ids: string[]) { + for (const batch of chunks(ids)) { + const { error } = await (db as any).from(table).delete().in("id", batch); + await throwIfError(error, `Failed to delete ${table}`); + } +} + +async function deleteWhereIn( + db: Db, + table: string, + column: string, + values: string[], +) { + for (const batch of chunks(values)) { + const { error } = await (db as any) + .from(table) + .delete() + .in(column, batch); + await throwIfError(error, `Failed to delete ${table}`); + } +} + +async function getOwnedProjectIds(db: Db, userId: string): Promise { + const { data, error } = await db + .from("projects") + .select("id") + .eq("user_id", userId); + await throwIfError(error, "Failed to load user projects"); + return uniqueStrings((data ?? []).map((row) => row.id as string | null)); +} + +async function getDocumentIdsForAccountDeletion( + db: Db, + userId: string, + ownedProjectIds: string[], +): Promise { + const [ownedDocs, projectDocs] = await Promise.all([ + db.from("documents").select("id").eq("user_id", userId), + ownedProjectIds.length > 0 + ? db.from("documents").select("id").in("project_id", ownedProjectIds) + : Promise.resolve({ data: [], error: null }), + ]); + + await throwIfError(ownedDocs.error, "Failed to load user documents"); + await throwIfError(projectDocs.error, "Failed to load project documents"); + + return uniqueStrings([ + ...((ownedDocs.data ?? []) as { id: string | null }[]).map((row) => row.id), + ...((projectDocs.data ?? []) as { id: string | null }[]).map((row) => row.id), + ]); +} + +async function deleteDocumentVersionFiles(db: Db, documentIds: string[]) { + const paths = new Set(); + + for (const batch of chunks(documentIds)) { + const { data, error } = await db + .from("document_versions") + .select("storage_path, pdf_storage_path") + .in("document_id", batch); + await throwIfError(error, "Failed to load document storage paths"); + + for (const version of data ?? []) { + if ( + typeof version.storage_path === "string" && + version.storage_path.length > 0 + ) { + paths.add(version.storage_path); + } + if ( + typeof version.pdf_storage_path === "string" && + version.pdf_storage_path.length > 0 + ) { + paths.add(version.pdf_storage_path); + } + } + } + + await Promise.all([...paths].map((path) => deleteFile(path))); +} + +async function deleteUserStoragePrefix(userId: string) { + try { + const paths = await listFiles(`documents/${userId}/`); + await Promise.all(paths.map((path) => deleteFile(path).catch(() => {}))); + } catch { + // Version-linked objects are deleted above. Prefix cleanup is best-effort + // for orphaned files left behind by interrupted uploads. + } +} + +async function removeEmailFromSharedWith( + db: Db, + table: "projects" | "tabular_reviews", + email: string | null | undefined, +) { + const normalizedEmail = email?.trim().toLowerCase(); + if (!normalizedEmail) return; + + const { data, error } = await db + .from(table) + .select("id, shared_with") + .filter("shared_with", "cs", JSON.stringify([normalizedEmail])); + await throwIfError(error, `Failed to load shared ${table}`); + + const updates = (data ?? []) + .map((row) => { + const sharedWith = Array.isArray(row.shared_with) + ? row.shared_with.filter( + (value) => + typeof value !== "string" || + value.trim().toLowerCase() !== normalizedEmail, + ) + : []; + return { id: row.id as string, sharedWith }; + }) + .filter((row) => row.id); + + await Promise.all( + updates.map(async ({ id, sharedWith }) => { + const { error: updateError } = await db + .from(table) + .update({ shared_with: sharedWith }) + .eq("id", id); + await throwIfError(updateError, `Failed to update shared ${table}`); + }), + ); +} + +export async function deleteAllUserChats(db: Db, userId: string) { + const [assistantChats, tabularChats] = await Promise.all([ + db.from("chats").delete().eq("user_id", userId), + db.from("tabular_review_chats").delete().eq("user_id", userId), + ]); + + await throwIfError(assistantChats.error, "Failed to delete assistant chats"); + await throwIfError(tabularChats.error, "Failed to delete tabular chats"); +} + +export async function deleteAllUserTabularReviews(db: Db, userId: string) { + const { data: reviews, error: reviewsError } = await db + .from("tabular_reviews") + .select("id") + .eq("user_id", userId); + await throwIfError(reviewsError, "Failed to load tabular reviews"); + + const reviewIds = uniqueStrings( + ((reviews ?? []) as { id: string | null }[]).map((row) => row.id), + ); + if (reviewIds.length === 0) return 0; + + const { data: reviewChats, error: reviewChatsError } = await db + .from("tabular_review_chats") + .select("id") + .in("review_id", reviewIds); + await throwIfError(reviewChatsError, "Failed to load tabular review chats"); + + const reviewChatIds = uniqueStrings( + ((reviewChats ?? []) as { id: string | null }[]).map((row) => row.id), + ); + + await deleteWhereIn( + db, + "tabular_review_chat_messages", + "chat_id", + reviewChatIds, + ); + await deleteWhereIn(db, "tabular_review_chats", "review_id", reviewIds); + await deleteWhereIn(db, "tabular_cells", "review_id", reviewIds); + await deleteByIds(db, "tabular_reviews", reviewIds); + + return reviewIds.length; +} + +export async function deleteUserProjects( + db: Db, + userId: string, + projectIds?: string[], +) { + const requestedProjectIds = projectIds + ? uniqueStrings(projectIds) + : undefined; + if (requestedProjectIds && requestedProjectIds.length === 0) return 0; + + let query = db.from("projects").select("id").eq("user_id", userId); + if (requestedProjectIds) query = query.in("id", requestedProjectIds); + + const { data: projects, error: projectsError } = await query; + await throwIfError(projectsError, "Failed to load user projects"); + + const ownedProjectIds = uniqueStrings( + ((projects ?? []) as { id: string | null }[]).map((row) => row.id), + ); + if (ownedProjectIds.length === 0) return 0; + + const [projectDocs, projectChats, projectReviews, projectFolders] = + await Promise.all([ + db.from("documents").select("id").in("project_id", ownedProjectIds), + db.from("chats").select("id").in("project_id", ownedProjectIds), + db + .from("tabular_reviews") + .select("id") + .in("project_id", ownedProjectIds), + db + .from("project_subfolders") + .select("id") + .in("project_id", ownedProjectIds), + ]); + + await throwIfError(projectDocs.error, "Failed to load project documents"); + await throwIfError(projectChats.error, "Failed to load project chats"); + await throwIfError( + projectReviews.error, + "Failed to load project tabular reviews", + ); + await throwIfError(projectFolders.error, "Failed to load project folders"); + + const documentIds = uniqueStrings( + ((projectDocs.data ?? []) as { id: string | null }[]).map( + (row) => row.id, + ), + ); + const chatIds = uniqueStrings( + ((projectChats.data ?? []) as { id: string | null }[]).map( + (row) => row.id, + ), + ); + const reviewIds = uniqueStrings( + ((projectReviews.data ?? []) as { id: string | null }[]).map( + (row) => row.id, + ), + ); + const folderIds = uniqueStrings( + ((projectFolders.data ?? []) as { id: string | null }[]).map( + (row) => row.id, + ), + ); + + const { data: reviewChats, error: reviewChatsError } = + reviewIds.length > 0 + ? await db + .from("tabular_review_chats") + .select("id") + .in("review_id", reviewIds) + : { data: [], error: null }; + await throwIfError(reviewChatsError, "Failed to load project review chats"); + + const reviewChatIds = uniqueStrings( + ((reviewChats ?? []) as { id: string | null }[]).map((row) => row.id), + ); + + await deleteDocumentVersionFiles(db, documentIds); + await deleteWhereIn( + db, + "tabular_review_chat_messages", + "chat_id", + reviewChatIds, + ); + await deleteWhereIn(db, "tabular_review_chats", "review_id", reviewIds); + await deleteWhereIn(db, "tabular_cells", "review_id", reviewIds); + await deleteByIds(db, "tabular_reviews", reviewIds); + await deleteWhereIn(db, "chat_messages", "chat_id", chatIds); + await deleteByIds(db, "chats", chatIds); + await deleteByIds(db, "documents", documentIds); + await deleteByIds(db, "project_subfolders", folderIds); + await deleteByIds(db, "projects", ownedProjectIds); + + return ownedProjectIds.length; +} + +export async function deleteUserAccountData( + db: Db, + userId: string, + userEmail?: string | null, +) { + const ownedProjectIds = await getOwnedProjectIds(db, userId); + const documentIds = await getDocumentIdsForAccountDeletion( + db, + userId, + ownedProjectIds, + ); + + await Promise.all([ + removeEmailFromSharedWith(db, "projects", userEmail), + removeEmailFromSharedWith(db, "tabular_reviews", userEmail), + deleteDocumentVersionFiles(db, documentIds), + deleteUserStoragePrefix(userId), + ]); + + await deleteByIds(db, "documents", documentIds); + + const deletions = [ + db.from("tabular_review_chats").delete().eq("user_id", userId), + db.from("tabular_reviews").delete().eq("user_id", userId), + db.from("chats").delete().eq("user_id", userId), + db.from("project_subfolders").delete().eq("user_id", userId), + db.from("hidden_workflows").delete().eq("user_id", userId), + db.from("workflow_shares").delete().eq("shared_by_user_id", userId), + userEmail + ? db + .from("workflow_shares") + .delete() + .eq("shared_with_email", userEmail.trim().toLowerCase()) + : Promise.resolve({ error: null }), + db.from("workflows").delete().eq("user_id", userId), + db.from("projects").delete().eq("user_id", userId), + ]; + + const results = await Promise.all(deletions); + for (const result of results) { + await throwIfError(result.error, "Failed to delete account data"); + } +} diff --git a/backend/src/lib/userDataExport.ts b/backend/src/lib/userDataExport.ts new file mode 100644 index 0000000..e2b3310 --- /dev/null +++ b/backend/src/lib/userDataExport.ts @@ -0,0 +1,278 @@ +import { createServerSupabase } from "./supabase"; + +type Db = ReturnType; + +const PAGE_SIZE = 1000; + +function nowStamp() { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +export function userExportFilename( + kind: "account" | "chats" | "tabular-reviews", + userId: string, +) { + return `mike-${kind}-export-${userId.slice(0, 8)}-${nowStamp()}.json`; +} + +function uniqueStrings(values: Array): string[] { + return [...new Set(values.filter((value): value is string => !!value))]; +} + +async function throwIfError( + error: T, + context: string, +) { + if (error) throw new Error(`${context}: ${error.message ?? "unknown error"}`); +} + +async function selectAll( + db: Db, + table: string, + configure: (query: any) => any, + columns = "*", +): Promise[]> { + const rows: Record[] = []; + + for (let from = 0; ; from += PAGE_SIZE) { + const to = from + PAGE_SIZE - 1; + const query = configure( + (db as any) + .from(table) + .select(columns) + .range(from, to), + ); + const { data, error } = await query; + await throwIfError(error, `Failed to export ${table}`); + const batch = (data ?? []) as Record[]; + rows.push(...batch); + if (batch.length < PAGE_SIZE) break; + } + + return rows; +} + +async function selectByIds( + db: Db, + table: string, + column: string, + ids: string[], +): Promise[]> { + if (ids.length === 0) return []; + return selectAll(db, table, (query) => query.in(column, ids)); +} + +function idsFrom(rows: Record[], column = "id"): string[] { + return uniqueStrings( + rows.map((row) => + typeof row[column] === "string" ? (row[column] as string) : null, + ), + ); +} + +async function loadUserChats(db: Db, userId: string) { + const chats = await selectAll(db, "chats", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ); + const chatIds = idsFrom(chats); + const messages = await selectByIds(db, "chat_messages", "chat_id", chatIds); + return { chats, messages }; +} + +async function loadUserTabularChats(db: Db, userId: string) { + const chats = await selectAll(db, "tabular_review_chats", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ); + const chatIds = idsFrom(chats); + const messages = await selectByIds( + db, + "tabular_review_chat_messages", + "chat_id", + chatIds, + ); + return { chats, messages }; +} + +async function loadApiKeyStatus(db: Db, userId: string) { + const rows = await selectAll(db, "user_api_keys", (query) => + query + .eq("user_id", userId) + .order("provider", { ascending: true }), + "provider, created_at, updated_at", + ); + return rows.map((row) => ({ + provider: row.provider, + has_key: true, + created_at: row.created_at, + updated_at: row.updated_at, + })); +} + +export async function buildUserChatsExport( + db: Db, + userId: string, + userEmail?: string | null, +) { + const [assistant, tabular] = await Promise.all([ + loadUserChats(db, userId), + loadUserTabularChats(db, userId), + ]); + + return { + exported_at: new Date().toISOString(), + user: { id: userId, email: userEmail ?? null }, + assistant_chats: assistant, + tabular_review_chats: tabular, + }; +} + +export async function buildUserTabularReviewsExport( + db: Db, + userId: string, + userEmail?: string | null, +) { + const tabularReviews = await selectAll(db, "tabular_reviews", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ); + const reviewIds = idsFrom(tabularReviews); + + const [cells, chats] = await Promise.all([ + selectByIds(db, "tabular_cells", "review_id", reviewIds), + selectByIds(db, "tabular_review_chats", "review_id", reviewIds), + ]); + const chatIds = idsFrom(chats); + const messages = await selectByIds( + db, + "tabular_review_chat_messages", + "chat_id", + chatIds, + ); + + return { + exported_at: new Date().toISOString(), + user: { id: userId, email: userEmail ?? null }, + tabular_reviews: tabularReviews, + tabular_cells: cells, + tabular_review_chats: { + chats, + messages, + }, + }; +} + +export async function buildUserAccountExport( + db: Db, + userId: string, + userEmail?: string | null, +) { + const [ + profile, + apiKeys, + projects, + standaloneDocuments, + workflows, + hiddenWorkflows, + workflowSharesByUser, + workflowSharesWithUser, + assistantChats, + tabularChats, + tabularReviews, + sharedProjects, + sharedTabularReviews, + ] = await Promise.all([ + selectAll(db, "user_profiles", (query) => query.eq("user_id", userId)), + loadApiKeyStatus(db, userId), + selectAll(db, "projects", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ), + selectAll(db, "documents", (query) => + query + .eq("user_id", userId) + .is("project_id", null) + .order("created_at", { ascending: true }), + ), + selectAll(db, "workflows", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ), + selectAll(db, "hidden_workflows", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ), + selectAll(db, "workflow_shares", (query) => + query + .eq("shared_by_user_id", userId) + .order("created_at", { ascending: true }), + ), + userEmail + ? selectAll(db, "workflow_shares", (query) => + query + .eq("shared_with_email", userEmail) + .order("created_at", { ascending: true }), + ) + : Promise.resolve([]), + loadUserChats(db, userId), + loadUserTabularChats(db, userId), + selectAll(db, "tabular_reviews", (query) => + query.eq("user_id", userId).order("created_at", { ascending: true }), + ), + userEmail + ? selectAll(db, "projects", (query) => + query + .filter("shared_with", "cs", JSON.stringify([userEmail])) + .neq("user_id", userId) + .order("created_at", { ascending: true }), + "id, user_id, name, cm_number, created_at, updated_at", + ) + : Promise.resolve([]), + userEmail + ? selectAll(db, "tabular_reviews", (query) => + query + .filter("shared_with", "cs", JSON.stringify([userEmail])) + .neq("user_id", userId) + .order("created_at", { ascending: true }), + "id, user_id, project_id, title, practice, created_at, updated_at", + ) + : Promise.resolve([]), + ]); + + const projectIds = idsFrom(projects); + const projectDocuments = await selectByIds( + db, + "documents", + "project_id", + projectIds, + ); + const documents = [...standaloneDocuments, ...projectDocuments]; + const documentIds = idsFrom(documents); + const reviewIds = idsFrom(tabularReviews); + + const [folders, versions, edits, tabularCells] = await Promise.all([ + selectByIds(db, "project_subfolders", "project_id", projectIds), + selectByIds(db, "document_versions", "document_id", documentIds), + selectByIds(db, "document_edits", "document_id", documentIds), + selectByIds(db, "tabular_cells", "review_id", reviewIds), + ]); + + return { + exported_at: new Date().toISOString(), + user: { id: userId, email: userEmail ?? null }, + profile, + api_keys: apiKeys, + projects, + project_subfolders: folders, + documents, + document_versions: versions, + document_edits: edits, + workflows, + hidden_workflows: hiddenWorkflows, + workflow_shares_by_user: workflowSharesByUser, + workflow_shares_with_user: workflowSharesWithUser, + chats: assistantChats, + tabular_reviews: tabularReviews, + tabular_cells: tabularCells, + tabular_review_chats: tabularChats, + shared_access: { + projects: sharedProjects, + tabular_reviews: sharedTabularReviews, + }, + }; +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index f30fd13..dac083a 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,90 @@ import { Request, Response, NextFunction } from "express"; -import { createClient } from "@supabase/supabase-js"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; + +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + if (isDev) console.log(...args); +}; + +function summarizeMfaFactors( + factors: Array<{ + factor_type?: string; + status?: string; + }> | null | undefined, +) { + return (factors ?? []).map((factor) => ({ + type: factor.factor_type ?? "unknown", + status: factor.status ?? "unknown", + })); +} + +function isLoginMfaBootstrapRoute(req: Request) { + const path = req.originalUrl.split("?")[0]; + return ( + (req.method === "GET" || req.method === "POST") && + (path === "/user/profile" || path === "/users/profile") + ); +} + +async function enforceLoginMfaIfEnabled( + req: Request, + res: Response, + admin: SupabaseClient, + token: string, +) { + if (isLoginMfaBootstrapRoute(req)) return true; + + const { data, error } = await admin + .from("user_profiles") + .select("mfa_on_login") + .eq("user_id", res.locals.userId) + .maybeSingle(); + + if (error) { + devLog("[auth/mfa] login preference lookup failed", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + error: error.message, + code: error.code, + }); + if (error.code === "42703") return true; + res.status(500).json({ detail: error.message }); + return false; + } + + const profile = data as { mfa_on_login?: boolean } | null; + if (profile?.mfa_on_login !== true) return true; + + const { data: assurance, error: assuranceError } = + await admin.auth.mfa.getAuthenticatorAssuranceLevel(token); + + if (assuranceError) { + devLog("[auth/mfa] login assurance lookup failed", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + error: assuranceError.message, + }); + res.status(401).json({ detail: assuranceError.message }); + return false; + } + + if (assurance.nextLevel === "aal2" && assurance.currentLevel !== "aal2") { + devLog("[auth/mfa] login verification required", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + }); + res.status(403).json({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); + return false; + } + + return true; +} export async function requireAuth( req: Request, @@ -33,5 +118,85 @@ export async function requireAuth( res.locals.userId = data.user.id; res.locals.userEmail = data.user.email?.toLowerCase() ?? ""; res.locals.token = token; + if (!(await enforceLoginMfaIfEnabled(req, res, admin, token))) { + return; + } + next(); +} + +export async function requireMfaIfEnrolled( + req: Request, + res: Response, + next: NextFunction, +): Promise { + const token = typeof res.locals.token === "string" ? res.locals.token : ""; + if (!token) { + devLog("[auth/mfa] missing auth session", { + method: req.method, + path: req.originalUrl, + }); + res.status(401).json({ detail: "Missing auth session" }); + return; + } + + const supabaseUrl = process.env.SUPABASE_URL ?? ""; + const serviceKey = process.env.SUPABASE_SECRET_KEY ?? ""; + + if (!supabaseUrl || !serviceKey) { + res.status(500).json({ detail: "Server auth is not configured" }); + return; + } + + const admin = createClient(supabaseUrl, serviceKey, { + auth: { persistSession: false }, + }); + const { data, error } = + await admin.auth.mfa.getAuthenticatorAssuranceLevel(token); + + if (error) { + devLog("[auth/mfa] assurance lookup failed", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + error: error.message, + }); + res.status(401).json({ detail: error.message }); + return; + } + + devLog("[auth/mfa] assurance level", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + currentLevel: data.currentLevel, + nextLevel: data.nextLevel, + required: data.nextLevel === "aal2" && data.currentLevel !== "aal2", + }); + + if (isDev) { + const { data: userData, error: userError } = await admin.auth.getUser(token); + devLog("[auth/mfa] user factors", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + factorCount: userData.user?.factors?.length ?? 0, + factors: summarizeMfaFactors(userData.user?.factors), + error: userError?.message ?? null, + }); + } + + if (data.nextLevel === "aal2" && data.currentLevel !== "aal2") { + devLog("[auth/mfa] verification required", { + method: req.method, + path: req.originalUrl, + userId: res.locals.userId, + }); + res.status(403).json({ + code: "mfa_verification_required", + detail: "MFA verification required", + }); + return; + } + next(); } diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 96b1fc7..b144865 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -17,6 +17,7 @@ import { import { completeText } from "../lib/llm"; import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; export const chatRouter = Router(); @@ -427,7 +428,7 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => { res.json({ title }); } catch (err) { - console.error("[generate-title]", err); + console.error("[generate-title]", safeErrorLog(err)); res.status(500).json({ detail: "Failed to generate title" }); } }); @@ -639,9 +640,8 @@ chatRouter.post("/", requireAuth, async (req, res) => { } return; } - console.error("[chat/stream] error:", err); - const message = - err instanceof Error && err.message ? err.message : "Stream error"; + console.error("[chat/stream] error:", safeErrorLog(err)); + const message = safeErrorMessage(err, "Stream error"); const errorEvents = err instanceof AssistantStreamError ? stripTransientAssistantEvents(err.events) : [{ type: "error" as const, message }]; diff --git a/backend/src/routes/projectChat.ts b/backend/src/routes/projectChat.ts index 29a62d9..76cea58 100644 --- a/backend/src/routes/projectChat.ts +++ b/backend/src/routes/projectChat.ts @@ -17,6 +17,7 @@ import { } from "../lib/chatTools"; import { getUserApiKeys } from "../lib/userSettings"; import { checkProjectAccess } from "../lib/access"; +import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; const PROJECT_SYSTEM_PROMPT_EXTRA = `PROJECT CONTEXT: You are operating within a project folder that contains a collection of legal documents the user has organised for a single matter. The user's questions will usually refer to one or more documents in this project — your job is to find the relevant files to work on. Use list_documents to see what is available and fetch_documents / read_document to pull in any documents you need before answering. @@ -224,9 +225,8 @@ projectChatRouter.post("/", requireAuth, async (req, res) => { } return; } - console.error("[project-chat/stream] error:", err); - const message = - err instanceof Error && err.message ? err.message : "Stream error"; + console.error("[project-chat/stream] error:", safeErrorLog(err)); + const message = safeErrorMessage(err, "Stream error"); const errorEvents = err instanceof AssistantStreamError ? stripTransientAssistantEvents(err.events) : [{ type: "error" as const, message }]; diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 0b62b8e..850e339 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -15,6 +15,7 @@ import { import { docxToPdf, convertedPdfKey } from "../lib/convert"; import { checkProjectAccess } from "../lib/access"; import { singleFileUpload } from "../lib/upload"; +import { deleteUserProjects } from "../lib/userDataCleanup"; export const projectsRouter = Router(); const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]); @@ -345,13 +346,15 @@ projectsRouter.delete("/:projectId", requireAuth, async (req, res) => { const userId = res.locals.userId as string; const { projectId } = req.params; const db = createServerSupabase(); - const { error } = await db - .from("projects") - .delete() - .eq("id", projectId) - .eq("user_id", userId); - if (error) return void res.status(500).json({ detail: error.message }); - res.status(204).send(); + try { + const deletedCount = await deleteUserProjects(db, userId, [projectId]); + if (deletedCount === 0) + return void res.status(404).json({ detail: "Project not found" }); + res.status(204).send(); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + res.status(500).json({ detail }); + } }); // GET /projects/:projectId/documents diff --git a/backend/src/routes/tabular.ts b/backend/src/routes/tabular.ts index 907c4f5..46bea1c 100644 --- a/backend/src/routes/tabular.ts +++ b/backend/src/routes/tabular.ts @@ -31,6 +31,7 @@ import { filterAccessibleDocumentIds, listAccessibleProjectIds, } from "../lib/access"; +import { safeErrorLog, safeErrorMessage } from "../lib/safeError"; function formatPromptSuffix(format?: string, tags?: string[]): string { switch (format) { @@ -1040,7 +1041,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { } catch (err) { console.error( `[tabular/generate] queryTabularAllColumns error doc=${docId}`, - err, + safeErrorLog(err), ); } @@ -1063,10 +1064,10 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => { write("data: [DONE]\n\n"); } catch (err) { - console.error("[tabular/generate] stream error", err); + console.error("[tabular/generate] stream error", safeErrorLog(err)); try { write( - `data: ${JSON.stringify({ type: "error", message: String(err) })}\n\ndata: [DONE]\n\n`, + `data: ${JSON.stringify({ type: "error", message: safeErrorMessage(err, "Stream error") })}\n\ndata: [DONE]\n\n`, ); } catch { /* ignore */ @@ -1518,9 +1519,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => { } return; } - console.error("[tabular/chat] error", err); - const message = - err instanceof Error && err.message ? err.message : "Stream error"; + console.error("[tabular/chat] error", safeErrorLog(err)); + const message = safeErrorMessage(err, "Stream error"); const errorEvents = err instanceof AssistantStreamError ? stripTransientAssistantEvents(err.events) : [{ type: "error" as const, message }]; @@ -1633,7 +1633,7 @@ The "summary" field must contain only the extracted value with inline citations apiKeys, }); } catch (err) { - console.error("[queryTabularCell] completion failed", err); + console.error("[queryTabularCell] completion failed", safeErrorLog(err)); return null; } try { @@ -1844,7 +1844,7 @@ Rules: }, }); } catch (err) { - console.error("[queryTabularAllColumns] stream failed", err); + console.error("[queryTabularAllColumns] stream failed", safeErrorLog(err)); } if (contentBuffer.trim()) pending.push(processLine(contentBuffer)); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index 1b7657e..369cb39 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import { requireAuth } from "../middleware/auth"; +import { requireAuth, requireMfaIfEnrolled } from "../middleware/auth"; import { createServerSupabase } from "../lib/supabase"; import { DEFAULT_TABULAR_MODEL, @@ -15,6 +15,18 @@ import { normalizeApiKeyProvider, saveUserApiKey, } from "../lib/userApiKeys"; +import { + deleteAllUserChats, + deleteAllUserTabularReviews, + deleteUserAccountData, + deleteUserProjects, +} from "../lib/userDataCleanup"; +import { + buildUserAccountExport, + buildUserChatsExport, + buildUserTabularReviewsExport, + userExportFilename, +} from "../lib/userDataExport"; export const userRouter = Router(); @@ -28,6 +40,7 @@ type UserProfileRow = { tier: string; title_model: string | null; tabular_model: string; + mfa_on_login: boolean | null; }; function errorMessage(error: unknown): string { @@ -48,20 +61,19 @@ function errorMessage(error: unknown): string { } const PROFILE_SELECT = - "display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model"; + "display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model, mfa_on_login"; const LEGACY_PROFILE_SELECT = "display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model"; +const LEGACY_PROFILE_MODEL_SELECT = + "display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model"; -function isMissingProfileModelColumn(error: unknown): boolean { +function isMissingProfileColumn(error: unknown, column: string): boolean { const record = error && typeof error === "object" ? (error as { code?: unknown; message?: unknown }) : {}; const message = typeof record.message === "string" ? record.message : ""; - return ( - record.code === "42703" || - message.includes("title_model") - ); + return record.code === "42703" && message.includes(column); } async function selectProfile( @@ -74,7 +86,30 @@ async function selectProfile( .select(PROFILE_SELECT) .eq("user_id", userId); const result = mode === "single" ? await query.single() : await query.maybeSingle(); - if (!result.error || !isMissingProfileModelColumn(result.error)) { + if (!result.error) { + return result; + } + + const missingMfaOnLogin = isMissingProfileColumn(result.error, "mfa_on_login"); + if (missingMfaOnLogin) { + const modelQuery = db + .from("user_profiles") + .select(LEGACY_PROFILE_MODEL_SELECT) + .eq("user_id", userId); + const modelLegacy = + mode === "single" ? await modelQuery.single() : await modelQuery.maybeSingle(); + if (!modelLegacy.error || !isMissingProfileColumn(modelLegacy.error, "title_model")) { + if (modelLegacy.data && typeof modelLegacy.data === "object") { + const row = modelLegacy.data as Record; + Object.assign(row, { + mfa_on_login: false, + }); + } + return modelLegacy; + } + } + + if (!missingMfaOnLogin && !isMissingProfileColumn(result.error, "title_model")) { return result; } @@ -88,6 +123,7 @@ async function selectProfile( const row = legacy.data as Record; Object.assign(row, { title_model: null, + mfa_on_login: false, }); } return legacy; @@ -114,6 +150,7 @@ function serializeProfile( tier: row.tier || "Free", titleModel: resolveModel(row.title_model, titleFallback), tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL), + mfaOnLogin: row.mfa_on_login === true, ...(apiKeyStatus ? { apiKeyStatus } : {}), }; } @@ -193,6 +230,44 @@ function validateProfilePayload(body: unknown): return { ok: true, update }; } +function readBooleanBodyField( + body: unknown, + field: string, +): { ok: true; value: boolean } | { ok: false; detail: string } { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return { ok: false, detail: "Expected a JSON object" }; + } + + const raw = body as Record; + const invalidField = Object.keys(raw).find((key) => key !== field); + if (invalidField) { + return { ok: false, detail: `Unsupported field: ${invalidField}` }; + } + if (typeof raw[field] !== "boolean") { + return { ok: false, detail: `${field} must be a boolean` }; + } + + return { ok: true, value: raw[field] }; +} + +async function userHasVerifiedTotpFactor( + db: ReturnType, + userId: string, +) { + const { data, error } = await db.auth.admin.getUserById(userId); + if (error) return { ok: false as const, error }; + + const factors = data.user?.factors ?? []; + return { + ok: true as const, + hasVerifiedTotp: factors.some( + (factor) => + factor.factor_type === "totp" && + factor.status === "verified", + ), + }; +} + async function ensureProfileRow( db: ReturnType, userId: string, @@ -299,6 +374,54 @@ userRouter.patch("/profile", requireAuth, async (req, res) => { res.json({ ...data, apiKeyStatus }); }); +// PATCH /user/security/mfa-login +userRouter.patch( + "/security/mfa-login", + requireAuth, + requireMfaIfEnrolled, + async (req, res) => { + const userId = res.locals.userId as string; + const parsed = readBooleanBodyField(req.body, "enabled"); + if (!parsed.ok) + return void res.status(400).json({ detail: parsed.detail }); + + const db = createServerSupabase(); + if (parsed.value) { + const factorCheck = await userHasVerifiedTotpFactor(db, userId); + if (!factorCheck.ok) { + return void res.status(500).json({ + detail: factorCheck.error.message, + }); + } + if (!factorCheck.hasVerifiedTotp) { + return void res.status(400).json({ + detail: + "Set up an authenticator app before requiring verification on login.", + }); + } + } + + const ensureError = await ensureProfileRow(db, userId); + if (ensureError) + return void res.status(500).json({ detail: ensureError.message }); + + const { error: updateError } = await db + .from("user_profiles") + .update({ + mfa_on_login: parsed.value, + updated_at: new Date().toISOString(), + }) + .eq("user_id", userId); + if (updateError) + return void res.status(500).json({ detail: updateError.message }); + + const apiKeyStatus = await getUserApiKeyStatus(userId, db); + const { data, error } = await loadProfile(db, userId, { apiKeyStatus }); + if (error) return void res.status(500).json({ detail: error.message }); + res.json({ ...data, apiKeyStatus }); + }, +); + // GET /user/api-keys userRouter.get("/api-keys", requireAuth, async (_req, res) => { const userId = res.locals.userId as string; @@ -308,7 +431,7 @@ userRouter.get("/api-keys", requireAuth, async (_req, res) => { }); // PUT /user/api-keys/:provider -userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => { +userRouter.put("/api-keys/:provider", requireAuth, requireMfaIfEnrolled, async (req, res) => { const userId = res.locals.userId as string; const provider = normalizeApiKeyProvider(req.params.provider); if (!provider) @@ -338,10 +461,126 @@ userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => { }); // DELETE /user/account -userRouter.delete("/account", requireAuth, async (_req, res) => { +userRouter.delete("/account", requireAuth, requireMfaIfEnrolled, async (_req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + try { + await deleteUserAccountData(db, userId, userEmail); + const { error } = await db.auth.admin.deleteUser(userId); + if (error) return void res.status(500).json({ detail: error.message }); + res.status(204).send(); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/account] delete failed", { userId, error: detail }); + res.status(500).json({ detail }); + } +}); + +// DELETE /user/chats +userRouter.delete("/chats", requireAuth, requireMfaIfEnrolled, async (_req, res) => { const userId = res.locals.userId as string; const db = createServerSupabase(); - const { error } = await db.auth.admin.deleteUser(userId); - if (error) return void res.status(500).json({ detail: error.message }); - res.status(204).send(); + try { + await deleteAllUserChats(db, userId); + res.status(204).send(); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/chats] delete failed", { userId, error: detail }); + res.status(500).json({ detail }); + } +}); + +// DELETE /user/projects +userRouter.delete("/projects", requireAuth, requireMfaIfEnrolled, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + await deleteUserProjects(db, userId); + res.status(204).send(); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/projects] delete failed", { userId, error: detail }); + res.status(500).json({ detail }); + } +}); + +// DELETE /user/tabular-reviews +userRouter.delete("/tabular-reviews", requireAuth, requireMfaIfEnrolled, async (_req, res) => { + const userId = res.locals.userId as string; + const db = createServerSupabase(); + try { + await deleteAllUserTabularReviews(db, userId); + res.status(204).send(); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/tabular-reviews] delete failed", { + userId, + error: detail, + }); + res.status(500).json({ detail }); + } +}); + +// GET /user/export +userRouter.get("/export", requireAuth, requireMfaIfEnrolled, async (_req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + try { + const data = await buildUserAccountExport(db, userId, userEmail); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${userExportFilename("account", userId)}"`, + ); + res.json(data); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/export] failed", { userId, error: detail }); + res.status(500).json({ detail }); + } +}); + +// GET /user/chats/export +userRouter.get("/chats/export", requireAuth, requireMfaIfEnrolled, async (_req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + try { + const data = await buildUserChatsExport(db, userId, userEmail); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${userExportFilename("chats", userId)}"`, + ); + res.json(data); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/chats/export] failed", { userId, error: detail }); + res.status(500).json({ detail }); + } +}); + +// GET /user/tabular-reviews/export +userRouter.get("/tabular-reviews/export", requireAuth, requireMfaIfEnrolled, async (_req, res) => { + const userId = res.locals.userId as string; + const userEmail = res.locals.userEmail as string | undefined; + const db = createServerSupabase(); + try { + const data = await buildUserTabularReviewsExport(db, userId, userEmail); + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${userExportFilename("tabular-reviews", userId)}"`, + ); + res.json(data); + } catch (err) { + const detail = errorMessage(err); + console.error("[user/tabular-reviews/export] failed", { + userId, + error: detail, + }); + res.status(500).json({ detail }); + } }); diff --git a/frontend/src/app/(pages)/account/accountStyles.ts b/frontend/src/app/(pages)/account/accountStyles.ts new file mode 100644 index 0000000..16c43fc --- /dev/null +++ b/frontend/src/app/(pages)/account/accountStyles.ts @@ -0,0 +1,37 @@ +import { cn } from "@/lib/utils"; + +export const accountGlassInputClassName = cn( + "rounded-lg px-3 text-gray-900 placeholder:text-gray-400", + "border border-transparent bg-gray-100 shadow-none", + "focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45", + "disabled:cursor-not-allowed disabled:text-gray-700 disabled:opacity-100 disabled:placeholder:text-gray-600", +); + +export const accountGlassSectionClassName = + "overflow-hidden rounded-xl bg-white"; + +export const accountGlassButtonClassName = cn( + "rounded-lg border border-transparent bg-transparent px-3 text-gray-700 shadow-none transition-colors hover:bg-gray-100 hover:text-gray-950 active:bg-gray-200", + "disabled:cursor-not-allowed disabled:opacity-45 disabled:active:scale-100", +); + +export const accountGlassPrimaryButtonClassName = + "rounded-lg border border-transparent bg-transparent px-3 text-gray-900 shadow-none transition-colors hover:bg-gray-100 hover:text-gray-950 active:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-45"; + +export const accountGlassDangerButtonClassName = + "rounded-lg border border-transparent bg-transparent px-3 text-red-600 shadow-none transition-colors hover:bg-red-50 hover:text-red-700 active:bg-red-100 disabled:cursor-not-allowed disabled:opacity-45"; + +export const accountGlassDangerOutlineButtonClassName = + "rounded-lg border border-transparent bg-transparent px-3 text-red-600 shadow-none transition-colors hover:bg-red-50 hover:text-red-700 active:bg-red-100 disabled:cursor-not-allowed disabled:opacity-45"; + +export const accountGlassIconButtonClassName = + "justify-center rounded-lg bg-transparent px-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-40"; + +export function accountTabButtonClassName(active: boolean) { + return cn( + "flex h-9 w-full items-center rounded-lg px-3 text-left text-sm font-medium whitespace-nowrap transition-colors", + active + ? "bg-gray-100 text-gray-900" + : "text-gray-500 hover:bg-white/55 hover:text-gray-900", + ); +} diff --git a/frontend/src/app/(pages)/account/api-keys/page.tsx b/frontend/src/app/(pages)/account/api-keys/page.tsx index 6ddf147..6e3fd1b 100644 --- a/frontend/src/app/(pages)/account/api-keys/page.tsx +++ b/frontend/src/app/(pages)/account/api-keys/page.tsx @@ -1,10 +1,19 @@ "use client"; import { useEffect, useState } from "react"; -import { Check, Eye, EyeOff, Save, Trash2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { Eye, EyeOff } from "lucide-react"; import { Input } from "@/components/ui/input"; import { useUserProfile } from "@/contexts/UserProfileContext"; +import { + MfaVerificationPopup, + needsMfaVerification, +} from "@/app/components/shared/MfaVerificationPopup"; +import { isMfaRequiredError } from "@/app/lib/mikeApi"; +import { + accountGlassIconButtonClassName, + accountGlassInputClassName, + accountGlassSectionClassName, +} from "../accountStyles"; const MODEL_API_KEY_FIELDS = [ { @@ -52,27 +61,35 @@ export default function ApiKeysPage() { your API keys into the .env file if you are running your own instance of Mike. All API keys are encrypted in storage.

-
- {MODEL_API_KEY_FIELDS.map((field) => ( - - updateApiKey(field.provider, value.trim() || null) - } - onRemove={() => updateApiKey(field.provider, null)} - /> +
+ {MODEL_API_KEY_FIELDS.map((field, index) => ( +
+ + updateApiKey( + field.provider, + value.trim() || null, + ) + } + onRemove={() => updateApiKey(field.provider, null)} + /> + {index < MODEL_API_KEY_FIELDS.length - 1 && ( +
+ )} +
))}
-
+
{OTHER_API_KEY_FIELDS.map((field) => ( (null); useEffect(() => { setValue(""); @@ -126,97 +146,141 @@ function ApiKeyField({ const handleSave = async () => { setIsSaving(true); - const ok = await onSave(value); - setIsSaving(false); - if (ok) { - setValue(""); - setSaved(true); - setTimeout(() => setSaved(false), 2000); - } else { - alert(`Failed to save ${label}.`); + try { + if (await needsMfaVerification()) { + setPendingMfaAction("save"); + return; + } + const ok = await onSave(value); + if (ok) { + setValue(""); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } else { + alert(`Failed to save ${label}.`); + } + } catch (error) { + if (isMfaRequiredError(error)) { + setPendingMfaAction("save"); + } else { + alert(`Failed to save ${label}.`); + } + } finally { + setIsSaving(false); } }; const handleRemove = async () => { setIsSaving(true); - const ok = await onRemove(); - setIsSaving(false); - if (!ok) alert(`Failed to remove ${label}.`); + try { + if (await needsMfaVerification()) { + setPendingMfaAction("remove"); + return; + } + const ok = await onRemove(); + if (!ok) alert(`Failed to remove ${label}.`); + } catch (error) { + if (isMfaRequiredError(error)) { + setPendingMfaAction("remove"); + } else { + alert(`Failed to remove ${label}.`); + } + } finally { + setIsSaving(false); + } + }; + + const handleMfaVerified = async () => { + const action = pendingMfaAction; + setPendingMfaAction(null); + if (action === "save") { + await handleSave(); + } else if (action === "remove") { + await handleRemove(); + } }; return ( -
- - {description && ( -

{description}

- )} -
-
- setValue(e.target.value)} - placeholder={ - isServerConfigured - ? "Server .env key configured" - : hasSavedKey - ? "Saved key hidden" - : placeholder - } - className="bg-gray-50 pr-10 shadow-none disabled:text-gray-700 disabled:placeholder:text-gray-700" - autoComplete="off" - spellCheck={false} - disabled={isServerConfigured} - /> - -
- - {hasSavedKey && !isServerConfigured && ( - + <> +
+ + {description && ( +

{description}

)} +
+
+ setValue(e.target.value)} + placeholder={ + isServerConfigured + ? "Server .env key configured" + : hasSavedKey + ? "Saved key hidden" + : placeholder + } + className={`pr-10 ${accountGlassInputClassName}`} + autoComplete="off" + spellCheck={false} + disabled={isServerConfigured} + /> + {dirty && ( + + )} +
+
+ + {hasSavedKey && !isServerConfigured && ( + + )} +
+
-
+ setPendingMfaAction(null)} + onVerified={() => void handleMfaVerified()} + /> + ); } diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index d475b1f..50baccb 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; +import { accountTabButtonClassName } from "./accountStyles"; interface TabDef { id: string; @@ -13,6 +14,12 @@ interface TabDef { const TABS: TabDef[] = [ { id: "general", label: "General", href: "/account" }, + { + id: "privacy-data", + label: "Privacy & Data", + href: "/account/privacy-data", + }, + { id: "security", label: "Security", href: "/account/security" }, { id: "models", label: "Model Preferences", href: "/account/models" }, { id: "api-keys", label: "API Keys", href: "/account/api-keys" }, ]; @@ -78,11 +85,9 @@ export default function AccountLayout({ onClick={() => router.push(tab.href) } - className={`flex h-9 w-full items-center rounded-lg px-3 text-left text-sm font-medium whitespace-nowrap transition-colors ${ - active - ? "bg-gray-100 text-gray-900" - : "text-gray-500 hover:bg-gray-50 hover:text-gray-900" - }`} + className={accountTabButtonClassName( + active, + )} > {tab.label} diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 55039e3..b7ffc06 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -22,6 +22,10 @@ import { modelGroupToProvider, providerLabel, } from "@/app/lib/modelAvailability"; +import { + accountGlassInputClassName, + accountGlassSectionClassName, +} from "../accountStyles"; type ModelPreferenceField = "titleModel" | "tabularModel"; @@ -75,7 +79,7 @@ export default function ModelPreferencesPage() { Model Preferences
-
+
+
-
+
-
+
- +
+ +
-
- - +
+
+ + + {/* Email */} +
+

+ Email +

+
+
+ { + setEmail(event.target.value); + setEmailStatus(null); + setEmailWarning(null); + setEmailSaved(false); + }} + placeholder="Enter your email" + className={accountGlassInputClassName} + /> + {emailStatus ? ( +

+ {emailStatus} +

+ ) : user.pendingEmail ? ( +

+ Pending confirmation: {user.pendingEmail} +

+ ) : null} + {emailStatus && ( +

+ Current email: {user.email} +

+ )} +
+
@@ -187,7 +316,7 @@ export default function AccountPage() {

Usage Plan

-
+

{profile?.tier || "Free"} @@ -201,16 +330,14 @@ export default function AccountPage() {

Actions

-
- -
+
{/* Danger Zone */} @@ -218,48 +345,76 @@ export default function AccountPage() {

Danger Zone

-
-

- Permanently delete your account and all associated data. - This action cannot be undone. -

- {deleteConfirm ? ( -
-

- Are you sure? This will permanently delete your - account. -

-
- - -
-
- ) : ( - - )} +
+
+

+ Delete account +

+

+ Permanently delete your account and all associated + data. This action cannot be undone. +

+
+
+ { + if (isDeleting) return; + setDeleteConfirm(false); + }} + onConfirm={() => void handleDeleteAccount()} + /> + setEmailWarning(null)} + /> + setAccountDeleteMfaOpen(false)} + onVerified={() => { + devLog("[account/mfa] account delete verification callback"); + setAccountDeleteMfaOpen(false); + void handleDeleteAccount(); + }} + title="Two-factor verification required" + message="Account deletion is sensitive. Enter a code from your authenticator app to continue." + /> + setEmailMfaOpen(false)} + onVerified={() => { + devLog("[account/mfa] email verification callback"); + setEmailMfaOpen(false); + void handleSaveEmail(); + }} + title="Two-factor verification required" + message="Email changes are sensitive. Enter a code from your authenticator app to continue." + />
); } + +function isAlreadyRegisteredEmailError(message: string) { + return message + .toLowerCase() + .includes("a user with this email address has already been registered"); +} diff --git a/frontend/src/app/(pages)/account/privacy-data/page.tsx b/frontend/src/app/(pages)/account/privacy-data/page.tsx new file mode 100644 index 0000000..8779fbb --- /dev/null +++ b/frontend/src/app/(pages)/account/privacy-data/page.tsx @@ -0,0 +1,398 @@ +"use client"; + +import { useState } from "react"; +import { Download, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useChatHistoryContext } from "@/app/contexts/ChatHistoryContext"; +import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup"; +import { + MfaVerificationPopup, + needsMfaVerification, +} from "@/app/components/shared/MfaVerificationPopup"; +import { + deleteAllChats, + deleteAllProjects, + deleteAllTabularReviews, + exportAccountData, + exportChatData, + exportTabularReviewsData, + isMfaRequiredError, +} from "@/app/lib/mikeApi"; +import { + accountGlassDangerOutlineButtonClassName, + accountGlassPrimaryButtonClassName, + accountGlassSectionClassName, +} from "../accountStyles"; + +type DeleteDataAction = "chats" | "tabular-reviews" | "projects"; +type ExportDataAction = "export-chats" | "export-tabular-reviews" | "export-account"; +type MfaRetryAction = DeleteDataAction | ExportDataAction; + +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + if (isDev) console.log(...args); +}; + +const DELETE_DATA_COPY: Record< + DeleteDataAction, + { + title: string; + message: string; + } +> = { + chats: { + title: "Delete all chats?", + message: + "This will permanently delete your assistant and tabular review chat history. This action cannot be undone.", + }, + "tabular-reviews": { + title: "Delete all tabular reviews?", + message: + "This will permanently delete all tabular reviews you own, including their cells and review chats. This action cannot be undone.", + }, + projects: { + title: "Delete all projects?", + message: + "This will permanently delete all projects you own, including their documents, chats, and tabular reviews. This action cannot be undone.", + }, +}; + +export default function PrivacyDataPage() { + const { loadChats, setCurrentChatId } = useChatHistoryContext(); + const [pendingDeleteAction, setPendingDeleteAction] = + useState(null); + const [deletingAction, setDeletingAction] = + useState(null); + const [pendingMfaAction, setPendingMfaAction] = + useState(null); + const [isExportingAccount, setIsExportingAccount] = useState(false); + const [isExportingChats, setIsExportingChats] = useState(false); + const [isExportingTabularReviews, setIsExportingTabularReviews] = + useState(false); + + const downloadBlob = (blob: Blob, filename: string) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + + const handleExportAccountData = async () => { + devLog("[privacy-data/mfa] export account requested"); + setIsExportingAccount(true); + try { + if (await needsMfaVerification()) { + setPendingMfaAction("export-account"); + return; + } + const { blob, filename } = await exportAccountData(); + downloadBlob(blob, filename ?? "mike-account-export.json"); + } catch (error) { + devLog("[privacy-data/mfa] export account failed", { + isMfaRequired: isMfaRequiredError(error), + error, + }); + if (isMfaRequiredError(error)) { + setPendingMfaAction("export-account"); + return; + } + alert("Failed to export account data. Please try again."); + } finally { + setIsExportingAccount(false); + } + }; + + const handleExportChatData = async () => { + devLog("[privacy-data/mfa] export chats requested"); + setIsExportingChats(true); + try { + if (await needsMfaVerification()) { + setPendingMfaAction("export-chats"); + return; + } + const { blob, filename } = await exportChatData(); + downloadBlob(blob, filename ?? "mike-chat-export.json"); + } catch (error) { + devLog("[privacy-data/mfa] export chats failed", { + isMfaRequired: isMfaRequiredError(error), + error, + }); + if (isMfaRequiredError(error)) { + setPendingMfaAction("export-chats"); + return; + } + alert("Failed to export chats. Please try again."); + } finally { + setIsExportingChats(false); + } + }; + + const handleExportTabularReviewsData = async () => { + devLog("[privacy-data/mfa] export tabular reviews requested"); + setIsExportingTabularReviews(true); + try { + if (await needsMfaVerification()) { + setPendingMfaAction("export-tabular-reviews"); + return; + } + const { blob, filename } = await exportTabularReviewsData(); + downloadBlob(blob, filename ?? "mike-tabular-reviews-export.json"); + } catch (error) { + devLog("[privacy-data/mfa] export tabular reviews failed", { + isMfaRequired: isMfaRequiredError(error), + error, + }); + if (isMfaRequiredError(error)) { + setPendingMfaAction("export-tabular-reviews"); + return; + } + alert("Failed to export tabular reviews. Please try again."); + } finally { + setIsExportingTabularReviews(false); + } + }; + + const handleDeleteData = async (action: DeleteDataAction) => { + devLog("[privacy-data/mfa] delete requested", { action }); + setDeletingAction(action); + try { + if (await needsMfaVerification()) { + setPendingDeleteAction(null); + setPendingMfaAction(action); + return; + } + if (action === "chats") { + await deleteAllChats(); + setCurrentChatId(null); + await loadChats(); + } else if (action === "tabular-reviews") { + await deleteAllTabularReviews(); + } else { + await deleteAllProjects(); + setCurrentChatId(null); + await loadChats(); + } + setPendingDeleteAction(null); + } catch (error) { + devLog("[privacy-data/mfa] delete failed", { + action, + isMfaRequired: isMfaRequiredError(error), + error, + }); + if (isMfaRequiredError(error)) { + setPendingDeleteAction(null); + setPendingMfaAction(action); + return; + } + alert("Failed to delete data. Please try again."); + } finally { + setDeletingAction(null); + } + }; + + const handleMfaVerified = async () => { + const action = pendingMfaAction; + devLog("[privacy-data/mfa] verification callback", { action }); + setPendingMfaAction(null); + if (!action) return; + + if (action === "export-account") { + await handleExportAccountData(); + } else if (action === "export-chats") { + await handleExportChatData(); + } else if (action === "export-tabular-reviews") { + await handleExportTabularReviewsData(); + } else { + await handleDeleteData(action); + } + }; + + const pendingDeleteCopy = pendingDeleteAction + ? DELETE_DATA_COPY[pendingDeleteAction] + : null; + + return ( +
+
+

+ Export data +

+
+
+
+

+ Export chats +

+

+ Download assistant and tabular review chat + history as JSON. +

+
+ +
+
+ +
+
+

+ Export tabular reviews +

+

+ Download all owned tabular reviews, cells, and + review chat records as JSON. +

+
+ +
+
+ +
+
+

+ Export account JSON +

+

+ Download account metadata, projects, document + metadata, workflows, and review data as JSON. +

+
+ +
+
+
+ +
+

+ Delete data +

+
+
+
+

+ Delete all chats +

+

+ Permanently delete your assistant and tabular + review chat history. +

+
+ +
+
+ +
+
+

+ Delete all tabular reviews +

+

+ Permanently delete all tabular reviews you own, + including cells and review chats. +

+
+ +
+
+ +
+
+

+ Delete all projects +

+

+ Permanently delete all projects you own, + including documents, chats, and tabular reviews. +

+
+ +
+
+
+ { + if (deletingAction) return; + setPendingDeleteAction(null); + }} + onConfirm={() => { + if (!pendingDeleteAction) return; + void handleDeleteData(pendingDeleteAction); + }} + /> + setPendingMfaAction(null)} + onVerified={() => void handleMfaVerified()} + title="Two-factor verification required" + message="This action is sensitive. Enter a code from your authenticator app to continue." + /> +
+ ); +} diff --git a/frontend/src/app/(pages)/account/security/page.tsx b/frontend/src/app/(pages)/account/security/page.tsx new file mode 100644 index 0000000..1170f63 --- /dev/null +++ b/frontend/src/app/(pages)/account/security/page.tsx @@ -0,0 +1,718 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + type ClipboardEvent, + type KeyboardEvent, +} from "react"; +import { Copy, Loader2 } from "lucide-react"; +import { supabase } from "@/lib/supabase"; +import { Button } from "@/components/ui/button"; +import { useUserProfile } from "@/contexts/UserProfileContext"; +import { isMfaRequiredError } from "@/app/lib/mikeApi"; +import { Modal } from "@/app/components/shared/Modal"; +import { + MfaVerificationPopup, + needsMfaVerification, +} from "@/app/components/shared/MfaVerificationPopup"; +import { + accountGlassPrimaryButtonClassName, + accountGlassSectionClassName, +} from "../accountStyles"; + +type MfaFactor = { + id: string; + friendly_name?: string | null; + factor_type: string; + status?: string; +}; + +type Enrollment = { + factorId: string; + challengeId: string; + qrCode: string; + secret: string; +}; + +const isDev = process.env.NODE_ENV !== "production"; +const traceMfa = (...args: Parameters) => { + if (isDev) console.info(...args); +}; + +function summarizeFactors(factors: MfaFactor[]) { + return factors.map((factor) => ({ + type: factor.factor_type, + status: factor.status ?? "unknown", + friendlyName: factor.friendly_name ?? null, + })); +} + +function isDuplicateFriendlyNameError(error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : ""; + return message + .toLowerCase() + .includes("a factor with the friendly name"); +} + +function VerificationCodeInput({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (value: string) => void; + disabled?: boolean; +}) { + const inputsRef = useRef>([]); + const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? ""); + + function updateDigit(index: number, nextValue: string) { + const digit = nextValue.replace(/\D/g, "").slice(-1); + const nextDigits = [...digits]; + nextDigits[index] = digit; + onChange(nextDigits.join("")); + if (digit && index < inputsRef.current.length - 1) { + inputsRef.current[index + 1]?.focus(); + } + } + + function handlePaste(event: ClipboardEvent) { + event.preventDefault(); + const pasted = event.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, 6); + if (!pasted) return; + onChange(pasted); + inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus(); + } + + function handleKeyDown( + event: KeyboardEvent, + index: number, + ) { + if (event.key === "Backspace" && !digits[index] && index > 0) { + inputsRef.current[index - 1]?.focus(); + } + if (event.key === "ArrowLeft" && index > 0) { + event.preventDefault(); + inputsRef.current[index - 1]?.focus(); + } + if (event.key === "ArrowRight" && index < digits.length - 1) { + event.preventDefault(); + inputsRef.current[index + 1]?.focus(); + } + } + + return ( +
+ {digits.map((digit, index) => ( + { + inputsRef.current[index] = element; + }} + type="text" + inputMode="numeric" + autoComplete={index === 0 ? "one-time-code" : "off"} + value={digit} + disabled={disabled} + onChange={(event) => + updateDigit(index, event.target.value) + } + onPaste={handlePaste} + onKeyDown={(event) => handleKeyDown(event, index)} + className="h-11 w-10 rounded-lg border border-transparent bg-gray-100 text-center text-lg font-medium text-gray-950 shadow-none outline-none transition-colors focus:border-gray-200 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45" + aria-label={`Verification code digit ${index + 1}`} + maxLength={1} + /> + ))} +
+ ); +} + +function MfaSettingsSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +export default function SecurityPage() { + const { profile, updateMfaOnLogin } = useUserProfile(); + const [loading, setLoading] = useState(true); + const [factors, setFactors] = useState([]); + const [currentLevel, setCurrentLevel] = useState(null); + const [nextLevel, setNextLevel] = useState(null); + const [setupModalOpen, setSetupModalOpen] = useState(false); + const [enrollment, setEnrollment] = useState(null); + const [verificationCode, setVerificationCode] = useState(""); + const [setupKeyCopied, setSetupKeyCopied] = useState(false); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [savingLoginPreference, setSavingLoginPreference] = useState(false); + const [pendingUnenrollFactorId, setPendingUnenrollFactorId] = useState< + string | null + >(null); + const [pendingLoginPreference, setPendingLoginPreference] = useState< + boolean | null + >(null); + + async function refreshMfaState() { + setLoading(true); + setStatus(null); + traceMfa("[security/mfa] refreshing state"); + const [factorResult, aalResult] = await Promise.all([ + supabase.auth.mfa.listFactors(), + supabase.auth.mfa.getAuthenticatorAssuranceLevel(), + ]); + + if (factorResult.error) { + traceMfa("[security/mfa] list factors failed", { + error: factorResult.error.message, + }); + setStatus(factorResult.error.message); + setFactors([]); + } else { + const verifiedTotp = (factorResult.data.totp ?? []) as MfaFactor[]; + const allFactors = (factorResult.data.all ?? []) as MfaFactor[]; + traceMfa("[security/mfa] factors loaded", { + allCount: allFactors.length, + verifiedTotpCount: verifiedTotp.length, + all: summarizeFactors(allFactors), + }); + setFactors(verifiedTotp); + } + + if (aalResult.error) { + traceMfa("[security/mfa] assurance lookup failed", { + error: aalResult.error.message, + }); + setStatus(aalResult.error.message); + setCurrentLevel(null); + setNextLevel(null); + } else { + traceMfa("[security/mfa] assurance level", { + currentLevel: aalResult.data.currentLevel, + nextLevel: aalResult.data.nextLevel, + }); + setCurrentLevel(aalResult.data.currentLevel); + setNextLevel(aalResult.data.nextLevel); + } + setLoading(false); + } + + useEffect(() => { + traceMfa("[security/mfa] page mounted"); + void refreshMfaState(); + }, []); + + useEffect(() => { + traceMfa("[security/mfa] rendered state", { + loading, + verifiedFactorCount: factors.length, + currentLevel, + nextLevel, + hasEnrollment: !!enrollment, + }); + }, [currentLevel, enrollment, factors.length, loading, nextLevel]); + + async function startEnrollment() { + setBusy(true); + setStatus(null); + try { + traceMfa("[security/mfa] enrollment requested"); + + let { data, error } = await supabase.auth.mfa.enroll({ + factorType: "totp", + friendlyName: "Mike", + }); + if (error && isDuplicateFriendlyNameError(error)) { + traceMfa("[security/mfa] retrying enrollment with unique name", { + error: error.message, + }); + const retry = await supabase.auth.mfa.enroll({ + factorType: "totp", + friendlyName: `Mike ${Date.now()}`, + }); + data = retry.data; + error = retry.error; + } + if (error) throw error; + if (!data) throw new Error("Failed to start MFA setup."); + traceMfa("[security/mfa] enrollment created", { + factorId: data.id, + }); + + const challenge = await supabase.auth.mfa.challenge({ + factorId: data.id, + }); + if (challenge.error) throw challenge.error; + traceMfa("[security/mfa] enrollment challenge created", { + factorId: data.id, + challengeId: challenge.data.id, + }); + + setEnrollment({ + factorId: data.id, + challengeId: challenge.data.id, + qrCode: data.totp.qr_code, + secret: data.totp.secret, + }); + setVerificationCode(""); + setSetupKeyCopied(false); + } catch (error) { + setStatus( + error instanceof Error + ? error.message + : "Failed to start MFA setup.", + ); + } finally { + setBusy(false); + } + } + + async function closeSetupModal() { + if (busy) return; + setSetupModalOpen(false); + if (enrollment) { + await cancelEnrollment(); + } else { + setVerificationCode(""); + setSetupKeyCopied(false); + } + } + + async function returnToSetupInstructions() { + if (busy || !enrollment) return; + await cancelEnrollment(); + } + + async function verifyEnrollment() { + if (!enrollment || verificationCode.trim().length !== 6) return; + + setBusy(true); + setStatus(null); + try { + traceMfa("[security/mfa] verifying enrollment", { + factorId: enrollment.factorId, + challengeId: enrollment.challengeId, + }); + const { error } = await supabase.auth.mfa.verify({ + factorId: enrollment.factorId, + challengeId: enrollment.challengeId, + code: verificationCode.trim(), + }); + if (error) throw error; + traceMfa("[security/mfa] enrollment verified", { + factorId: enrollment.factorId, + }); + + setEnrollment(null); + setSetupModalOpen(false); + setVerificationCode(""); + setSetupKeyCopied(false); + setStatus("MFA enabled."); + await refreshMfaState(); + } catch (error) { + setStatus( + error instanceof Error + ? error.message + : "Failed to verify MFA code.", + ); + } finally { + setBusy(false); + } + } + + async function cancelEnrollment() { + const factorId = enrollment?.factorId; + setEnrollment(null); + setVerificationCode(""); + setSetupKeyCopied(false); + if (factorId) { + await supabase.auth.mfa.unenroll({ factorId }).catch(() => null); + } + await refreshMfaState(); + } + + async function copySetupKey() { + if (!enrollment?.secret) return; + await navigator.clipboard.writeText(enrollment.secret); + setSetupKeyCopied(true); + window.setTimeout(() => setSetupKeyCopied(false), 1600); + } + + async function requestUnenroll(factorId: string) { + setStatus(null); + const { data, error } = + await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (error) { + setStatus(error.message); + return; + } + + if (data.nextLevel === "aal2" && data.currentLevel !== "aal2") { + setPendingUnenrollFactorId(factorId); + return; + } + + await unenrollFactor(factorId); + } + + async function unenrollFactor(factorId: string) { + setBusy(true); + setStatus(null); + const { error } = await supabase.auth.mfa.unenroll({ factorId }); + setBusy(false); + + if (error) { + if ( + error.message.toLowerCase().includes("aal") || + error.code === "insufficient_aal" + ) { + setPendingUnenrollFactorId(factorId); + return; + } + setStatus(error.message); + return; + } + + setStatus("MFA disabled."); + if (profile?.mfaOnLogin) { + void updateMfaOnLogin(false); + } + await refreshMfaState(); + } + + async function handleLoginPreferenceToggle() { + if (!hasVerifiedFactor || savingLoginPreference) return; + const enabled = !(profile?.mfaOnLogin === true); + setSavingLoginPreference(true); + setStatus(null); + try { + if (await needsMfaVerification()) { + setPendingLoginPreference(enabled); + return; + } + await saveLoginPreference(enabled); + } catch (error) { + setStatus( + error instanceof Error + ? error.message + : "Failed to update login authentication preference.", + ); + } finally { + setSavingLoginPreference(false); + } + } + + async function saveLoginPreference(enabled: boolean) { + setSavingLoginPreference(true); + setStatus(null); + try { + const success = await updateMfaOnLogin(enabled); + if (!success) { + setStatus("Failed to update login authentication preference."); + } + } catch (error) { + if (isMfaRequiredError(error)) { + setPendingLoginPreference(enabled); + } else { + setStatus( + error instanceof Error + ? error.message + : "Failed to update login authentication preference.", + ); + } + } finally { + setSavingLoginPreference(false); + } + } + + const hasVerifiedFactor = factors.length > 0; + const sessionVerified = currentLevel === "aal2"; + const loginMfaEnabled = profile?.mfaOnLogin === true; + + return ( +
+
+

+ Multi-Factor Authentication +

+
+ {loading ? ( + + ) : ( + <> +
+
+
+

+ Verification method +

+ + {hasVerifiedFactor + ? "Enabled" + : "Not set up"} + +
+

+ {hasVerifiedFactor + ? sessionVerified + ? "Authenticator app is saved on your account. Sensitive actions are unlocked for this session." + : "Authenticator app is saved on your account. Sensitive actions require a verification code." + : "Add an authenticator app to protect sensitive actions such as exporting data, deleting data, deleting your account, and changing API keys."} +

+
+ {!hasVerifiedFactor && !enrollment ? ( +
+ +
+ ) : null} +
+ + {hasVerifiedFactor && ( + <> +
+
+
+

+ Login verification +

+

+ Ask for an authenticator code + after each new login, instead of + only before sensitive actions. +

+
+ +
+
+ +
+ + )} + + )} + + {status && ( + <> +
+

+ {status} +

+ + )} +
+
+ void closeSetupModal()} + title="Set up authenticator app" + cancelAction={{ + label: enrollment ? "Back" : "Cancel", + onClick: enrollment + ? () => void returnToSetupInstructions() + : () => void closeSetupModal(), + disabled: busy, + }} + primaryAction={ + enrollment + ? { + label: busy ? "Verifying..." : "Verify", + onClick: () => void verifyEnrollment(), + disabled: + busy || verificationCode.trim().length !== 6, + } + : { + label: busy ? "Starting..." : "Continue", + onClick: () => void startEnrollment(), + disabled: busy, + } + } + > +
+ {!enrollment ? ( + <> +

+ Step 1 +

+
+

+ Before you start +

+

+ Download an authenticator app such as Google + Authenticator, Microsoft Authenticator, + Authy, 1Password, or iCloud Passwords. +

+
+
    +
  1. + Download and open your authenticator app. +
  2. +
  3. + Choose the option to add a new account. +
  4. +
+ + ) : ( + <> +

+ Step 2 +

+
+

+ Scan this code +

+

+ In your authenticator app, add a new account + and scan the QR code. If you cannot scan it, + enter the setup key below manually. +

+
+
+
+

+ Setup key +

+ +
+

+ {enrollment.secret} +

+
+
+
+ MFA QR code +
+
+
+ +
+ + )} +
+
+ setPendingUnenrollFactorId(null)} + onVerified={() => { + const factorId = pendingUnenrollFactorId; + setPendingUnenrollFactorId(null); + if (factorId) void unenrollFactor(factorId); + }} + /> + setPendingLoginPreference(null)} + onVerified={() => { + const enabled = pendingLoginPreference; + setPendingLoginPreference(null); + if (enabled !== null) void saveLoginPreference(enabled); + }} + title="Authenticator required" + message="Enter a code from your authenticator app to change login verification." + /> +
+ ); +} diff --git a/frontend/src/app/components/shared/AppSidebar.tsx b/frontend/src/app/components/shared/AppSidebar.tsx index 60ae958..a093fe0 100644 --- a/frontend/src/app/components/shared/AppSidebar.tsx +++ b/frontend/src/app/components/shared/AppSidebar.tsx @@ -430,7 +430,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) { {isDropdownOpen && (
diff --git a/frontend/src/app/components/shared/MfaLoginGate.tsx b/frontend/src/app/components/shared/MfaLoginGate.tsx new file mode 100644 index 0000000..b905c6c --- /dev/null +++ b/frontend/src/app/components/shared/MfaLoginGate.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUserProfile } from "@/contexts/UserProfileContext"; +import { needsMfaVerification } from "./MfaVerificationPopup"; + +type GateState = "idle" | "checking" | "required" | "verified"; +const MFA_VERIFIED_AT_KEY = "mike:mfa-verified-at"; +const MFA_VERIFIED_GRACE_MS = 60_000; + +export function MfaLoginGate({ children }: { children: ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { user } = useAuth(); + const { profile, loading } = useUserProfile(); + const [gateState, setGateState] = useState("idle"); + const isVerifyPage = pathname === "/verify-mfa"; + + useEffect(() => { + if (!user || loading || !profile?.mfaOnLogin) { + setGateState("idle"); + return; + } + + let cancelled = false; + setGateState("checking"); + + async function checkLoginMfa() { + try { + if (hasRecentMfaVerification()) { + if (!cancelled) setGateState("verified"); + return; + } + const required = await needsMfaVerification(); + if (cancelled) return; + setGateState(required ? "required" : "verified"); + } catch { + if (!cancelled) setGateState("required"); + } + } + + void checkLoginMfa(); + + return () => { + cancelled = true; + }; + }, [loading, profile?.mfaOnLogin, user]); + + useEffect(() => { + if (!user || loading || !profile?.mfaOnLogin) return; + + if (gateState === "required" && !isVerifyPage) { + if (hasRecentMfaVerification()) { + setGateState("verified"); + return; + } + const search = searchParams.toString(); + const next = `${pathname}${search ? `?${search}` : ""}`; + router.replace(`/verify-mfa?next=${encodeURIComponent(next)}`); + } else if (gateState === "verified" && isVerifyPage) { + const next = safeNextPath(searchParams.get("next")); + router.replace(next); + } + }, [ + gateState, + isVerifyPage, + loading, + pathname, + profile?.mfaOnLogin, + router, + searchParams, + user, + ]); + + if (user && loading) return ; + + if (user && profile?.mfaOnLogin) { + if (gateState === "required" && isVerifyPage) { + return <>{children}; + } + if (gateState === "verified" && isVerifyPage) { + return ; + } + if (gateState === "verified") { + return <>{children}; + } + if (gateState === "required" && !isVerifyPage) { + return ; + } + return ; + } + + return <>{children}; +} + +function safeNextPath(value: string | null) { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/assistant"; + } + if (value.startsWith("/verify-mfa")) return "/assistant"; + return value; +} + +function FullScreenGateLoader() { + return ( +
+
+
+ ); +} + +export function markMfaVerifiedForGate() { + window.sessionStorage.setItem(MFA_VERIFIED_AT_KEY, String(Date.now())); +} + +function hasRecentMfaVerification() { + const raw = window.sessionStorage.getItem(MFA_VERIFIED_AT_KEY); + const verifiedAt = raw ? Number.parseInt(raw, 10) : 0; + return ( + Number.isFinite(verifiedAt) && + Date.now() - verifiedAt < MFA_VERIFIED_GRACE_MS + ); +} diff --git a/frontend/src/app/components/shared/MfaVerificationPopup.tsx b/frontend/src/app/components/shared/MfaVerificationPopup.tsx new file mode 100644 index 0000000..2bd3e0e --- /dev/null +++ b/frontend/src/app/components/shared/MfaVerificationPopup.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + type ClipboardEvent, + type KeyboardEvent, +} from "react"; +import { Loader2 } from "lucide-react"; +import { supabase } from "@/lib/supabase"; +import { Modal } from "@/app/components/shared/Modal"; + +type MfaFactor = { + id: string; + friendly_name?: string | null; + factor_type: string; +}; + +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + if (isDev) console.log(...args); +}; + +export async function needsMfaVerification() { + const { data, error } = + await supabase.auth.mfa.getAuthenticatorAssuranceLevel(); + if (error) throw error; + return data.nextLevel === "aal2" && data.currentLevel !== "aal2"; +} + +interface MfaVerificationPopupProps { + open: boolean; + onCancel: () => void; + onVerified: () => void; + title?: string; + message?: string; +} + +export function MfaVerificationPopup({ + open, + onCancel, + onVerified, + title = "Two-factor verification required", + message = "Enter a code from your authenticator app to continue.", +}: MfaVerificationPopupProps) { + const [factors, setFactors] = useState([]); + const [selectedFactorId, setSelectedFactorId] = useState(""); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(null); + const canVerify = + !verifying && + !loading && + !!selectedFactorId && + code.trim().length === 6; + + useEffect(() => { + if (!open) return; + let cancelled = false; + devLog("[mfa-popup] opened"); + + async function loadFactors() { + setLoading(true); + setError(null); + setCode(""); + const { data, error: listError } = + await supabase.auth.mfa.listFactors(); + if (cancelled) return; + if (listError) { + devLog("[mfa-popup] list factors failed", { + error: listError.message, + }); + setError(listError.message); + setFactors([]); + setSelectedFactorId(""); + } else { + const verified = (data.totp ?? []) as MfaFactor[]; + devLog("[mfa-popup] factors loaded", { + totpCount: verified.length, + selectedFactorId: verified[0]?.id ?? null, + }); + setFactors(verified); + setSelectedFactorId(verified[0]?.id ?? ""); + } + setLoading(false); + } + + void loadFactors(); + return () => { + cancelled = true; + }; + }, [open]); + + async function verify() { + if (!canVerify) return; + + setVerifying(true); + setError(null); + devLog("[mfa-popup] verifying code", { factorId: selectedFactorId }); + const { error: verifyError } = + await supabase.auth.mfa.challengeAndVerify({ + factorId: selectedFactorId, + code: code.trim(), + }); + setVerifying(false); + + if (verifyError) { + devLog("[mfa-popup] verification failed", { + error: verifyError.message, + }); + setError(verifyError.message); + return; + } + + devLog("[mfa-popup] verification succeeded"); + setCode(""); + onVerified(); + } + + if (!open) return null; + + return ( + + + Verifying... + + ) : ( + "Verify" + ), + onClick: () => void verify(), + disabled: !canVerify, + }} + > +
+

{message}

+ {loading ? ( +
+ + Loading authenticator... +
+ ) : factors.length === 0 ? ( +

+ No verified authenticator factor is available for this + session. +

+ ) : ( +
+ {factors.length > 1 && ( + + )} + void verify()} + canSubmit={canVerify} + /> +
+ )} + {error &&

{error}

} +
+
+ ); +} + +export function VerificationCodeInput({ + value, + onChange, + disabled, + autoFocus, + onSubmit, + canSubmit, +}: { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + autoFocus?: boolean; + onSubmit?: () => void; + canSubmit?: boolean; +}) { + const inputsRef = useRef>([]); + const digits = Array.from({ length: 6 }, (_, index) => value[index] ?? ""); + + useEffect(() => { + if (!autoFocus || disabled) return; + const focusTimer = window.setTimeout(() => { + const firstEmptyIndex = digits.findIndex((digit) => !digit); + inputsRef.current[ + firstEmptyIndex === -1 ? 0 : firstEmptyIndex + ]?.focus(); + }, 0); + return () => window.clearTimeout(focusTimer); + }, [autoFocus, disabled]); + + function updateDigit(index: number, nextValue: string) { + const digit = nextValue.replace(/\D/g, "").slice(-1); + const nextDigits = [...digits]; + nextDigits[index] = digit; + onChange(nextDigits.join("")); + if (digit && index < inputsRef.current.length - 1) { + inputsRef.current[index + 1]?.focus(); + } + } + + function handlePaste(event: ClipboardEvent) { + event.preventDefault(); + const pasted = event.clipboardData + .getData("text") + .replace(/\D/g, "") + .slice(0, 6); + if (!pasted) return; + onChange(pasted); + inputsRef.current[Math.min(pasted.length, 6) - 1]?.focus(); + } + + function handleKeyDown( + event: KeyboardEvent, + index: number, + ) { + if (event.key === "Enter") { + event.preventDefault(); + if (canSubmit) onSubmit?.(); + return; + } + if (event.key === "Backspace" && !digits[index] && index > 0) { + inputsRef.current[index - 1]?.focus(); + } + if (event.key === "ArrowLeft" && index > 0) { + event.preventDefault(); + inputsRef.current[index - 1]?.focus(); + } + if (event.key === "ArrowRight" && index < digits.length - 1) { + event.preventDefault(); + inputsRef.current[index + 1]?.focus(); + } + } + + return ( +
+ {digits.map((digit, index) => ( + { + inputsRef.current[index] = element; + }} + type="text" + inputMode="numeric" + autoComplete={index === 0 ? "one-time-code" : "off"} + value={digit} + disabled={disabled} + onChange={(event) => updateDigit(index, event.target.value)} + onPaste={handlePaste} + onKeyDown={(event) => handleKeyDown(event, index)} + className="h-13 w-12 rounded-lg border border-gray-300 bg-gray-50 text-center text-2xl font-medium font-serif text-gray-950 shadow-none outline-none transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-300/45 disabled:cursor-not-allowed disabled:opacity-45" + aria-label={`Verification code digit ${index + 1}`} + maxLength={1} + /> + ))} +
+ ); +} diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 88233a6..a70b371 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -36,6 +36,30 @@ interface ServerChatDetailOut { const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3001"; +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + if (isDev) console.log(...args); +}; + +export class MikeApiError extends Error { + status: number; + code: string | null; + + constructor(args: { message: string; status: number; code?: string | null }) { + super(args.message); + this.name = "MikeApiError"; + this.status = args.status; + this.code = args.code ?? null; + } +} + +export function isMfaRequiredError(error: unknown) { + return ( + error instanceof MikeApiError && + error.status === 403 && + error.code === "mfa_verification_required" + ); +} async function getAuthHeader(): Promise> { const { @@ -59,8 +83,7 @@ async function apiRequest(path: string, init?: RequestInit): Promise { }); if (!response.ok) { - const detail = await response.text(); - throw new Error(detail || `API error: ${response.status}`); + throw await toApiError(response, path); } if ( @@ -73,6 +96,65 @@ async function apiRequest(path: string, init?: RequestInit): Promise { return (await response.json()) as T; } +async function apiBlobRequest(path: string): Promise<{ + blob: Blob; + filename: string | null; +}> { + const authHeaders = await getAuthHeader(); + const response = await fetch(`${API_BASE}${path}`, { + cache: "no-store", + headers: { + Accept: "application/json", + ...authHeaders, + }, + }); + + if (!response.ok) { + throw await toApiError(response, path); + } + + const disposition = response.headers.get("content-disposition") ?? ""; + const filenameMatch = disposition.match(/filename="?([^";]+)"?/i); + return { + blob: await response.blob(), + filename: filenameMatch?.[1] ?? null, + }; +} + +async function toApiError(response: Response, path: string) { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { + detail?: unknown; + code?: unknown; + }; + devLog("[mike-api] non-ok response", { + path, + status: response.status, + code: parsed.code, + detail: parsed.detail, + }); + return new MikeApiError({ + status: response.status, + code: typeof parsed.code === "string" ? parsed.code : null, + message: + typeof parsed.detail === "string" && parsed.detail + ? parsed.detail + : `API error: ${response.status}`, + }); + } catch { + devLog("[mike-api] non-ok non-json response", { + path, + status: response.status, + bodyPreview: text.slice(0, 200), + }); + return new MikeApiError({ + status: response.status, + message: text || `API error: ${response.status}`, + }); + } +} + // --------------------------------------------------------------------------- // Projects // --------------------------------------------------------------------------- @@ -97,6 +179,39 @@ export async function deleteAccount(): Promise { return apiRequest("/user/account", { method: "DELETE" }); } +export async function deleteAllChats(): Promise { + return apiRequest("/user/chats", { method: "DELETE" }); +} + +export async function deleteAllProjects(): Promise { + return apiRequest("/user/projects", { method: "DELETE" }); +} + +export async function deleteAllTabularReviews(): Promise { + return apiRequest("/user/tabular-reviews", { method: "DELETE" }); +} + +export async function exportAccountData(): Promise<{ + blob: Blob; + filename: string | null; +}> { + return apiBlobRequest("/user/export"); +} + +export async function exportChatData(): Promise<{ + blob: Blob; + filename: string | null; +}> { + return apiBlobRequest("/user/chats/export"); +} + +export async function exportTabularReviewsData(): Promise<{ + blob: Blob; + filename: string | null; +}> { + return apiBlobRequest("/user/tabular-reviews/export"); +} + export interface UserProfile { displayName: string | null; organisation: string | null; @@ -106,6 +221,7 @@ export interface UserProfile { tier: string; titleModel: string; tabularModel: string; + mfaOnLogin: boolean; apiKeyStatus: ApiKeyStatus; } @@ -126,6 +242,16 @@ export async function updateUserProfile(payload: { }); } +export async function updateUserMfaOnLogin( + enabled: boolean, +): Promise { + return apiRequest("/user/security/mfa-login", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); +} + export type ApiKeyProvider = | "claude" | "gemini" diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index 52ee1f6..9c5b70f 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -8,6 +8,12 @@ import { Input } from "@/components/ui/input"; import Link from "next/link"; import { SiteLogo } from "@/components/site-logo"; import { useAuth } from "@/contexts/AuthContext"; + +const authGlassCardClassName = + "rounded-2xl border border-white/70 bg-white/72 p-8 shadow-[0_4px_14px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-8px_18px_rgba(255,255,255,0.12)] backdrop-blur-2xl"; +const authInputClassName = + "rounded-lg border border-transparent bg-gray-100 px-3 shadow-none focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45"; + export default function LoginPage() { const router = useRouter(); const { isAuthenticated, authLoading } = useAuth(); @@ -44,19 +50,19 @@ export default function LoginPage() { }; return ( -
+
- +
{/* Login Form */} -
+

Log In

-
- +
+ Log in setEmail(e.target.value)} placeholder="Enter your email" required - className="w-full" + className={`w-full ${authInputClassName}`} />
@@ -100,7 +106,7 @@ export default function LoginPage() { onChange={(e) => setPassword(e.target.value)} placeholder="Enter your password" required - className="w-full" + className={`w-full ${authInputClassName}`} />
diff --git a/frontend/src/app/signup/page.tsx b/frontend/src/app/signup/page.tsx index 29774da..f326d06 100644 --- a/frontend/src/app/signup/page.tsx +++ b/frontend/src/app/signup/page.tsx @@ -11,6 +11,11 @@ import { CheckCircle2 } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; import { updateUserProfile } from "@/app/lib/mikeApi"; +const authGlassCardClassName = + "rounded-2xl border border-white/70 bg-white/72 p-8 shadow-[0_4px_14px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-8px_18px_rgba(255,255,255,0.12)] backdrop-blur-2xl"; +const authInputClassName = + "rounded-lg border border-transparent bg-gray-100 px-3 shadow-none focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45"; + export default function SignupPage() { const router = useRouter(); const { isAuthenticated, authLoading } = useAuth(); @@ -91,12 +96,14 @@ export default function SignupPage() { // Success View if (success) { return ( -
+
- +
-
+
@@ -114,24 +121,24 @@ export default function SignupPage() { // Default Signup Form View return ( -
+
- +
-
+

Create Account

-
+
Log in - + Sign up
@@ -154,7 +161,7 @@ export default function SignupPage() { value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" - className="w-full" + className={`w-full ${authInputClassName}`} />
@@ -176,7 +183,7 @@ export default function SignupPage() { setOrganisation(e.target.value) } placeholder="Your organisation" - className="w-full" + className={`w-full ${authInputClassName}`} />
@@ -194,7 +201,7 @@ export default function SignupPage() { onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email" required - className="w-full" + className={`w-full ${authInputClassName}`} />
@@ -212,7 +219,7 @@ export default function SignupPage() { onChange={(e) => setPassword(e.target.value)} placeholder="Create a password (min. 6 characters)" required - className="w-full" + className={`w-full ${authInputClassName}`} />
@@ -232,7 +239,7 @@ export default function SignupPage() { } placeholder="Confirm your password" required - className="w-full" + className={`w-full ${authInputClassName}`} />
diff --git a/frontend/src/app/verify-mfa/page.tsx b/frontend/src/app/verify-mfa/page.tsx new file mode 100644 index 0000000..5fdb600 --- /dev/null +++ b/frontend/src/app/verify-mfa/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { SiteLogo } from "@/components/site-logo"; +import { Button } from "@/components/ui/button"; +import { useAuth } from "@/contexts/AuthContext"; +import { supabase } from "@/lib/supabase"; +import { + needsMfaVerification, + VerificationCodeInput, +} from "@/app/components/shared/MfaVerificationPopup"; +import { markMfaVerifiedForGate } from "@/app/components/shared/MfaLoginGate"; + +type MfaFactor = { + id: string; + friendly_name?: string | null; + factor_type: string; +}; + +const authGlassCardClassName = + "rounded-2xl border border-white/70 bg-white/72 px-8 py-8 shadow-[0_4px_14px_rgba(15,23,42,0.045),inset_0_1px_0_rgba(255,255,255,0.86),inset_0_-8px_18px_rgba(255,255,255,0.12)] backdrop-blur-2xl"; + +export default function VerifyMfaPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { user, authLoading, signOut } = useAuth(); + const [factors, setFactors] = useState([]); + const [selectedFactorId, setSelectedFactorId] = useState(""); + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(true); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(null); + + const nextPath = safeNextPath(searchParams.get("next")); + const canVerify = + !loading && !verifying && !!selectedFactorId && code.trim().length === 6; + + useEffect(() => { + if (authLoading) return; + if (!user) { + router.replace("/login"); + return; + } + + let cancelled = false; + + async function loadMfaState() { + setLoading(true); + setError(null); + setCode(""); + try { + const required = await needsMfaVerification(); + if (cancelled) return; + if (!required) { + router.replace(nextPath); + return; + } + + const { data, error: factorError } = + await supabase.auth.mfa.listFactors(); + if (cancelled) return; + if (factorError) throw factorError; + + const verified = (data.totp ?? []) as MfaFactor[]; + setFactors(verified); + setSelectedFactorId(verified[0]?.id ?? ""); + if (verified.length === 0) { + setError( + "No verified authenticator factor is available for this account.", + ); + } + } catch (loadError) { + if (cancelled) return; + setError( + loadError instanceof Error + ? loadError.message + : "Unable to load authenticator verification.", + ); + } finally { + if (!cancelled) setLoading(false); + } + } + + void loadMfaState(); + + return () => { + cancelled = true; + }; + }, [authLoading, nextPath, router, user]); + + async function verify() { + if (!canVerify) return; + + setVerifying(true); + setError(null); + const { error: verifyError } = + await supabase.auth.mfa.challengeAndVerify({ + factorId: selectedFactorId, + code: code.trim(), + }); + setVerifying(false); + + if (verifyError) { + setError(verifyError.message); + return; + } + + setCode(""); + markMfaVerifiedForGate(); + router.replace(nextPath); + } + + async function cancel() { + await signOut(); + router.replace("/login"); + } + + return ( +
+
+ +
+
+
+

+ Verify your identity +

+

+ Enter the six-digit code from your authenticator app to + continue. +

+
+ +
+ {loading ? ( +
+ + Loading authenticator... +
+ ) : factors.length === 0 ? ( +

+ No verified authenticator factor is available for + this session. +

+ ) : ( + <> + {factors.length > 1 && ( + + )} + void verify()} + /> + + )} + + {error &&

{error}

} + +
+ + +
+
+
+
+ ); +} + +function safeNextPath(value: string | null) { + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return "/assistant"; + } + if (value.startsWith("/verify-mfa")) return "/assistant"; + return value; +} diff --git a/frontend/src/components/providers.tsx b/frontend/src/components/providers.tsx index 64fe36b..8d00463 100644 --- a/frontend/src/components/providers.tsx +++ b/frontend/src/components/providers.tsx @@ -1,14 +1,26 @@ "use client"; +import { Suspense } from "react"; import { AuthProvider } from "@/contexts/AuthContext"; import { UserProfileProvider } from "@/contexts/UserProfileContext"; +import { MfaLoginGate } from "@/app/components/shared/MfaLoginGate"; export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + }> + {children} + ); } + +function ProviderLoader() { + return ( +
+
+
+ ); +} diff --git a/frontend/src/components/site-logo.tsx b/frontend/src/components/site-logo.tsx index 7ff3d93..ce4ecdd 100644 --- a/frontend/src/components/site-logo.tsx +++ b/frontend/src/components/site-logo.tsx @@ -4,6 +4,7 @@ import { MikeIcon } from "@/components/chat/mike-icon"; interface SiteLogoProps { size?: "sm" | "md" | "lg" | "xl"; className?: string; + iconClassName?: string; animate?: boolean; asLink?: boolean; } @@ -11,6 +12,7 @@ interface SiteLogoProps { export function SiteLogo({ size = "md", className = "", + iconClassName = "", animate = false, asLink = false, }: SiteLogoProps) { @@ -28,7 +30,7 @@ export function SiteLogo({ const iconSizes = { sm: 20, md: 22, - lg: 32, + lg: 30, xl: 48, }; @@ -38,7 +40,11 @@ export function SiteLogo({ animate ? "sidebar-fade-in" : "" } ${className}`} > - + + + Mike ); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 8038941..c1ce65e 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -7,11 +7,13 @@ import React, { useState, ReactNode, } from "react"; +import type { User as SupabaseUser } from "@supabase/supabase-js"; import { supabase } from "@/lib/supabase"; interface User { id: string; email: string; + pendingEmail?: string | null; } interface AuthContextType { @@ -19,10 +21,19 @@ interface AuthContextType { isAuthenticated: boolean; authLoading: boolean; signOut: () => Promise; + updateEmail: (email: string) => Promise; } const AuthContext = createContext(undefined); +function toUser(user: SupabaseUser): User { + return { + id: user.id, + email: user.email || "", + pendingEmail: user.new_email ?? null, + }; +} + export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [authLoading, setAuthLoading] = useState(true); @@ -34,10 +45,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } = await supabase.auth.getSession(); if (session?.user) { - setUser({ - id: session.user.id, - email: session.user.email || "", - }); + setUser(toUser(session.user)); } setAuthLoading(false); }; @@ -48,10 +56,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { data: { subscription }, } = supabase.auth.onAuthStateChange(async (_event, session) => { if (session?.user) { - setUser({ - id: session.user.id, - email: session.user.email || "", - }); + setUser(toUser(session.user)); } else { setUser(null); } @@ -64,10 +69,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const signOut = async () => { - await supabase.auth.signOut(); + await supabase.auth.signOut({ scope: "local" }); setUser(null); }; + const updateEmail = async (email: string) => { + const redirectTo = + typeof window === "undefined" + ? undefined + : `${window.location.origin}/account`; + const { data, error } = await supabase.auth.updateUser( + { email }, + redirectTo ? { emailRedirectTo: redirectTo } : undefined, + ); + + if (error) throw error; + if (!data.user) throw new Error("Unable to update email"); + + const nextUser = toUser(data.user); + setUser(nextUser); + return nextUser; + }; + return ( {children} diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index 0b4e8dd..2fdc072 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -14,7 +14,9 @@ import { type ApiKeyProvider, type UserProfile as ApiUserProfile, getUserProfile, + isMfaRequiredError, saveApiKey, + updateUserMfaOnLogin, updateUserProfile, } from "@/app/lib/mikeApi"; @@ -27,6 +29,7 @@ interface UserProfile { tier: string; titleModel: string; tabularModel: string; + mfaOnLogin: boolean; apiKeys: ApiKeyState; } @@ -39,6 +42,7 @@ interface UserProfileContextType { field: "titleModel" | "tabularModel", value: string, ) => Promise; + updateMfaOnLogin: (enabled: boolean) => Promise; updateApiKey: ( provider: ApiKeyProvider, value: string | null, @@ -83,6 +87,7 @@ function toProfile(data: ApiUserProfile): UserProfile { return { ...profile, + mfaOnLogin: profile.mfaOnLogin === true, apiKeys, }; } @@ -111,6 +116,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { tier: "Free", titleModel: "gemini-3.1-flash-lite-preview", tabularModel: "gemini-3-flash-preview", + mfaOnLogin: false, apiKeys: emptyApiKeys(), }); } finally { @@ -156,7 +162,8 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { prev ? { ...prev, ...toProfile(updated) } : null, ); return true; - } catch { + } catch (error) { + if (isMfaRequiredError(error)) throw error; return false; } }, @@ -184,6 +191,23 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { [user], ); + const updateMfaOnLogin = useCallback( + async (enabled: boolean): Promise => { + if (!user) return false; + try { + const updated = await updateUserMfaOnLogin(enabled); + setProfile((prev) => + prev ? { ...prev, ...toProfile(updated) } : null, + ); + return true; + } catch (error) { + if (isMfaRequiredError(error)) throw error; + return false; + } + }, + [user], + ); + const updateApiKey = useCallback( async ( provider: ApiKeyProvider, @@ -208,7 +232,8 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { : null, ); return true; - } catch { + } catch (error) { + if (isMfaRequiredError(error)) throw error; return false; } }, @@ -242,6 +267,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) { updateDisplayName, updateOrganisation, updateModelPreference, + updateMfaOnLogin, updateApiKey, reloadProfile, incrementMessageCredits,