import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useT } from "../i18n"; import { api, ApiError } from "../lib/api"; import { useMe } from "../lib/auth"; import type { AdminMail, AdminUser } from "../lib/types"; const GIB = 1024 ** 3; const inputClass = "w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"; /** * The admin application. Three states: signed out (login), signed in without the * administrator flag (refused), signed in as an administrator (the panel). * * The login here is the ordinary session login — same cookie, same endpoint as * the mail client. What makes this a separate thing is that the bundle is served * only on the admin listener, and every action it performs hits /api/admin/*, * which the public listener refuses outright. */ export function AdminApp() { const t = useT(); const me = useMe(); return (

{t("admin.title")}

{/* Two columns once there is room: instance settings and users on the left, outgoing system mail on the right. Signed out, it stays narrow. */}
{me.isPending &&

{t("common.loading")}

} {!me.isPending && !me.data && } {me.data && !me.data.is_admin && } {me.data?.is_admin && (
{/* The divider runs the full height of the taller column. */}
)}
); } function SignIn() { const t = useT(); const queryClient = useQueryClient(); const [handle, setHandle] = useState(""); const [password, setPassword] = useState(""); const [totp, setTotp] = useState(""); const [needTotp, setNeedTotp] = useState(false); const login = useMutation({ mutationFn: () => api.login(handle, password, needTotp ? totp : undefined), onSuccess: (user) => queryClient.setQueryData(["me"], user), onError: (error) => { if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) { setNeedTotp(true); } }, }); const error = login.error instanceof ApiError ? login.error : null; const showError = error && !(needTotp && error.code === "totp_required"); return (

{t("admin.signInTitle")}

{t("admin.signInHint")}

{ e.preventDefault(); login.mutate(); }} > setHandle(e.target.value)} disabled={needTotp} autoFocus /> setPassword(e.target.value)} disabled={needTotp} /> {needTotp && (
setTotp(e.target.value)} autoFocus />

{t("auth.totpPrompt")}

)} {showError &&

{error.message}

}
); } function SignOutButton() { const t = useT(); const queryClient = useQueryClient(); const logout = useMutation({ mutationFn: api.logout, onSuccess: () => queryClient.setQueryData(["me"], null), }); return ( ); } function NotAdmin({ handle }: { handle: string }) { const t = useT(); return (

{t("admin.notAdminTitle")}

{t("admin.notAdmin", { handle })}

); } function Panel({ handle }: { handle: string }) { const t = useT(); const queryClient = useQueryClient(); const settings = useQuery({ queryKey: ["adminSettings"], queryFn: api.adminSettings }); // The quota field is a draft until Save — unlike the toggle, you do not want a // write per keystroke. `null` means "not edited yet, show the server's value". const [quotaDraft, setQuotaDraft] = useState(null); // The invitation email settings open in a dialog of their own. const [configuring, setConfiguring] = useState(false); const save = useMutation({ mutationFn: api.saveAdminSettings, onSuccess: (data) => { queryClient.setQueryData(["adminSettings"], data); setQuotaDraft(null); }, }); const open = settings.data?.registration_open ?? false; const serverQuotaGib = settings.data ? settings.data.default_quota_bytes / GIB : 0; const quotaValue = quotaDraft ?? String(Number(serverQuotaGib.toFixed(3))); const quotaGib = Number(quotaValue); const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0; const quotaDirty = quotaDraft !== null && quotaGib !== serverQuotaGib; return (

{t("admin.signedInAs", { handle })}

{settings.isPending &&

{t("common.loading")}

} {settings.error instanceof ApiError && (

{settings.error.message}

)} {settings.data && ( <>
{t("admin.registration")}
{([true, false] as const).map((value) => ( ))}

{t("admin.registrationHelp")}

{t("admin.invitations")}
{([true, false] as const).map((value) => ( ))}

{t("admin.invitationsHelp")}

{settings.data.invitations_enabled && ( )}
{configuring && setConfiguring(false)} />}
{t("admin.defaultQuota")}
setQuotaDraft(e.target.value)} /> GiB

{t("admin.quotaHelp")}

{!quotaValid &&

{t("admin.quotaInvalid")}

} {save.error instanceof ApiError && (

{save.error.message}

)}
)}
); } type MailDraft = Omit; /** The SMTP account the server sends password-reset links from. */ function SystemMail() { const t = useT(); const queryClient = useQueryClient(); const mail = useQuery({ queryKey: ["adminMail"], queryFn: api.adminMail }); // Edits are a draft until Save; null means "show what the server has". const [draft, setDraft] = useState(null); const [password, setPassword] = useState(""); const [testTo, setTestTo] = useState(""); const save = useMutation({ mutationFn: (body: Parameters[0]) => api.saveAdminMail(body), onSuccess: (data) => { queryClient.setQueryData(["adminMail"], data); setDraft(null); setPassword(""); }, }); const test = useMutation({ mutationFn: () => api.testAdminMail(testTo.trim()) }); if (!mail.data) { return mail.error instanceof ApiError ? (

{mail.error.message}

) : null; } const server = mail.data; const value: MailDraft = draft ?? { smtp_host: server.smtp_host, smtp_port: server.smtp_port, smtp_security: server.smtp_security, smtp_username: server.smtp_username, smtp_from: server.smtp_from, public_url: server.public_url, }; const set = (patch: Partial) => setDraft({ ...value, ...patch }); const dirty = draft !== null || password !== ""; const field = "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none"; const label = "block space-y-1 text-sm"; return (

{t("admin.mail")}

{t("admin.mailHelp")}

{server.configured ? t("admin.mailOn") : t("admin.mailOff")}

{ e.preventDefault(); save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) }); }} >
{server.has_password ? ( ) : ( )}
{save.error instanceof ApiError &&

{save.error.message}

}
{ e.preventDefault(); test.mutate(); }} > {t("admin.testTo")} { setTestTo(e.target.value); test.reset(); }} />
{test.isSuccess &&

{t("admin.testSent")}

} {test.error instanceof ApiError &&

{test.error.message}

}
); } /** "Configure" under Allow invitations: the invitation email settings in a dialog. */ function InvitationEmailDialog({ onClose }: { onClose: () => void }) { const t = useT(); useEffect(() => { const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [onClose]); return createPortal(
e.stopPropagation()} >
, document.body, ); } /** The invitation email: sender, subject and an uploaded HTML template with * placeholders; previewed with sample names, and sent as a test. */ function InvitationEmail() { const t = useT(); const queryClient = useQueryClient(); const tpl = useQuery({ queryKey: ["adminInviteTemplate"], queryFn: api.adminInviteTemplate }); // Sender and subject are drafts until Save; null shows the server's value. const [from, setFrom] = useState(null); const [subject, setSubject] = useState(null); const [testTo, setTestTo] = useState(""); const save = useMutation({ mutationFn: api.saveAdminInviteTemplate, onSuccess: (data) => { queryClient.setQueryData(["adminInviteTemplate"], data); setFrom(null); setSubject(null); }, }); const test = useMutation({ mutationFn: () => api.testInvite(testTo.trim()) }); const data = tpl.data; const preview = useQuery({ queryKey: ["adminInvitePreview", data?.subject, data?.html], queryFn: () => api.previewInvite(data?.subject ?? "", data?.html ?? ""), enabled: !!data, }); if (!data) { return tpl.error instanceof ApiError ?

{tpl.error.message}

: null; } const custom = data.html !== ""; const dirty = (from !== null && from !== data.from_address) || (subject !== null && subject !== data.subject); const field = "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none"; const label = "block space-y-1 text-sm"; const upload = async (file: File | undefined) => { if (!file) return; save.mutate({ html: await file.text() }); }; const download = () => { const blob = new Blob([data.html || data.default_html], { type: "text/html" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = custom ? "invitation.html" : "invitation-default.html"; a.click(); URL.revokeObjectURL(a.href); }; return (

{t("admin.inviteMail")}

{t("admin.inviteMailHelp")}

{ e.preventDefault(); save.mutate({ ...(from !== null ? { from_address: from } : {}), ...(subject !== null ? { subject } : {}), }); }} >

{custom ? t("admin.inviteTemplateCustom") : t("admin.inviteTemplateDefault")}

{custom && ( )}

{t("admin.invitePlaceholders")}{" "} {data.placeholders.map((p, i) => ( {i > 0 && ", "} {`{{${p}}}`} ))}

{t("admin.invitePlaceholdersHelp")}

{save.error instanceof ApiError &&

{save.error.message}

}
{preview.data && (

{t("admin.invitePreview")} {preview.data.subject}

{/* Sandboxed, script-less: the admin's own HTML, shown as mail. */}