mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
merge: integrate the v1.23 sessions and login-alert wave
This commit is contained in:
commit
4c4231838c
38 files changed
+2590
-23
No files matched your search
@@ -13,6 +13,8 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (
|
||||
@@ -65,6 +67,16 @@ export const POST = apiHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — reject an admin-set password found in a known breach corpus
|
||||
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
|
||||
const breach = await checkPasswordBreach(password);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* GET /api/auth/me/security-activity
|
||||
*
|
||||
* v1.23 — the SHARED user-facing security-activity feed. Returns the caller's
|
||||
* recent account-security audit events (logins, MFA, password change, token /
|
||||
* session revocations, exports, deletions) with timestamp + resolved location
|
||||
* + MASKED IP. The IP is never returned in full and no event detail body is
|
||||
* surfaced — only the action name, time, and coarse geo. Consumed by the
|
||||
* account-security surface and the privacy dashboard.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { securityActivityWhere } from "@/lib/auth/security-activity";
|
||||
import { maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
export interface SecurityActivityDTO {
|
||||
action: string;
|
||||
createdAt: string;
|
||||
location: string | null;
|
||||
ipMasked: string | null;
|
||||
carrier: string | null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const limitParam = Number(
|
||||
request.nextUrl.searchParams.get("limit") ?? DEFAULT_LIMIT,
|
||||
);
|
||||
const limit =
|
||||
Number.isFinite(limitParam) && limitParam > 0
|
||||
? Math.min(Math.floor(limitParam), MAX_LIMIT)
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: securityActivityWhere(user.id),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
action: true,
|
||||
createdAt: true,
|
||||
location: true,
|
||||
ipAddress: true,
|
||||
carrier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const events: SecurityActivityDTO[] = rows.map((r) => ({
|
||||
action: r.action,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
location: r.location ?? null,
|
||||
ipMasked: maskIp(r.ipAddress),
|
||||
carrier: r.carrier ?? null,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.security_activity.list" },
|
||||
meta: { event_count: events.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ events });
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* DELETE /api/auth/me/sessions/[id] — revoke a single active web session.
|
||||
*
|
||||
* v1.23 — the deletion is scoped to the authenticated user (the Prisma `where`
|
||||
* narrows on both id AND userId), so a caller can never revoke another user's
|
||||
* session by guessing an id. A 404 is returned for a non-existent or
|
||||
* not-owned id so the surface cannot probe foreign session ids.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { destroySessionById } from "@/lib/auth/session";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user, session } = await requireAuth();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const removed = await destroySessionById(user.id, id);
|
||||
if (!removed) {
|
||||
return apiError("Session not found", 404);
|
||||
}
|
||||
|
||||
await auditLog("auth.session.revoke", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { self: id === session.id },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.revoke" },
|
||||
meta: { self: id === session.id },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { session: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
destroyOtherSessions: vi.fn(),
|
||||
destroySessionById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, DELETE } from "../route";
|
||||
import { DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
getSession,
|
||||
destroyOtherSessions,
|
||||
destroySessionById,
|
||||
} from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-current", expiresAt: new Date(Date.now() + 3.6e6) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const },
|
||||
};
|
||||
|
||||
function del(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/me/sessions", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/sessions", () => {
|
||||
it("401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("lists sessions with masked IP, location, and the current marker", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.session.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "sess-current",
|
||||
ipAddress: "203.0.113.7",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:120.0) Gecko/20100101 Firefox/120.0",
|
||||
lastActiveAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "sess-other",
|
||||
ipAddress: "198.51.100.4",
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36",
|
||||
lastActiveAt: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
const list = body.data.sessions;
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
id: "sess-current",
|
||||
device: "Firefox on macOS",
|
||||
ipMasked: "203.0.x.x",
|
||||
location: "Berlin, DE",
|
||||
isCurrent: true,
|
||||
});
|
||||
expect(list[1].isCurrent).toBe(false);
|
||||
// The full IP never appears in the response body.
|
||||
expect(JSON.stringify(body)).not.toContain("203.0.113.7");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
it("revokes other sessions and reports the count", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroyOtherSessions).mockResolvedValue({ sessionsRevoked: 3 });
|
||||
|
||||
const res = await DELETE(del());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.sessionsRevoked).toBe(3);
|
||||
expect(destroyOtherSessions).toHaveBeenCalledWith("user-1", "sess-current");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions/[id]", () => {
|
||||
function ctx(id: string) {
|
||||
return { params: Promise.resolve({ id }) };
|
||||
}
|
||||
function delReq(id: string): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/auth/me/sessions/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
it("revokes a single owned session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroySessionById).mockResolvedValue(true);
|
||||
const res = await DELETE_ONE(delReq("sess-other"), ctx("sess-other"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(destroySessionById).toHaveBeenCalledWith("user-1", "sess-other");
|
||||
});
|
||||
|
||||
it("404 for a non-existent or not-owned id", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(destroySessionById).mockResolvedValue(false);
|
||||
const res = await DELETE_ONE(delReq("nope"), ctx("nope"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* GET /api/auth/me/sessions — list the user's active web sessions.
|
||||
* DELETE /api/auth/me/sessions — "sign out everywhere": revoke every OTHER
|
||||
* session (and native refresh tokens), keeping
|
||||
* the caller's current session. Closes #64.
|
||||
*
|
||||
* v1.23 — the user-facing session/device-management surface. Distinct from
|
||||
* `/api/auth/me/devices`, which lists APNs / Web-Push notification devices;
|
||||
* this lists the authenticated `Session` rows (one per browser login). The list
|
||||
* carries the masked IP + resolved coarse location + a coarse device label +
|
||||
* the sliding `lastActiveAt` — never the full IP, never a credential. The
|
||||
* caller's own row is flagged `isCurrent` so the UI can mark "this device".
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export interface SessionDTO {
|
||||
id: string;
|
||||
device: string;
|
||||
ipMasked: string | null;
|
||||
location: string | null;
|
||||
lastActiveAt: string | null;
|
||||
createdAt: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, session } = await requireAuth();
|
||||
|
||||
const sessions = await prisma.session.findMany({
|
||||
where: { userId: user.id, expiresAt: { gt: new Date() } },
|
||||
orderBy: { lastActiveAt: { sort: "desc", nulls: "last" } },
|
||||
select: {
|
||||
id: true,
|
||||
ipAddress: true,
|
||||
userAgent: true,
|
||||
lastActiveAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve location once per distinct IP (the resolver caches internally, but
|
||||
// de-duping keeps the await count to the number of unique networks).
|
||||
const uniqueIps = Array.from(
|
||||
new Set(
|
||||
sessions.map((s) => s.ipAddress).filter((ip): ip is string => !!ip),
|
||||
),
|
||||
);
|
||||
const locationByIp = new Map<string, string | null>();
|
||||
await Promise.all(
|
||||
uniqueIps.map(async (ip) => {
|
||||
try {
|
||||
locationByIp.set(ip, await lookupIpLocation(ip));
|
||||
} catch {
|
||||
locationByIp.set(ip, null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const result: SessionDTO[] = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
device: coarseDeviceLabel(s.userAgent),
|
||||
ipMasked: maskIp(s.ipAddress),
|
||||
location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null,
|
||||
lastActiveAt: s.lastActiveAt ? s.lastActiveAt.toISOString() : null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
isCurrent: s.id === session.id,
|
||||
}));
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.list" },
|
||||
meta: { session_count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ sessions: result });
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user, session } = await requireAuth();
|
||||
|
||||
const { sessionsRevoked } = await destroyOtherSessions(user.id, session.id);
|
||||
|
||||
await auditLog("auth.session.revoke_others", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sessionsRevoked },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.session.revoke_others" },
|
||||
meta: { sessions_revoked: sessionsRevoked },
|
||||
});
|
||||
|
||||
return apiSuccess({ sessionsRevoked });
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -76,6 +77,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
});
|
||||
|
||||
// v1.23 — new-device / new-location alert, fire-and-forget (see finishLogin).
|
||||
void recordSignInDevice({ userId: user.id, ip, userAgent: ua });
|
||||
|
||||
annotate({ action: { name: "auth.login.passkey" } });
|
||||
|
||||
if (
|
||||
|
||||
@@ -19,6 +19,8 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -73,6 +75,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — reject a newly chosen password that appears in a known breach
|
||||
// corpus (HIBP k-anonymity). Fail-open: a null result (HIBP unreachable)
|
||||
// never blocks the change. Only the user's chosen NEW password is checked —
|
||||
// existing credentials are never retroactively blocked at login.
|
||||
const breach = await checkPasswordBreach(newPassword);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { registerSchema } from "@/lib/validations/auth";
|
||||
import { hashPassword, checkPasswordStrength } from "@/lib/auth/password";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { recordSignInDevice } from "@/lib/auth/login-alert";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
@@ -19,6 +20,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { checkPasswordBreach } from "@/lib/auth/hibp";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
@@ -121,6 +124,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.23 — reject a registration password found in a known breach corpus
|
||||
// (HIBP k-anonymity). Fail-open on an unreachable HIBP.
|
||||
const breach = await checkPasswordBreach(password);
|
||||
if (breach?.breached) {
|
||||
return apiError(
|
||||
getServerTranslator(locale).t("auth.passwordBreached"),
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
// v1.15.20 — consume the invite LAST, after every other validation,
|
||||
@@ -176,6 +190,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ua = request.headers.get("user-agent");
|
||||
await createSession(user.id, true, ip, ua);
|
||||
|
||||
// v1.23 — record the registering device silently so the account's first
|
||||
// login from this same device does not immediately fire a "new device"
|
||||
// alert. A genuinely new device later still alerts on its first sighting.
|
||||
void recordSignInDevice({
|
||||
userId: user.id,
|
||||
ip,
|
||||
userAgent: ua,
|
||||
alertOnNew: false,
|
||||
});
|
||||
|
||||
// Stamp the consumer onto the invite (informational, best-effort —
|
||||
// the use itself was already counted atomically above).
|
||||
if (inviteId) {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,9 +15,14 @@ export const dynamic = "force-dynamic";
|
||||
/**
|
||||
* Permanently delete the user account and ALL associated data.
|
||||
* Cascading deletes in the schema handle all related records.
|
||||
*
|
||||
* v1.23 — for an account with a second factor active this requires a fresh
|
||||
* step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed
|
||||
* confirmation, so a hijacked live session cannot erase the record. Accounts
|
||||
* without MFA are unaffected and keep the typed-confirmation-only contract.
|
||||
*/
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,11 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireFreshMfaIfEnrolled,
|
||||
MFA_STEP_UP_MAX_AGE_SECONDS,
|
||||
} from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { invalidateUserData } from "@/lib/cache/invalidate";
|
||||
|
||||
@@ -10,9 +14,13 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Delete all user-owned health/integration data while keeping the account.
|
||||
*
|
||||
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
|
||||
* deletion route); single-factor accounts keep the typed-confirmation-only
|
||||
* contract.
|
||||
*/
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user