mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
There was no way for an admin to remove someone else's account; the
users list only offered the undo for a deletion the account itself or a
Guardian had asked for.
DELETE /api/admin/users/{id} takes the account's own deletion path,
now shared as requestAccountDeletion: with a grace window the account
is locked at once (credentials revoked, record untouched) and erased by
the purge job when the window ends, and the existing undo gives it back
until then; with ACCOUNT_DELETION_GRACE_DAYS=0 it is erased in the
request. The route is cookie-only behind requireAdmin and the
recent-proof gate, takes the typed DELETE_ACCOUNT confirmation, and
refuses the caller's own account, a managed profile (its Guardians
delete it), an account already pending deletion, the last admin who
can sign in and the last Guardian of a managed profile, the last three
decided under their locks inside the transaction. Both the admin and
the account get an audit row.
The users list says who asked for a pending deletion, now including an
admin, and flags managed profiles. Each other account's row carries a
delete button whose confirmation names the purge date, or says the
erasure is immediate when the server keeps no window.
117 lines
4.6 KiB
TypeScript
117 lines
4.6 KiB
TypeScript
import { z } from "zod/v4";
|
|
import { locales } from "@/lib/i18n/config";
|
|
import { isPublicUrl } from "@/lib/validations/notifications";
|
|
|
|
/**
|
|
* Schema for admin settings update (PUT /api/admin/settings).
|
|
* All fields are optional — partial updates are supported.
|
|
*/
|
|
export const adminSettingsSchema = z
|
|
.object({
|
|
registrationEnabled: z.boolean().optional(),
|
|
// v1.23 — instance-wide "require a second factor" policy.
|
|
mfaRequired: z.boolean().optional(),
|
|
// Every shipped locale. It was the pair de/en long after the app shipped
|
|
// seven, so the admin picker could offer only two of them.
|
|
defaultLocale: z.enum(locales).optional(),
|
|
telegramGlobal: z.boolean().optional(),
|
|
ntfyGlobal: z.boolean().optional(),
|
|
webPushGlobal: z.boolean().optional(),
|
|
webPushVapidPublicKey: z.string().optional(),
|
|
webPushVapidSubject: z
|
|
.string()
|
|
.refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
|
|
message:
|
|
"webPushVapidSubject must be in mailto:address@example.com format",
|
|
})
|
|
.optional(),
|
|
webPushVapidPrivateKey: z.string().optional(),
|
|
clearWebPushVapidPrivateKey: z.boolean().optional(),
|
|
apiGlobal: z.boolean().optional(),
|
|
umamiEnabled: z.boolean().optional(),
|
|
umamiScriptUrl: z
|
|
.string()
|
|
.refine(
|
|
(v) => {
|
|
const trimmed = v.trim();
|
|
if (!trimmed) return true;
|
|
// The Umami script proxy fetches this URL server-side and
|
|
// re-serves the body from the app origin under a strict CSP
|
|
// — a private-IP target would be an SSRF channel. Match the
|
|
// posture every other admin-supplied outbound URL takes.
|
|
return isPublicUrl(trimmed);
|
|
},
|
|
{
|
|
message:
|
|
"Umami script URL must be a public HTTP(S) endpoint (no private / loopback / metadata addresses)",
|
|
},
|
|
)
|
|
.optional(),
|
|
umamiWebsiteId: z.string().optional(),
|
|
glitchtipEnabled: z.boolean().optional(),
|
|
glitchtipDsn: z
|
|
.string()
|
|
.refine(
|
|
(v) => {
|
|
const trimmed = v.trim();
|
|
if (!trimmed) return true;
|
|
// Glitchtip DSNs are `https://<key>@host/projectid`. The
|
|
// userinfo is the auth material; the SSRF surface is the
|
|
// host — same `isPublicUrl` posture as the Umami URL.
|
|
return isPublicUrl(trimmed);
|
|
},
|
|
{
|
|
message:
|
|
"Glitchtip DSN must point to a public HTTP(S) endpoint (no private / loopback / metadata addresses)",
|
|
},
|
|
)
|
|
.optional(),
|
|
glitchtipEnvironment: z.string().optional(),
|
|
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
|
|
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
|
|
// Document vault — the two admin-tunable limits. The per-file cap is
|
|
// bounded by the hard 100 MiB ceiling (single-shot GCM + bounded
|
|
// in-memory reads are load-bearing past that; the runtime resolver
|
|
// clamps again as defence-in-depth). The quota accepts up to 1 TiB —
|
|
// BYTEA rides pg_dump, so anything larger is an operator error.
|
|
documentMaxFileBytes: z
|
|
.number()
|
|
.int()
|
|
.min(1_048_576)
|
|
.max(104_857_600)
|
|
.optional(),
|
|
documentQuotaBytes: z
|
|
.number()
|
|
.int()
|
|
.min(10_485_760)
|
|
.max(1_099_511_627_776)
|
|
.optional(),
|
|
// v1.4.25 — server-wide default timezone for new signups
|
|
// that don't carry a browser-detected zone. The route validates
|
|
// against Intl.supportedValuesOf at runtime; this schema just
|
|
// guards the shape + length.
|
|
defaultUserTimezone: z.string().max(64).optional(),
|
|
// The operator's assistant switches. The master stops every AI
|
|
// capability; the sub-switches each stop the AI work they cover. The
|
|
// dedicated admin endpoint at
|
|
// /api/admin/settings/assistant-flags also accepts these via
|
|
// its own schema; this entry exists so the generic
|
|
// /api/admin/settings PUT path can still carry the values for
|
|
// operators who script their settings updates over a single
|
|
// route.
|
|
assistantEnabled: z.boolean().optional(),
|
|
assistantCoachEnabled: z.boolean().optional(),
|
|
assistantBriefingEnabled: z.boolean().optional(),
|
|
assistantInsightStatusEnabled: z.boolean().optional(),
|
|
assistantDocumentAiEnabled: z.boolean().optional(),
|
|
})
|
|
.strict();
|
|
|
|
/**
|
|
* Body of DELETE /api/admin/users/{id}: the typed confirmation the account's
|
|
* own deletion asks for, so a stray request cannot lock an account.
|
|
*/
|
|
export const adminDeleteUserSchema = z
|
|
.object({ confirm: z.literal("DELETE_ACCOUNT") })
|
|
.strict();
|