From 3a109432000f97b636ddbcd8c2f267461cde030c Mon Sep 17 00:00:00 2001
From: willchen96
Date: Wed, 10 Jun 2026 03:48:08 +0800
Subject: [PATCH] feat: implement multi-factor authentication (MFA) setup and
verification flow
- Add SecurityPage component for managing MFA settings, including enrollment and verification.
- Create MfaLoginGate to handle MFA verification state during login.
- Develop MfaVerificationPopup for user input of verification codes.
- Implement VerifyMfaPage for the MFA verification process after login.
- Introduce reusable VerificationCodeInput component for entering verification codes.
- Integrate Supabase MFA API for managing factors and verification.
- Add loading states and error handling for a better user experience.
---
.../20260606_oss_schema_diff.sql | 1 +
backend/schema.sql | 1 +
backend/src/index.ts | 19 +
backend/src/lib/chatTools.ts | 4 +-
backend/src/lib/convert.ts | 71 +-
backend/src/lib/safeError.ts | 59 ++
backend/src/lib/userDataCleanup.ts | 339 +++++++++
backend/src/lib/userDataExport.ts | 278 +++++++
backend/src/middleware/auth.ts | 167 +++-
backend/src/routes/chat.ts | 8 +-
backend/src/routes/projectChat.ts | 6 +-
backend/src/routes/projects.ts | 17 +-
backend/src/routes/tabular.ts | 16 +-
backend/src/routes/user.ts | 265 ++++++-
.../src/app/(pages)/account/accountStyles.ts | 37 +
.../src/app/(pages)/account/api-keys/page.tsx | 268 ++++---
frontend/src/app/(pages)/account/layout.tsx | 15 +-
.../src/app/(pages)/account/models/page.tsx | 9 +-
frontend/src/app/(pages)/account/page.tsx | 399 +++++++---
.../app/(pages)/account/privacy-data/page.tsx | 398 ++++++++++
.../src/app/(pages)/account/security/page.tsx | 718 ++++++++++++++++++
.../src/app/components/shared/AppSidebar.tsx | 3 +-
.../app/components/shared/MfaLoginGate.tsx | 126 +++
.../shared/MfaVerificationPopup.tsx | 294 +++++++
frontend/src/app/lib/mikeApi.ts | 130 +++-
frontend/src/app/login/page.tsx | 20 +-
frontend/src/app/signup/page.tsx | 33 +-
frontend/src/app/verify-mfa/page.tsx | 218 ++++++
frontend/src/components/providers.tsx | 14 +-
frontend/src/components/site-logo.tsx | 10 +-
frontend/src/contexts/AuthContext.tsx | 42 +-
frontend/src/contexts/UserProfileContext.tsx | 30 +-
32 files changed, 3704 insertions(+), 311 deletions(-)
create mode 100644 backend/src/lib/safeError.ts
create mode 100644 backend/src/lib/userDataCleanup.ts
create mode 100644 backend/src/lib/userDataExport.ts
create mode 100644 frontend/src/app/(pages)/account/accountStyles.ts
create mode 100644 frontend/src/app/(pages)/account/privacy-data/page.tsx
create mode 100644 frontend/src/app/(pages)/account/security/page.tsx
create mode 100644 frontend/src/app/components/shared/MfaLoginGate.tsx
create mode 100644 frontend/src/app/components/shared/MfaVerificationPopup.tsx
create mode 100644 frontend/src/app/verify-mfa/page.tsx
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
-
+
+