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.
This commit is contained in:
willchen96 2026-06-10 03:48:08 +08:00
parent 15c96b0dd4
commit 3a10943200
32 changed files with 3704 additions and 311 deletions

View file

@ -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;
-- ---------------------------------------------------------------------------

View file

@ -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()
);

View file

@ -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),

View file

@ -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);
}

View file

@ -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<Buffer>)
| 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<string>();
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<Buffer> | void;
_convert = (buf, ext, filter) =>
new Promise<Buffer>((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<Buffer> {
* Throws if LibreOffice is not installed or conversion fails.
*/
export async function docxToPdf(buffer: Buffer): Promise<Buffer> {
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);

View file

@ -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),
};
}

View file

@ -0,0 +1,339 @@
import { createServerSupabase } from "./supabase";
import { deleteFile, listFiles } from "./storage";
type Db = ReturnType<typeof createServerSupabase>;
const DELETE_BATCH_SIZE = 500;
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => !!value))];
}
function chunks<T>(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<T extends { message?: string } | null>(
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<string[]> {
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<string[]> {
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<string>();
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");
}
}

View file

@ -0,0 +1,278 @@
import { createServerSupabase } from "./supabase";
type Db = ReturnType<typeof createServerSupabase>;
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 | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => !!value))];
}
async function throwIfError<T extends { message?: string } | null>(
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<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
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<string, unknown>[];
rows.push(...batch);
if (batch.length < PAGE_SIZE) break;
}
return rows;
}
async function selectByIds(
db: Db,
table: string,
column: string,
ids: string[],
): Promise<Record<string, unknown>[]> {
if (ids.length === 0) return [];
return selectAll(db, table, (query) => query.in(column, ids));
}
function idsFrom(rows: Record<string, unknown>[], 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,
},
};
}

View file

@ -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<typeof console.log>) => {
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<any, "public", any>,
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<void> {
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();
}

View file

@ -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 }];

View file

@ -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 }];

View file

@ -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

View file

@ -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));

View file

@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<typeof createServerSupabase>,
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<typeof createServerSupabase>,
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 });
}
});

View file

@ -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",
);
}

View file

@ -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.
</p>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
{MODEL_API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
label={field.label}
placeholder={field.placeholder}
hasSavedKey={
!!profile?.apiKeys[field.provider].configured
}
isServerConfigured={
profile?.apiKeys[field.provider].source === "env"
}
onSave={(value) =>
updateApiKey(field.provider, value.trim() || null)
}
onRemove={() => updateApiKey(field.provider, null)}
/>
<div className={accountGlassSectionClassName}>
{MODEL_API_KEY_FIELDS.map((field, index) => (
<div key={field.provider}>
<ApiKeyField
label={field.label}
placeholder={field.placeholder}
hasSavedKey={
!!profile?.apiKeys[field.provider].configured
}
isServerConfigured={
profile?.apiKeys[field.provider].source ===
"env"
}
onSave={(value) =>
updateApiKey(
field.provider,
value.trim() || null,
)
}
onRemove={() => updateApiKey(field.provider, null)}
/>
{index < MODEL_API_KEY_FIELDS.length - 1 && (
<div className="mx-4 h-px bg-gray-200" />
)}
</div>
))}
</div>
<div className="mt-8 overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
<div className={`mt-8 ${accountGlassSectionClassName}`}>
{OTHER_API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
@ -117,6 +134,9 @@ function ApiKeyField({
const [reveal, setReveal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [pendingMfaAction, setPendingMfaAction] = useState<
"save" | "remove" | null
>(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 (
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
{label}
</label>
{description && (
<p className="text-sm text-gray-500 mb-3">{description}</p>
)}
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={reveal ? "text" : "password"}
value={value}
onChange={(e) => 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}
/>
<button
type="button"
onClick={() => setReveal((r) => !r)}
disabled={isServerConfigured}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={reveal ? "Hide key" : "Show key"}
>
{reveal ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<Button
onClick={handleSave}
variant="outline"
disabled={isServerConfigured || isSaving || !dirty || saved}
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
>
{isSaving ? (
"Saving..."
) : saved ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save
</>
)}
</Button>
{hasSavedKey && !isServerConfigured && (
<Button
type="button"
variant="outline"
onClick={handleRemove}
disabled={isSaving}
className="h-9 gap-1.5 bg-white px-2.5 text-xs text-red-600 shadow-none hover:bg-red-50 hover:text-red-700"
>
<Trash2 className="h-3.5 w-3.5" />
Remove
</Button>
<>
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
{label}
</label>
{description && (
<p className="text-sm text-gray-500 mb-3">{description}</p>
)}
<div className="space-y-2">
<div className="relative flex-1">
<Input
type={reveal ? "text" : "password"}
value={value}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setReveal((r) => !r)}
disabled={isServerConfigured}
className={`absolute inset-y-1 right-1.5 flex items-center ${accountGlassIconButtonClassName}`}
aria-label={reveal ? "Hide key" : "Show key"}
>
{reveal ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
)}
</div>
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={handleSave}
disabled={
isServerConfigured ||
isSaving ||
!dirty ||
saved
}
className="text-xs font-medium text-gray-700 transition-colors hover:text-gray-950 disabled:cursor-not-allowed disabled:text-gray-400"
>
{isSaving ? (
"Saving..."
) : saved ? (
"Saved"
) : (
"Save"
)}
</button>
{hasSavedKey && !isServerConfigured && (
<button
type="button"
onClick={handleRemove}
disabled={isSaving}
className="text-xs font-medium text-red-600 transition-colors hover:text-red-700 disabled:cursor-not-allowed disabled:text-red-300"
>
Remove
</button>
)}
</div>
</div>
</div>
</div>
<MfaVerificationPopup
open={!!pendingMfaAction}
onCancel={() => setPendingMfaAction(null)}
onVerified={() => void handleMfaVerified()}
/>
</>
);
}

View file

@ -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}
</button>

View file

@ -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
</h2>
</div>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white divide-y divide-gray-200">
<div className={accountGlassSectionClassName}>
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
Title generation model
@ -96,6 +100,7 @@ export default function ModelPreferencesPage() {
onChange={(id) => handleModelChange("titleModel", id)}
/>
</div>
<div className="mx-4 h-px bg-gray-200" />
<div className="px-4 py-5">
<label className="text-sm font-medium text-gray-700 block mb-2">
Tabular review model
@ -152,7 +157,7 @@ function ModelPreferenceDropdown({
<button
type="button"
disabled={isSaving}
className="w-full h-9 rounded-md border border-gray-300 bg-gray-50 px-3 text-sm flex items-center justify-between gap-2 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-black/10"
className={`flex h-9 w-full items-center justify-between gap-2 px-3 text-sm hover:bg-white/78 ${accountGlassInputClassName}`}
>
<span className="flex items-center gap-2 min-w-0">
{!selectedAvailable && (

View file

@ -2,16 +2,33 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { LogOut, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LogOut, Check, Save } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useUserProfile } from "@/contexts/UserProfileContext";
import { deleteAccount } from "@/app/lib/mikeApi";
import { ConfirmPopup } from "@/app/components/shared/ConfirmPopup";
import {
MfaVerificationPopup,
needsMfaVerification,
} from "@/app/components/shared/MfaVerificationPopup";
import { WarningPopup } from "@/app/components/shared/WarningPopup";
import { deleteAccount, isMfaRequiredError } from "@/app/lib/mikeApi";
import {
accountGlassDangerOutlineButtonClassName,
accountGlassInputClassName,
accountGlassPrimaryButtonClassName,
accountGlassSectionClassName,
} from "./accountStyles";
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
export default function AccountPage() {
const router = useRouter();
const { user, signOut } = useAuth();
const { user, signOut, updateEmail } = useAuth();
const { profile, updateDisplayName, updateOrganisation } = useUserProfile();
const [displayName, setDisplayName] = useState("");
const [isSavingName, setIsSavingName] = useState(false);
@ -19,8 +36,15 @@ export default function AccountPage() {
const [organisation, setOrganisation] = useState("");
const [isSavingOrg, setIsSavingOrg] = useState(false);
const [orgSaved, setOrgSaved] = useState(false);
const [email, setEmail] = useState("");
const [isSavingEmail, setIsSavingEmail] = useState(false);
const [emailSaved, setEmailSaved] = useState(false);
const [emailStatus, setEmailStatus] = useState<string | null>(null);
const [emailWarning, setEmailWarning] = useState<string | null>(null);
const [emailMfaOpen, setEmailMfaOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [accountDeleteMfaOpen, setAccountDeleteMfaOpen] = useState(false);
useEffect(() => {
if (profile?.displayName) {
@ -31,24 +55,89 @@ export default function AccountPage() {
}
}, [profile]);
useEffect(() => {
if (user?.email) {
setEmail(user.pendingEmail || user.email);
}
}, [user?.email, user?.pendingEmail]);
const handleLogout = async () => {
await signOut();
router.push("/");
};
const handleDeleteAccount = async () => {
devLog("[account/mfa] delete account requested");
setIsDeleting(true);
try {
if (await needsMfaVerification()) {
setDeleteConfirm(false);
setAccountDeleteMfaOpen(true);
setIsDeleting(false);
return;
}
await deleteAccount();
await signOut();
router.push("/");
} catch {
} catch (error) {
setIsDeleting(false);
devLog("[account/mfa] delete account failed", {
isMfaRequired: isMfaRequiredError(error),
error,
});
if (isMfaRequiredError(error)) {
setDeleteConfirm(false);
setAccountDeleteMfaOpen(true);
return;
}
setDeleteConfirm(false);
alert("Failed to delete account. Please try again.");
}
};
const handleSaveEmail = async () => {
const nextEmail = email.trim();
if (!nextEmail || nextEmail === user?.email) return;
devLog("[account/mfa] save email requested");
setIsSavingEmail(true);
setEmailStatus(null);
setEmailWarning(null);
try {
if (await needsMfaVerification()) {
setEmailMfaOpen(true);
return;
}
const updatedUser = await updateEmail(nextEmail);
const pendingEmail = updatedUser.pendingEmail;
setEmail(pendingEmail || updatedUser.email);
setEmailSaved(true);
setEmailStatus(
pendingEmail
? `Confirmation sent to ${pendingEmail}. Your current email remains ${updatedUser.email} until the change is confirmed.`
: "Email updated.",
);
setTimeout(() => setEmailSaved(false), 2000);
} catch (error: unknown) {
devLog("[account/mfa] save email failed", { error });
const message =
error instanceof Error
? error.message
: "Failed to update email. Please try again.";
if (isAlreadyRegisteredEmailError(message)) {
setEmail(user?.pendingEmail || user?.email || "");
setEmailWarning(message);
return;
}
setEmailStatus(message);
} finally {
setIsSavingEmail(false);
}
};
const handleSaveDisplayName = async () => {
setIsSavingName(true);
const success = await updateDisplayName(displayName.trim());
@ -84,13 +173,13 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Profile
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<div className="space-y-4">
<div>
<div className={`${accountGlassSectionClassName} p-4`}>
<div className="divide-y divide-gray-200">
<div className="pb-4">
<label className="text-sm text-gray-600 block mb-2">
Display Name
</label>
<div className="flex gap-2">
<div className="space-y-2">
<Input
type="text"
value={displayName}
@ -98,39 +187,35 @@ export default function AccountPage() {
setDisplayName(e.target.value)
}
placeholder="Enter your name"
className="flex-1 bg-gray-50 shadow-none"
className={accountGlassInputClassName}
/>
<Button
onClick={handleSaveDisplayName}
variant="outline"
disabled={
isSavingName ||
!displayName.trim() ||
saved
}
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
>
{isSavingName ? (
"Saving..."
) : saved ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save
</>
)}
</Button>
<div className="flex justify-end">
<button
type="button"
onClick={handleSaveDisplayName}
disabled={
isSavingName ||
!displayName.trim() ||
saved
}
className="text-xs font-medium text-gray-700 transition-colors hover:text-gray-950 disabled:cursor-not-allowed disabled:text-gray-400"
>
{isSavingName ? (
"Saving..."
) : saved ? (
"Saved"
) : (
"Save"
)}
</button>
</div>
</div>
</div>
<div>
<div className="pt-4">
<label className="text-sm text-gray-600 block mb-2">
Organisation
</label>
<div className="flex gap-2">
<div className="space-y-2">
<Input
type="text"
value={organisation}
@ -138,45 +223,89 @@ export default function AccountPage() {
setOrganisation(e.target.value)
}
placeholder="Enter your organisation"
className="flex-1 bg-gray-50 shadow-none"
className={accountGlassInputClassName}
/>
<Button
onClick={handleSaveOrganisation}
variant="outline"
disabled={
isSavingOrg ||
organisation.trim() ===
(profile?.organisation ?? "") ||
orgSaved
}
className="h-9 min-w-[74px] gap-1.5 bg-white px-2.5 text-xs text-gray-700 shadow-none hover:bg-gray-50"
>
{isSavingOrg ? (
"Saving..."
) : orgSaved ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Save className="h-3.5 w-3.5" />
Save
</>
)}
</Button>
<div className="flex justify-end">
<button
type="button"
onClick={handleSaveOrganisation}
disabled={
isSavingOrg ||
organisation.trim() ===
(profile?.organisation ?? "") ||
orgSaved
}
className="text-xs font-medium text-gray-700 transition-colors hover:text-gray-950 disabled:cursor-not-allowed disabled:text-gray-400"
>
{isSavingOrg ? (
"Saving..."
) : orgSaved ? (
"Saved"
) : (
"Save"
)}
</button>
</div>
</div>
</div>
<div>
<label className="text-sm text-gray-600 block mb-2">
Email
</label>
<Input
type="email"
value={user?.email ?? ""}
disabled
className="bg-gray-50 shadow-none disabled:text-gray-700 disabled:opacity-100"
/>
</div>
</div>
</section>
{/* Email */}
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Email
</h2>
<div className={`${accountGlassSectionClassName} p-4`}>
<div className="space-y-2">
<Input
type="email"
value={email}
onChange={(event) => {
setEmail(event.target.value);
setEmailStatus(null);
setEmailWarning(null);
setEmailSaved(false);
}}
placeholder="Enter your email"
className={accountGlassInputClassName}
/>
{emailStatus ? (
<p className="text-xs text-gray-500">
{emailStatus}
</p>
) : user.pendingEmail ? (
<p className="text-xs text-gray-500">
Pending confirmation: {user.pendingEmail}
</p>
) : null}
{emailStatus && (
<p className="text-xs text-gray-400">
Current email: {user.email}
</p>
)}
<div className="flex justify-end">
<button
type="button"
onClick={handleSaveEmail}
disabled={
isSavingEmail ||
!email.trim() ||
email.trim() === user.email ||
email.trim() === user.pendingEmail ||
emailSaved
}
className="text-xs font-medium text-gray-700 transition-colors hover:text-gray-950 disabled:cursor-not-allowed disabled:text-gray-400"
>
{isSavingEmail ? (
"Saving..."
) : emailSaved ? (
"Saved"
) : (
"Save"
)}
</button>
</div>
</div>
</div>
@ -187,7 +316,7 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Usage Plan
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<div className={`${accountGlassSectionClassName} p-4`}>
<div>
<p className="text-base font-medium text-gray-500 capitalize">
{profile?.tier || "Free"}
@ -201,16 +330,14 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-gray-900">
Actions
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<Button
variant="outline"
onClick={handleLogout}
className="w-full shadow-none sm:w-auto"
>
<LogOut className="h-4 w-4 mr-2" />
Sign Out
</Button>
</div>
<Button
variant="outline"
onClick={handleLogout}
className="w-full gap-1.5 rounded-lg border border-transparent bg-gray-950 px-3 text-white shadow-none transition-colors hover:bg-gray-900 hover:text-white active:bg-black sm:w-auto"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign Out
</Button>
</section>
{/* Danger Zone */}
@ -218,48 +345,76 @@ export default function AccountPage() {
<h2 className="text-2xl font-medium font-serif text-red-600">
Danger Zone
</h2>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-500 mb-4">
Permanently delete your account and all associated data.
This action cannot be undone.
</p>
{deleteConfirm ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 space-y-3 max-w-sm">
<p className="text-sm font-medium text-red-700">
Are you sure? This will permanently delete your
account.
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeleteConfirm(false)}
disabled={isDeleting}
className="text-sm shadow-none"
>
Cancel
</Button>
<Button
onClick={handleDeleteAccount}
disabled={isDeleting}
className="bg-red-600 text-sm text-white shadow-none hover:bg-red-700"
>
{isDeleting
? "Deleting…"
: "Delete Account"}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
onClick={() => setDeleteConfirm(true)}
className="w-full border-red-200 text-red-600 shadow-none hover:bg-red-50 hover:text-red-700 sm:w-auto"
>
Delete Account
</Button>
)}
<div
className={`${accountGlassSectionClassName} flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between`}
>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Delete account
</p>
<p className="text-sm text-gray-500">
Permanently delete your account and all associated
data. This action cannot be undone.
</p>
</div>
<Button
variant="outline"
onClick={() => setDeleteConfirm(true)}
disabled={isDeleting}
className={`w-full shrink-0 gap-1.5 sm:w-auto ${accountGlassDangerOutlineButtonClassName}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
Delete account
</Button>
</div>
</section>
<ConfirmPopup
open={deleteConfirm}
title="Delete account?"
message="This will permanently delete your account and all associated data. This action cannot be undone."
confirmLabel="Delete"
confirmStatus={isDeleting ? "loading" : "idle"}
cancelLabel="Cancel"
onCancel={() => {
if (isDeleting) return;
setDeleteConfirm(false);
}}
onConfirm={() => void handleDeleteAccount()}
/>
<WarningPopup
open={!!emailWarning}
title="Email already registered"
message={emailWarning}
onClose={() => setEmailWarning(null)}
/>
<MfaVerificationPopup
open={accountDeleteMfaOpen}
onCancel={() => 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."
/>
<MfaVerificationPopup
open={emailMfaOpen}
onCancel={() => 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."
/>
</div>
);
}
function isAlreadyRegisteredEmailError(message: string) {
return message
.toLowerCase()
.includes("a user with this email address has already been registered");
}

View file

@ -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<typeof console.log>) => {
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<DeleteDataAction | null>(null);
const [deletingAction, setDeletingAction] =
useState<DeleteDataAction | null>(null);
const [pendingMfaAction, setPendingMfaAction] =
useState<MfaRetryAction | null>(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 (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Export data
</h2>
<div className={accountGlassSectionClassName}>
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Export chats
</p>
<p className="text-sm text-gray-500">
Download assistant and tabular review chat
history as JSON.
</p>
</div>
<Button
variant="outline"
onClick={handleExportChatData}
disabled={isExportingChats}
className={`h-9 gap-1.5 text-sm ${accountGlassPrimaryButtonClassName}`}
>
{!isExportingChats && (
<Download className="h-4 w-4 shrink-0" />
)}
{isExportingChats ? "Exporting..." : "Export"}
</Button>
</div>
<div className="mx-4 h-px bg-gray-200" />
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Export tabular reviews
</p>
<p className="text-sm text-gray-500">
Download all owned tabular reviews, cells, and
review chat records as JSON.
</p>
</div>
<Button
variant="outline"
onClick={handleExportTabularReviewsData}
disabled={isExportingTabularReviews}
className={`h-9 gap-1.5 text-sm ${accountGlassPrimaryButtonClassName}`}
>
{!isExportingTabularReviews && (
<Download className="h-4 w-4 shrink-0" />
)}
{isExportingTabularReviews
? "Exporting..."
: "Export"}
</Button>
</div>
<div className="mx-4 h-px bg-gray-200" />
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Export account JSON
</p>
<p className="text-sm text-gray-500">
Download account metadata, projects, document
metadata, workflows, and review data as JSON.
</p>
</div>
<Button
variant="outline"
onClick={handleExportAccountData}
disabled={isExportingAccount}
className={`h-9 gap-1.5 text-sm ${accountGlassPrimaryButtonClassName}`}
>
{!isExportingAccount && (
<Download className="h-4 w-4 shrink-0" />
)}
{isExportingAccount ? "Exporting..." : "Export"}
</Button>
</div>
</div>
</section>
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Delete data
</h2>
<div className={accountGlassSectionClassName}>
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Delete all chats
</p>
<p className="text-sm text-gray-500">
Permanently delete your assistant and tabular
review chat history.
</p>
</div>
<Button
variant="outline"
onClick={() => setPendingDeleteAction("chats")}
disabled={!!deletingAction}
className={`h-9 w-full shrink-0 gap-1.5 sm:w-auto ${accountGlassDangerOutlineButtonClassName}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
Delete
</Button>
</div>
<div className="mx-4 h-px bg-gray-200" />
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Delete all tabular reviews
</p>
<p className="text-sm text-gray-500">
Permanently delete all tabular reviews you own,
including cells and review chats.
</p>
</div>
<Button
variant="outline"
onClick={() =>
setPendingDeleteAction("tabular-reviews")
}
disabled={!!deletingAction}
className={`h-9 w-full shrink-0 gap-1.5 sm:w-auto ${accountGlassDangerOutlineButtonClassName}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
Delete
</Button>
</div>
<div className="mx-4 h-px bg-gray-200" />
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Delete all projects
</p>
<p className="text-sm text-gray-500">
Permanently delete all projects you own,
including documents, chats, and tabular reviews.
</p>
</div>
<Button
variant="outline"
onClick={() => setPendingDeleteAction("projects")}
disabled={!!deletingAction}
className={`h-9 w-full shrink-0 gap-1.5 sm:w-auto ${accountGlassDangerOutlineButtonClassName}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
Delete
</Button>
</div>
</div>
</section>
<ConfirmPopup
open={!!pendingDeleteAction}
title={pendingDeleteCopy?.title}
message={pendingDeleteCopy?.message}
confirmLabel="Delete"
confirmStatus={deletingAction ? "loading" : "idle"}
cancelLabel="Cancel"
onCancel={() => {
if (deletingAction) return;
setPendingDeleteAction(null);
}}
onConfirm={() => {
if (!pendingDeleteAction) return;
void handleDeleteData(pendingDeleteAction);
}}
/>
<MfaVerificationPopup
open={!!pendingMfaAction}
onCancel={() => setPendingMfaAction(null)}
onVerified={() => void handleMfaVerified()}
title="Two-factor verification required"
message="This action is sensitive. Enter a code from your authenticator app to continue."
/>
</div>
);
}

View file

@ -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<typeof console.info>) => {
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<Array<HTMLInputElement | null>>([]);
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<HTMLInputElement>) {
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<HTMLInputElement>,
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 (
<div
className="flex justify-center gap-2"
role="group"
aria-label="Six digit verification code"
>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
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}
/>
))}
</div>
);
}
function MfaSettingsSkeleton() {
return (
<div className="px-4 py-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="h-4 w-36 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-72 max-w-full animate-pulse rounded bg-gray-100" />
</div>
<div className="h-8 w-20 animate-pulse rounded-lg bg-gray-100" />
</div>
<div className="my-5 h-px bg-gray-100" />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-2">
<div className="h-4 w-32 animate-pulse rounded bg-gray-100" />
<div className="h-3 w-64 max-w-full animate-pulse rounded bg-gray-100" />
</div>
<div className="h-7 w-12 animate-pulse rounded-full bg-gray-100" />
</div>
</div>
);
}
export default function SecurityPage() {
const { profile, updateMfaOnLogin } = useUserProfile();
const [loading, setLoading] = useState(true);
const [factors, setFactors] = useState<MfaFactor[]>([]);
const [currentLevel, setCurrentLevel] = useState<string | null>(null);
const [nextLevel, setNextLevel] = useState<string | null>(null);
const [setupModalOpen, setSetupModalOpen] = useState(false);
const [enrollment, setEnrollment] = useState<Enrollment | null>(null);
const [verificationCode, setVerificationCode] = useState("");
const [setupKeyCopied, setSetupKeyCopied] = useState(false);
const [status, setStatus] = useState<string | null>(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 (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-2xl font-medium font-serif text-gray-900">
Multi-Factor Authentication
</h2>
<div className={accountGlassSectionClassName}>
{loading ? (
<MfaSettingsSkeleton />
) : (
<>
<div className="px-4 py-5">
<div className="space-y-1">
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-gray-900">
Verification method
</p>
<span
className={`shrink-0 text-xs font-medium ${
hasVerifiedFactor
? "text-green-700"
: "text-gray-500"
}`}
>
{hasVerifiedFactor
? "Enabled"
: "Not set up"}
</span>
</div>
<p className="text-sm text-gray-500">
{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."}
</p>
</div>
{!hasVerifiedFactor && !enrollment ? (
<div className="mt-3 flex justify-end">
<Button
variant="outline"
onClick={() =>
setSetupModalOpen(true)
}
disabled={busy}
className={`h-9 w-full gap-1.5 sm:w-auto ${accountGlassPrimaryButtonClassName}`}
>
{busy ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting...
</>
) : (
"Set up"
)}
</Button>
</div>
) : null}
</div>
{hasVerifiedFactor && (
<>
<div className="mx-4 h-px bg-gray-200" />
<div className="flex flex-col gap-3 px-4 py-5 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Login verification
</p>
<p className="text-sm text-gray-500">
Ask for an authenticator code
after each new login, instead of
only before sensitive actions.
</p>
</div>
<button
type="button"
role="switch"
aria-checked={loginMfaEnabled}
onClick={() =>
void handleLoginPreferenceToggle()
}
disabled={savingLoginPreference}
className={`flex h-7 w-12 shrink-0 items-center rounded-full px-1 transition-colors ${
loginMfaEnabled
? "bg-gray-950"
: "bg-gray-200"
} disabled:cursor-not-allowed disabled:opacity-45`}
>
<span
className={`h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
loginMfaEnabled
? "translate-x-5"
: "translate-x-0"
}`}
/>
</button>
</div>
<div className="flex justify-end px-4 pb-4 pt-1">
<button
type="button"
onClick={() =>
void requestUnenroll(
factors[0]?.id,
)
}
disabled={busy || !factors[0]?.id}
className="text-xs font-medium text-red-600 transition-colors hover:text-red-700 disabled:cursor-not-allowed disabled:text-red-300"
>
Remove authenticator app
</button>
</div>
</>
)}
</>
)}
{status && (
<>
<div className="mx-4 h-px bg-gray-200" />
<p className="px-4 py-3 text-xs text-gray-500">
{status}
</p>
</>
)}
</div>
</section>
<Modal
open={setupModalOpen}
onClose={() => 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,
}
}
>
<div className={enrollment ? "space-y-3 pt-2" : "space-y-5 pt-3"}>
{!enrollment ? (
<>
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
Step 1
</p>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Before you start
</p>
<p className="text-sm text-gray-500">
Download an authenticator app such as Google
Authenticator, Microsoft Authenticator,
Authy, 1Password, or iCloud Passwords.
</p>
</div>
<ol className="list-decimal space-y-1 pl-4 text-sm text-gray-500">
<li>
Download and open your authenticator app.
</li>
<li>
Choose the option to add a new account.
</li>
</ol>
</>
) : (
<>
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">
Step 2
</p>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-900">
Scan this code
</p>
<p className="text-sm text-gray-500">
In your authenticator app, add a new account
and scan the QR code. If you cannot scan it,
enter the setup key below manually.
</p>
</div>
<div className="min-w-0">
<div className="mb-1 flex items-center justify-between gap-3">
<p className="text-xs font-medium text-gray-500">
Setup key
</p>
<button
type="button"
onClick={() => void copySetupKey()}
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 transition-colors hover:text-gray-950"
>
<Copy className="h-3 w-3" />
{setupKeyCopied ? "Copied" : "Copy"}
</button>
</div>
<p className="break-all text-xs text-gray-700">
{enrollment.secret}
</p>
</div>
<div className="flex justify-center">
<div className="flex h-48 w-48 items-center justify-center rounded-xl bg-white p-2">
<img
src={enrollment.qrCode}
alt="MFA QR code"
className="h-full w-full"
/>
</div>
</div>
<div className="min-w-0 space-y-3">
<VerificationCodeInput
value={verificationCode}
onChange={setVerificationCode}
disabled={busy}
/>
</div>
</>
)}
</div>
</Modal>
<MfaVerificationPopup
open={!!pendingUnenrollFactorId}
onCancel={() => setPendingUnenrollFactorId(null)}
onVerified={() => {
const factorId = pendingUnenrollFactorId;
setPendingUnenrollFactorId(null);
if (factorId) void unenrollFactor(factorId);
}}
/>
<MfaVerificationPopup
open={pendingLoginPreference !== null}
onCancel={() => 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."
/>
</div>
);
}

View file

@ -430,7 +430,8 @@ export function AppSidebar({ isOpen, onToggle }: AppSidebarProps) {
{isDropdownOpen && (
<div
className={cn(
"absolute bottom-full left-0 right-0 z-50 mb-1 p-1 whitespace-nowrap",
"absolute bottom-full left-0 z-50 mb-1 p-1 whitespace-nowrap",
isOpen ? "right-0" : "w-56",
"bg-white/80 rounded-xl shadow-[0_6px_17px_rgba(15,23,42,0.1)] border border-white/70 backdrop-blur-xl",
)}
>

View file

@ -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<GateState>("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 <FullScreenGateLoader />;
if (user && profile?.mfaOnLogin) {
if (gateState === "required" && isVerifyPage) {
return <>{children}</>;
}
if (gateState === "verified" && isVerifyPage) {
return <FullScreenGateLoader />;
}
if (gateState === "verified") {
return <>{children}</>;
}
if (gateState === "required" && !isVerifyPage) {
return <FullScreenGateLoader />;
}
return <FullScreenGateLoader />;
}
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 (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}
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
);
}

View file

@ -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<typeof console.log>) => {
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<MfaFactor[]>([]);
const [selectedFactorId, setSelectedFactorId] = useState("");
const [code, setCode] = useState("");
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
className="h-auto min-h-[310px] max-h-[min(92vh,400px)]"
cancelAction={{
label: "Cancel",
onClick: onCancel,
disabled: verifying,
}}
primaryAction={{
label: verifying ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Verifying...
</span>
) : (
"Verify"
),
onClick: () => void verify(),
disabled: !canVerify,
}}
>
<div className="space-y-5 pb-2 pt-0">
<p className="text-sm text-gray-500 pb-6">{message}</p>
{loading ? (
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading authenticator...
</div>
) : factors.length === 0 ? (
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
No verified authenticator factor is available for this
session.
</p>
) : (
<div className="space-y-4">
{factors.length > 1 && (
<select
value={selectedFactorId}
onChange={(event) =>
setSelectedFactorId(event.target.value)
}
className="h-9 w-full rounded-lg bg-gray-100 px-3 text-sm text-gray-900 outline-none focus-visible:ring-2 focus-visible:ring-gray-300/45"
>
{factors.map((factor) => (
<option key={factor.id} value={factor.id}>
{factor.friendly_name ||
"Authenticator app"}
</option>
))}
</select>
)}
<VerificationCodeInput
value={code}
onChange={setCode}
disabled={verifying}
autoFocus={open && !loading}
onSubmit={() => void verify()}
canSubmit={canVerify}
/>
</div>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
</Modal>
);
}
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<Array<HTMLInputElement | null>>([]);
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<HTMLInputElement>) {
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<HTMLInputElement>,
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 (
<div
className="flex justify-center gap-2"
role="group"
aria-label="Six digit verification code"
>
{digits.map((digit, index) => (
<input
key={index}
ref={(element) => {
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}
/>
))}
</div>
);
}

View file

@ -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<typeof console.log>) => {
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<Record<string, string>> {
const {
@ -59,8 +83,7 @@ async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
});
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<T>(path: string, init?: RequestInit): Promise<T> {
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<void> {
return apiRequest<void>("/user/account", { method: "DELETE" });
}
export async function deleteAllChats(): Promise<void> {
return apiRequest<void>("/user/chats", { method: "DELETE" });
}
export async function deleteAllProjects(): Promise<void> {
return apiRequest<void>("/user/projects", { method: "DELETE" });
}
export async function deleteAllTabularReviews(): Promise<void> {
return apiRequest<void>("/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<UserProfile> {
return apiRequest<UserProfile>("/user/security/mfa-login", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
}
export type ApiKeyProvider =
| "claude"
| "gemini"

View file

@ -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 (
<div className="min-h-dvh bg-white flex items-start justify-center px-6 pt-32 md:pt-40 pb-10 relative">
<div className="min-h-dvh bg-gray-50/80 flex items-start justify-center px-6 pt-32 md:pt-40 pb-10 relative">
<div className="absolute top-4 md:top-8 left-1/2 -translate-x-1/2">
<SiteLogo size="md" className="md:text-4xl" asLink />
<SiteLogo size="lg" asLink />
</div>
<div className="w-full max-w-md">
{/* Login Form */}
<div className="bg-white border border-gray-200 rounded-2xl p-8 mb-4">
<div className={`${authGlassCardClassName} mb-4`}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-left text-2xl font-serif">
Log In
</h2>
<div className="bg-gray-100 p-1 rounded-md flex text-xs font-medium">
<span className="text-gray-600 px-3 py-1 bg-white rounded-sm shadow-sm">
<div className="bg-gray-200/70 p-1 rounded-lg flex text-xs font-medium shadow-[inset_0_1px_0_rgba(255,255,255,0.65),inset_0_-3px_8px_rgba(148,163,184,0.16)] backdrop-blur-xl">
<span className="text-gray-700 px-3 py-1 bg-white/85 rounded-md shadow-[0_1px_4px_rgba(15,23,42,0.06)]">
Log in
</span>
<Link
@ -82,7 +88,7 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
required
className="w-full"
className={`w-full ${authInputClassName}`}
/>
</div>
@ -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}`}
/>
</div>

View file

@ -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 (
<div className="min-h-dvh bg-white flex items-start justify-center px-6 pt-32 md:pt-40 pb-10 relative">
<div className="min-h-dvh bg-gray-50/80 flex items-start justify-center px-6 pt-32 md:pt-40 pb-10 relative">
<div className="absolute top-4 md:top-8 left-1/2 -translate-x-1/2">
<SiteLogo size="md" className="md:text-4xl" asLink />
<SiteLogo size="lg" asLink />
</div>
<div className="w-full max-w-md">
<div className="bg-white border border-gray-200 rounded-2xl p-10 text-center shadow-sm">
<div
className={`${authGlassCardClassName} p-10 text-center`}
>
<div className="mx-auto w-12 h-12 bg-green-50 rounded-full flex items-center justify-center mb-6">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
@ -114,24 +121,24 @@ export default function SignupPage() {
// Default Signup Form View
return (
<div className="min-h-dvh bg-white flex items-start justify-center px-6 pt-32 md:pt-40 pb-10 relative">
<div className="min-h-dvh bg-gray-50/80 flex items-start justify-center px-6 pt-32 md:pt-40 pb-10 relative">
<div className="absolute top-4 md:top-8 left-1/2 -translate-x-1/2">
<SiteLogo size="md" className="md:text-4xl" asLink />
<SiteLogo size="lg" asLink />
</div>
<div className="w-full max-w-md">
<div className="bg-white border border-gray-200 rounded-2xl p-8 mb-4">
<div className={`${authGlassCardClassName} mb-4`}>
<div className="flex justify-between items-center mb-6">
<h2 className="text-left text-2xl font-serif">
Create Account
</h2>
<div className="bg-gray-100 p-1 rounded-md flex text-xs font-medium">
<div className="bg-gray-200/70 p-1 rounded-lg flex text-xs font-medium shadow-[inset_0_1px_0_rgba(255,255,255,0.65),inset_0_-3px_8px_rgba(148,163,184,0.16)] backdrop-blur-xl">
<Link
href="/login"
className="px-3 py-1 text-gray-500 hover:text-gray-900"
>
Log in
</Link>
<span className="px-3 py-1 bg-white rounded-sm shadow-sm text-gray-900">
<span className="px-3 py-1 bg-white/85 rounded-md shadow-[0_1px_4px_rgba(15,23,42,0.06)] text-gray-900">
Sign up
</span>
</div>
@ -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}`}
/>
</div>
@ -176,7 +183,7 @@ export default function SignupPage() {
setOrganisation(e.target.value)
}
placeholder="Your organisation"
className="w-full"
className={`w-full ${authInputClassName}`}
/>
</div>
@ -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}`}
/>
</div>
@ -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}`}
/>
</div>
@ -232,7 +239,7 @@ export default function SignupPage() {
}
placeholder="Confirm your password"
required
className="w-full"
className={`w-full ${authInputClassName}`}
/>
</div>

View file

@ -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<MfaFactor[]>([]);
const [selectedFactorId, setSelectedFactorId] = useState("");
const [code, setCode] = useState("");
const [loading, setLoading] = useState(true);
const [verifying, setVerifying] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="relative flex min-h-dvh items-start justify-center bg-gray-50/80 px-6 pb-10 pt-32 md:pt-40">
<div className="absolute left-1/2 top-4 -translate-x-1/2 md:top-8">
<SiteLogo size="lg" asLink />
</div>
<div className={`w-full max-w-md ${authGlassCardClassName}`}>
<div className="mb-8 space-y-2">
<h1 className="text-2xl font-serif">
Verify your identity
</h1>
<p className="text-sm text-gray-500">
Enter the six-digit code from your authenticator app to
continue.
</p>
</div>
<div className="space-y-6">
{loading ? (
<div className="flex h-13 items-center justify-center text-sm text-gray-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading authenticator...
</div>
) : factors.length === 0 ? (
<p className="rounded-lg bg-gray-100 px-3 py-2 text-sm text-gray-600">
No verified authenticator factor is available for
this session.
</p>
) : (
<>
{factors.length > 1 && (
<select
value={selectedFactorId}
onChange={(event) =>
setSelectedFactorId(event.target.value)
}
className="h-9 w-full rounded-lg border border-transparent bg-gray-100 px-3 text-sm text-gray-900 shadow-none outline-none focus-visible:border-gray-200 focus-visible:ring-2 focus-visible:ring-gray-300/45"
>
{factors.map((factor) => (
<option
key={factor.id}
value={factor.id}
>
{factor.friendly_name ||
"Authenticator app"}
</option>
))}
</select>
)}
<VerificationCodeInput
value={code}
onChange={setCode}
disabled={verifying}
autoFocus={!loading}
canSubmit={canVerify}
onSubmit={() => void verify()}
/>
</>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex items-center justify-end gap-2 pt-4">
<button
type="button"
onClick={() => void cancel()}
disabled={verifying}
className="px-3 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:text-gray-950 disabled:cursor-not-allowed disabled:text-gray-400"
>
Cancel
</button>
<Button
type="button"
onClick={() => void verify()}
disabled={!canVerify}
className="inline-flex items-center justify-center gap-1.5 rounded-full border border-gray-700/40 bg-gray-950/88 px-4 py-1.5 text-sm font-medium text-white shadow-[0_3px_9px_rgba(15,23,42,0.16),inset_0_1px_0_rgba(255,255,255,0.22),inset_0_-4px_9px_rgba(15,23,42,0.2)] backdrop-blur-xl transition-all hover:bg-gray-900/90 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 disabled:active:scale-100"
>
{verifying ? (
<span className="inline-flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Verifying...
</span>
) : (
"Verify"
)}
</Button>
</div>
</div>
</div>
</div>
);
}
function safeNextPath(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) {
return "/assistant";
}
if (value.startsWith("/verify-mfa")) return "/assistant";
return value;
}

View file

@ -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 (
<AuthProvider>
<UserProfileProvider>
{children}
<Suspense fallback={<ProviderLoader />}>
<MfaLoginGate>{children}</MfaLoginGate>
</Suspense>
</UserProfileProvider>
</AuthProvider>
);
}
function ProviderLoader() {
return (
<div className="flex min-h-dvh items-center justify-center bg-gray-50/80">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-gray-700" />
</div>
);
}

View file

@ -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}`}
>
<MikeIcon size={iconSizes[size]} />
<span
className={`inline-flex shrink-0 items-center leading-none ${iconClassName}`}
>
<MikeIcon size={iconSizes[size]} />
</span>
<span>Mike</span>
</h1>
);

View file

@ -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<void>;
updateEmail: (email: string) => Promise<User>;
}
const AuthContext = createContext<AuthContextType | undefined>(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<User | null>(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 (
<AuthContext.Provider
value={{
@ -75,6 +98,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
isAuthenticated: !!user,
authLoading,
signOut,
updateEmail,
}}
>
{children}

View file

@ -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<boolean>;
updateMfaOnLogin: (enabled: boolean) => Promise<boolean>;
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<boolean> => {
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,