Files
HealthLog/src/app/api/admin/settings/route.ts
T
Marc-André Bombeck c8bef61344 feat(security): let an operator require a second factor
Wire the previously-unused mfaEnforced flag into a real policy. An admin can
require a second factor instance-wide (an AppSettings toggle in the General
section) or per user (a toggle in user management); the effective requirement is
the OR of the two. When required and the account has no active factor — no
confirmed TOTP, no registered security key, and no primary passkey — the user is
walled behind a forced-enrollment interstitial after sign-in, with only the
security settings and sign-out reachable until a factor is enrolled.

The gate is surfaced like the onboarding gate: a hint cookie the proxy reads
without a DB round-trip, recomputed from the database on every sign-in and on
/api/auth/me so a tampered cookie self-corrects. Nobody is locked out — the
enrollment surface stays reachable, and toggling the policy off (or the
disable-mfa CLI) is the operator's recovery path.
2026-06-27 21:49:53 +02:00

246 lines
8.1 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
import { NextRequest } from "next/server";
import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.settings.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
});
return apiSuccess({
registrationEnabled: settings?.registrationEnabled ?? true,
mfaRequired: settings?.mfaRequired ?? false,
defaultLocale: settings?.defaultLocale ?? "de",
telegramGlobal: settings?.telegramGlobal ?? true,
ntfyGlobal: settings?.ntfyGlobal ?? true,
webPushGlobal: settings?.webPushGlobal ?? true,
webPushVapidPublicKey: settings?.webPushVapidPublicKey ?? null,
webPushVapidSubject: settings?.webPushVapidSubject ?? null,
webPushVapidConfigured: Boolean(
settings?.webPushVapidPublicKey &&
settings?.webPushVapidPrivateKeyEncrypted &&
settings?.webPushVapidSubject,
),
apiGlobal: settings?.apiGlobal ?? true,
umamiEnabled: settings?.umamiEnabled ?? false,
umamiScriptUrl: settings?.umamiScriptUrl ?? null,
umamiWebsiteId: settings?.umamiWebsiteId ?? null,
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
glitchtipDsn: settings?.glitchtipDsn ?? null,
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
// resolver"; surfacing the raw value lets the admin UI render
// an empty picker placeholder until they opt in.
defaultUserTimezone: settings?.defaultUserTimezone ?? null,
});
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
const updates: Record<string, unknown> = {};
const auditDetails: Record<string, unknown> = {};
// Boolean fields — direct mapping
const booleanFields = [
"registrationEnabled",
"mfaRequired",
"telegramGlobal",
"ntfyGlobal",
"webPushGlobal",
"apiGlobal",
"umamiEnabled",
"glitchtipEnabled",
"moodLogGlobal",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
updates[field] = data[field];
auditDetails[field] = data[field];
}
}
if (data.defaultLocale !== undefined) {
updates.defaultLocale = data.defaultLocale;
auditDetails.defaultLocale = data.defaultLocale;
}
// String fields that map directly (with empty → null)
if (data.webPushVapidPublicKey !== undefined) {
const value = data.webPushVapidPublicKey.trim();
updates.webPushVapidPublicKey = value || null;
auditDetails.webPushVapidPublicKey = value ? "configured" : null;
}
if (data.webPushVapidSubject !== undefined) {
const value = data.webPushVapidSubject.trim();
updates.webPushVapidSubject = value || null;
auditDetails.webPushVapidSubject = value || null;
}
// Encrypted fields
if (data.webPushVapidPrivateKey !== undefined) {
const value = data.webPushVapidPrivateKey.trim();
if (value) {
updates.webPushVapidPrivateKeyEncrypted = encrypt(value);
auditDetails.webPushVapidPrivateKeyUpdated = true;
}
}
if (data.clearWebPushVapidPrivateKey === true) {
updates.webPushVapidPrivateKeyEncrypted = null;
auditDetails.webPushVapidPrivateKeyUpdated = false;
}
// URL fields with normalization
if (data.umamiScriptUrl !== undefined) {
const value = data.umamiScriptUrl.trim();
if (!value) {
updates.umamiScriptUrl = null;
auditDetails.umamiScriptUrl = null;
} else {
const parsed = new URL(value);
if (parsed.pathname === "/" || parsed.pathname === "") {
parsed.pathname = "/script.js";
}
updates.umamiScriptUrl = parsed.toString();
auditDetails.umamiScriptUrl = parsed.toString();
}
}
if (data.umamiWebsiteId !== undefined) {
const value = data.umamiWebsiteId.trim();
updates.umamiWebsiteId = value || null;
auditDetails.umamiWebsiteId = value || null;
}
if (data.glitchtipDsn !== undefined) {
const value = data.glitchtipDsn.trim();
if (!value) {
updates.glitchtipDsn = null;
auditDetails.glitchtipDsn = null;
} else {
updates.glitchtipDsn = new URL(value).toString();
auditDetails.glitchtipDsn = "configured";
}
}
if (data.glitchtipEnvironment !== undefined) {
const value = data.glitchtipEnvironment.trim();
updates.glitchtipEnvironment = value || null;
auditDetails.glitchtipEnvironment = value || null;
}
// Numeric thresholds
if (data.reminderLateMinutes !== undefined) {
updates.reminderLateMinutes = data.reminderLateMinutes;
auditDetails.reminderLateMinutes = data.reminderLateMinutes;
}
if (data.reminderMissedMinutes !== undefined) {
updates.reminderMissedMinutes = data.reminderMissedMinutes;
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
}
// v1.4.25 W7 — server-default timezone for new signups.
// Empty string clears the override (resolver falls back to
// Europe/Berlin); a non-empty string must pass Intl validation
// upstream of the column write.
let didTouchTimezone = false;
if (data.defaultUserTimezone !== undefined) {
const trimmed = data.defaultUserTimezone.trim();
if (trimmed === "") {
updates.defaultUserTimezone = null;
auditDetails.defaultUserTimezone = null;
didTouchTimezone = true;
} else if (isValidTimezone(trimmed)) {
updates.defaultUserTimezone = trimmed;
auditDetails.defaultUserTimezone = trimmed;
didTouchTimezone = true;
} else {
return apiError("Not a valid IANA timezone.", 422);
}
}
if (Object.keys(updates).length === 0) {
return apiError("No valid fields", 422);
}
const settings = await prisma.appSettings.upsert({
where: { id: "singleton" },
update: updates,
create: { id: "singleton", ...updates },
});
if (didTouchTimezone) {
invalidateServerDefaultTimezone();
}
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: auditDetails,
});
return apiSuccess({
registrationEnabled: settings.registrationEnabled,
mfaRequired: settings.mfaRequired,
defaultLocale: settings.defaultLocale,
telegramGlobal: settings.telegramGlobal,
ntfyGlobal: settings.ntfyGlobal,
webPushGlobal: settings.webPushGlobal,
webPushVapidPublicKey: settings.webPushVapidPublicKey,
webPushVapidSubject: settings.webPushVapidSubject,
webPushVapidConfigured: Boolean(
settings.webPushVapidPublicKey &&
settings.webPushVapidPrivateKeyEncrypted &&
settings.webPushVapidSubject,
),
apiGlobal: settings.apiGlobal,
umamiEnabled: settings.umamiEnabled,
umamiScriptUrl: settings.umamiScriptUrl,
umamiWebsiteId: settings.umamiWebsiteId,
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
defaultUserTimezone: settings.defaultUserTimezone,
});
});