mirror of
https://github.com/willchen96/mike.git
synced 2026-06-26 21:39:39 +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
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue