mirror of
https://github.com/willchen96/mike.git
synced 2026-06-10 20:35:12 +02:00
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:
parent
15c96b0dd4
commit
3a10943200
32 changed files with 3704 additions and 311 deletions
|
|
@ -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;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
59
backend/src/lib/safeError.ts
Normal file
59
backend/src/lib/safeError.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
339
backend/src/lib/userDataCleanup.ts
Normal file
339
backend/src/lib/userDataCleanup.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
278
backend/src/lib/userDataExport.ts
Normal file
278
backend/src/lib/userDataExport.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
37
frontend/src/app/(pages)/account/accountStyles.ts
Normal file
37
frontend/src/app/(pages)/account/accountStyles.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
398
frontend/src/app/(pages)/account/privacy-data/page.tsx
Normal file
398
frontend/src/app/(pages)/account/privacy-data/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
718
frontend/src/app/(pages)/account/security/page.tsx
Normal file
718
frontend/src/app/(pages)/account/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
126
frontend/src/app/components/shared/MfaLoginGate.tsx
Normal file
126
frontend/src/app/components/shared/MfaLoginGate.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
294
frontend/src/app/components/shared/MfaVerificationPopup.tsx
Normal file
294
frontend/src/app/components/shared/MfaVerificationPopup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
218
frontend/src/app/verify-mfa/page.tsx
Normal file
218
frontend/src/app/verify-mfa/page.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue