mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
246 lines
8.1 KiB
TypeScript
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,
|
|
});
|
|
});
|