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 (
{/* 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")}
);
}
function SignOutButton() {
const t = useT();
const queryClient = useQueryClient();
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => queryClient.setQueryData(["me"], null),
});
return (
logout.mutate()}
>
{t("admin.signOut")}
);
}
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) => (
save.mutate({ registration_open: value })}
className={`rounded px-3 py-1 disabled:opacity-50 ${
open === value
? "bg-app-active font-medium text-app-fg"
: "text-app-muted hover:bg-app-hover"
}`}
>
{t(value ? "admin.registrationOn" : "admin.registrationOff")}
))}
{t("admin.registrationHelp")}
{t("admin.invitations")}
{([true, false] as const).map((value) => (
save.mutate({ invitations_enabled: value })}
className={`rounded px-3 py-1 disabled:opacity-50 ${
settings.data.invitations_enabled === value
? "bg-app-active font-medium text-app-fg"
: "text-app-muted hover:bg-app-hover"
}`}
>
{t(value ? "admin.invitationsOn" : "admin.invitationsOff")}
))}
{t("admin.invitationsHelp")}
{settings.data.invitations_enabled && (
setConfiguring(true)}
>
{t("admin.inviteConfigure")}
)}
{configuring && setConfiguring(false)} />}
{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")}
{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(
,
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")}
{custom ? t("admin.inviteTemplateCustom") : t("admin.inviteTemplateDefault")}
{t("admin.inviteUpload")}
{
void upload(e.target.files?.[0]);
e.target.value = "";
}}
/>
{t("admin.inviteDownload")}
{custom && (
confirm(t("admin.inviteResetConfirm")) && save.mutate({ html: "" })}
>
{t("admin.inviteReset")}
)}
{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. */}
)}
{test.isSuccess && {t("admin.inviteTestSent")}
}
{test.error instanceof ApiError && {test.error.message}
}
);
}
function formatBytes(bytes: number): string {
if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`;
return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`;
}
/** Find a user by login, mail address or name, and open them to manage. */
function Users({ selfId }: { selfId: string }) {
const t = useT();
const [term, setTerm] = useState("");
const [query, setQuery] = useState("");
const [openId, setOpenId] = useState(null);
// Search as you type, but not on every keystroke.
useEffect(() => {
const id = window.setTimeout(() => setQuery(term.trim()), 250);
return () => window.clearTimeout(id);
}, [term]);
// Only search results are listed: nothing until something is typed.
const users = useQuery({
queryKey: ["adminUsers", query],
queryFn: () => api.adminUsers(query),
enabled: query !== "",
placeholderData: keepPreviousData,
});
const results = query ? users.data : undefined;
const open = results?.find((u) => u.id === openId) ?? null;
return (
);
}
function Badges({ user, self }: { user: AdminUser; self: boolean }) {
const t = useT();
const badge = "shrink-0 rounded px-1.5 text-[11px] font-medium";
return (
<>
{self && {t("admin.you")} }
{user.is_admin && (
{t("admin.adminBadge")}
)}
{user.blocked_at && (
{t("admin.blockedBadge")}
)}
>
);
}
/** One user: change their quota, block or unblock them, or delete them. */
function UserDialog({
user,
self,
onClose,
}: {
user: AdminUser;
self: boolean;
onClose: () => void;
}) {
const t = useT();
const queryClient = useQueryClient();
const [quotaDraft, setQuotaDraft] = useState(null);
const [confirm, setConfirm] = useState(null);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["adminUsers"] });
const update = useMutation({
mutationFn: (body: { quota_bytes?: number; blocked?: boolean }) =>
api.updateAdminUser(user.id, body),
onSuccess: () => {
setQuotaDraft(null);
setConfirm(null);
void refresh();
},
});
const remove = useMutation({
mutationFn: () => api.deleteAdminUser(user.id),
onSuccess: () => {
onClose();
void refresh();
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (confirm) setConfirm(null);
else onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [confirm, onClose]);
const serverGib = user.quota_bytes / GIB;
const quotaValue = quotaDraft ?? String(Number(serverGib.toFixed(3)));
const quotaGib = Number(quotaValue);
const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0;
const quotaDirty = quotaDraft !== null && quotaGib !== serverGib;
const busy = update.isPending || remove.isPending;
const error = [update.error, remove.error].find((e) => e instanceof ApiError) as
| ApiError
| undefined;
return (
e.stopPropagation()}
>
{user.handle}
{user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")}
{t("admin.usage", {
used: formatBytes(user.used_bytes),
quota: formatBytes(user.quota_bytes),
})}
{!quotaValid &&
{t("admin.quotaInvalid")}
}
{user.blocked_at && (
{t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })}
)}
{self ? (
{t("admin.selfNote")}
) : (
{user.blocked_at ? (
update.mutate({ blocked: false })}
>
{t("admin.unblock")}
) : (
setConfirm("block")}
>
{t("admin.block")}
)}
setConfirm("delete")}
>
{t("admin.deleteUser")}
)}
{error &&
{error.message}
}
{confirm && (
setConfirm(null)}
>
e.stopPropagation()}
>
{t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", {
handle: user.handle,
})}
setConfirm(null)}
autoFocus
>
{t("admin.cancel")}
confirm === "block" ? update.mutate({ blocked: true }) : remove.mutate()
}
>
{t(confirm === "block" ? "admin.block" : "admin.deleteUser")}
)}
);
}