import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useT } from "../i18n"; import { api, ApiError } from "../lib/api"; import { useMe } from "../lib/auth"; 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")}

{me.isPending &&

{t("common.loading")}

} {!me.isPending && !me.data && } {me.data && !me.data.is_admin && } {me.data?.is_admin && }
); } 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); 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.defaultQuota")}
setQuotaDraft(e.target.value)} /> GiB

{t("admin.quotaHelp")}

{!quotaValid &&

{t("admin.quotaInvalid")}

} {save.error instanceof ApiError && (

{save.error.message}

)}
)}
); }