Files
webmail-client/frontend/src/admin/AdminApp.tsx
T
lukasandClaude Opus 5.5 463f96ad57 Invite a friend; handle field shows the domain; system mail fixes
- Invitations: each user can invite two friends by email (sign-up works
  through the link even while registration is closed); admin-editable
  template, subject and sender.
- Invitation sender defaults to the system-mail From address, which the
  SMTP account is allowed to send as (was postmaster@APP_DOMAIN, refused
  with 553 when the two differ).
- Sign-in, sign-up and forgot-password fields show the fixed
  @domain after the typed name; the registration status reports it.
- System mail gets a Date header; the invitation's plain-text part keeps
  its paragraphs and line breaks.
- SMTP EHLO uses SMTP_HELO_NAME / APP_DOMAIN, not the container name.
- DNS recommendations for the mail domains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:34:31 +02:00

991 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="min-h-screen bg-app-bg text-app-fg">
<header className="border-b border-app-border px-6 py-3">
<h1 className="font-semibold">{t("admin.title")}</h1>
</header>
{/* Two columns once there is room: instance settings and users on the
left, outgoing system mail on the right. Signed out, it stays narrow. */}
<main className={`mx-auto px-6 py-10 ${me.data?.is_admin ? "max-w-6xl" : "max-w-2xl"}`}>
{me.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
{!me.isPending && !me.data && <SignIn />}
{me.data && !me.data.is_admin && <NotAdmin handle={me.data.handle} />}
{me.data?.is_admin && (
<div className="grid gap-y-10 lg:grid-cols-2">
<div className="min-w-0 lg:pr-12">
<Panel handle={me.data.handle} />
<Users selfId={me.data.id} />
</div>
{/* The divider runs the full height of the taller column. */}
<div className="min-w-0 lg:border-l lg:border-app-border lg:pl-12">
<SystemMail />
</div>
</div>
)}
</main>
</div>
);
}
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 (
<section className="mx-auto max-w-sm">
<h2 className="mb-1 text-2xl font-semibold">{t("admin.signInTitle")}</h2>
<p className="mb-6 text-sm text-app-muted">{t("admin.signInHint")}</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
login.mutate();
}}
>
<input
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
value={handle}
onChange={(e) => setHandle(e.target.value)}
disabled={needTotp}
autoFocus
/>
<input
className={inputClass}
type="password"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={needTotp}
/>
{needTotp && (
<div className="space-y-1">
<input
className={`${inputClass} tracking-widest`}
placeholder="123456"
inputMode="numeric"
autoComplete="one-time-code"
value={totp}
onChange={(e) => setTotp(e.target.value)}
autoFocus
/>
<p className="text-xs text-app-muted">{t("auth.totpPrompt")}</p>
</div>
)}
<button
className="w-full rounded-lg bg-app-accent py-2 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={login.isPending}
type="submit"
>
{login.isPending ? t("auth.signingIn") : t("auth.signIn")}
</button>
{showError && <p className="text-sm text-red-500">{error.message}</p>}
</form>
</section>
);
}
function SignOutButton() {
const t = useT();
const queryClient = useQueryClient();
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => queryClient.setQueryData(["me"], null),
});
return (
<button
className="rounded border border-app-border px-3 py-1 text-sm text-app-muted hover:bg-app-hover"
onClick={() => logout.mutate()}
>
{t("admin.signOut")}
</button>
);
}
function NotAdmin({ handle }: { handle: string }) {
const t = useT();
return (
<section className="space-y-3">
<h2 className="text-2xl font-semibold">{t("admin.notAdminTitle")}</h2>
<p className="text-sm text-app-muted">{t("admin.notAdmin", { handle })}</p>
<SignOutButton />
</section>
);
}
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<string | null>(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 (
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm text-app-muted">{t("admin.signedInAs", { handle })}</p>
<SignOutButton />
</div>
{settings.isPending && <p className="text-sm text-app-muted">{t("common.loading")}</p>}
{settings.error instanceof ApiError && (
<p className="text-sm text-red-500">{settings.error.message}</p>
)}
{settings.data && (
<>
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.registration")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{([true, false] as const).map((value) => (
<button
key={String(value)}
disabled={save.isPending}
onClick={() => 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")}
</button>
))}
</div>
</div>
<p className="mt-1 text-xs text-app-faint">{t("admin.registrationHelp")}</p>
</div>
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.invitations")}</span>
<div className="flex gap-1 rounded-lg border border-app-border p-0.5">
{([true, false] as const).map((value) => (
<button
key={String(value)}
disabled={save.isPending}
onClick={() => 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")}
</button>
))}
</div>
</div>
<p className="mt-1 text-xs text-app-faint">{t("admin.invitationsHelp")}</p>
{settings.data.invitations_enabled && (
<button
type="button"
className="mt-2 rounded border border-app-border px-3 py-1 text-sm hover:bg-app-hover"
onClick={() => setConfiguring(true)}
>
{t("admin.inviteConfigure")}
</button>
)}
</div>
{configuring && <InvitationEmailDialog onClose={() => setConfiguring(false)} />}
<div className="border-b border-app-border py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-app-muted">{t("admin.defaultQuota")}</span>
<div className="flex items-center gap-2">
<input
className="w-28 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
type="number"
min="0.1"
step="0.5"
value={quotaValue}
onChange={(e) => setQuotaDraft(e.target.value)}
/>
<span className="text-app-muted">GiB</span>
<button
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!quotaDirty || !quotaValid || save.isPending}
onClick={() => save.mutate({ default_quota_bytes: Math.round(quotaGib * GIB) })}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
</div>
<p className="mt-1 text-xs text-app-faint">{t("admin.quotaHelp")}</p>
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
{save.error instanceof ApiError && (
<p className="mt-1 text-xs text-red-500">{save.error.message}</p>
)}
</div>
</>
)}
</section>
);
}
type MailDraft = Omit<AdminMail, "has_password" | "configured">;
/** 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<MailDraft | null>(null);
const [password, setPassword] = useState("");
const [testTo, setTestTo] = useState("");
const save = useMutation({
mutationFn: (body: Parameters<typeof api.saveAdminMail>[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 ? (
<p className="text-sm text-red-500">{mail.error.message}</p>
) : 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<MailDraft>) => 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 (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.mail")}</h2>
<p className="text-xs text-app-faint">{t("admin.mailHelp")}</p>
<p className={`text-sm ${server.configured ? "text-emerald-600" : "text-amber-600"}`}>
{server.configured ? t("admin.mailOn") : t("admin.mailOff")}
</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) });
}}
>
<div className="grid grid-cols-[1fr_6rem_8rem] gap-2">
<label className={label}>
<span className="text-app-muted">{t("admin.smtpHost")}</span>
<input
className={field}
value={value.smtp_host}
placeholder="smtp.example.com"
onChange={(e) => set({ smtp_host: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpPort")}</span>
<input
className={field}
type="number"
min="1"
max="65535"
value={value.smtp_port}
onChange={(e) => set({ smtp_port: Number(e.target.value) })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpSecurity")}</span>
<select
className={field}
value={value.smtp_security}
onChange={(e) => {
const security = e.target.value as MailDraft["smtp_security"];
// Follow the usual port for the mode, unless a custom one was typed.
const usual = { starttls: 587, ssl: 465, none: 25 };
const port = Object.values(usual).includes(value.smtp_port) ? usual[security] : value.smtp_port;
set({ smtp_security: security, smtp_port: port });
}}
>
<option value="starttls">{t("admin.smtpStarttls")}</option>
<option value="ssl">{t("admin.smtpSsl")}</option>
<option value="none">{t("admin.smtpNone")}</option>
</select>
</label>
</div>
<div className="grid grid-cols-2 gap-2">
<label className={label}>
<span className="text-app-muted">{t("admin.smtpUsername")}</span>
<input
className={field}
autoComplete="off"
value={value.smtp_username}
placeholder="postmaster@example.com"
onChange={(e) => set({ smtp_username: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpPassword")}</span>
<input
className={field}
type="password"
autoComplete="new-password"
value={password}
placeholder={server.has_password ? t("admin.smtpPasswordKept") : ""}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
</div>
<label className={label}>
<span className="text-app-muted">{t("admin.smtpFrom")}</span>
<input
className={field}
type="email"
value={value.smtp_from}
placeholder="postmaster@example.com"
onChange={(e) => set({ smtp_from: e.target.value })}
/>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.publicUrl")}</span>
<input
className={field}
value={value.public_url}
placeholder="https://mail.example.com"
onChange={(e) => set({ public_url: e.target.value })}
/>
<span className="block text-xs text-app-faint">{t("admin.publicUrlHint")}</span>
</label>
<div className="flex items-center justify-between gap-2">
{server.has_password ? (
<button
type="button"
className="text-xs text-app-muted underline hover:text-app-fg disabled:opacity-50"
disabled={save.isPending}
onClick={() => save.mutate({ smtp_password: "" })}
>
{t("admin.smtpPasswordRemove")}
</button>
) : (
<span />
)}
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!dirty || save.isPending}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
</form>
<form
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
test.mutate();
}}
>
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
<input
className={field}
type="email"
value={testTo}
placeholder="you@example.com"
onChange={(e) => {
setTestTo(e.target.value);
test.reset();
}}
/>
<button
type="submit"
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={!server.configured || !testTo.trim() || test.isPending || dirty}
>
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
</button>
</form>
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.testSent")}</p>}
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
</section>
);
}
/** "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(
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 backdrop-blur-sm sm:items-center"
onClick={onClose}
>
<div
role="dialog"
aria-label={t("admin.inviteMail")}
className="relative my-4 max-h-[calc(100vh-2rem)] w-full max-w-2xl overflow-y-auto rounded-2xl bg-app-bg p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label={t("admin.close")}
className="absolute right-4 top-4 rounded px-2 py-0.5 text-xl leading-none text-app-muted hover:bg-app-hover hover:text-app-fg"
onClick={onClose}
>
×
</button>
<InvitationEmail />
</div>
</div>,
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<string | null>(null);
const [subject, setSubject] = useState<string | null>(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 ? <p className="text-sm text-red-500">{tpl.error.message}</p> : 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 (
<section className="space-y-3">
<h2 className="text-lg font-semibold">{t("admin.inviteMail")}</h2>
<p className="text-xs text-app-faint">{t("admin.inviteMailHelp")}</p>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
...(from !== null ? { from_address: from } : {}),
...(subject !== null ? { subject } : {}),
});
}}
>
<label className={label}>
<span className="text-app-muted">{t("admin.inviteFrom")}</span>
<input
className={field}
type="email"
value={from ?? data.from_address}
placeholder={data.default_from}
onChange={(e) => setFrom(e.target.value)}
/>
<span className="block text-xs text-app-faint">{t("admin.inviteFromHint")}</span>
</label>
<label className={label}>
<span className="text-app-muted">{t("admin.inviteSubject")}</span>
<input
className={field}
value={subject ?? data.subject}
placeholder={data.default_subject}
onChange={(e) => setSubject(e.target.value)}
/>
</label>
<div className="flex justify-end">
<button
type="submit"
className="rounded bg-app-accent px-3 py-1 text-sm font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!dirty || save.isPending}
>
{save.isPending ? t("admin.saving") : t("admin.save")}
</button>
</div>
</form>
<div className="space-y-2 border-t border-app-border pt-3 text-sm">
<p className="text-app-muted">
{custom ? t("admin.inviteTemplateCustom") : t("admin.inviteTemplateDefault")}
</p>
<div className="flex flex-wrap gap-2">
<label className="cursor-pointer rounded border border-app-border px-3 py-1 hover:bg-app-hover">
{t("admin.inviteUpload")}
<input
type="file"
accept=".html,.htm,text/html"
className="hidden"
onChange={(e) => {
void upload(e.target.files?.[0]);
e.target.value = "";
}}
/>
</label>
<button type="button" className="rounded border border-app-border px-3 py-1 hover:bg-app-hover" onClick={download}>
{t("admin.inviteDownload")}
</button>
{custom && (
<button
type="button"
className="rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={save.isPending}
onClick={() => confirm(t("admin.inviteResetConfirm")) && save.mutate({ html: "" })}
>
{t("admin.inviteReset")}
</button>
)}
</div>
<p className="text-xs text-app-faint">
{t("admin.invitePlaceholders")}{" "}
{data.placeholders.map((p, i) => (
<span key={p}>
{i > 0 && ", "}
<code className="select-all rounded bg-app-hover px-1">{`{{${p}}}`}</code>
</span>
))}
</p>
<p className="text-xs text-app-faint">{t("admin.invitePlaceholdersHelp")}</p>
{save.error instanceof ApiError && <p className="text-xs text-red-500">{save.error.message}</p>}
</div>
{preview.data && (
<div className="space-y-1">
<p className="text-xs text-app-muted">
{t("admin.invitePreview")} <span className="font-medium text-app-fg">{preview.data.subject}</span>
</p>
{/* Sandboxed, script-less: the admin's own HTML, shown as mail. */}
<iframe
title={t("admin.invitePreview")}
sandbox=""
srcDoc={preview.data.html}
className="h-80 w-full rounded-lg border border-app-border bg-white"
/>
</div>
)}
<form
className="flex items-center gap-2 border-t border-app-border pt-3 text-sm"
onSubmit={(e) => {
e.preventDefault();
test.mutate();
}}
>
<span className="shrink-0 text-app-muted">{t("admin.testTo")}</span>
<input
className={field}
type="email"
value={testTo}
placeholder="you@example.com"
onChange={(e) => {
setTestTo(e.target.value);
test.reset();
}}
/>
<button
type="submit"
className="shrink-0 rounded border border-app-border px-3 py-1 hover:bg-app-hover disabled:opacity-50"
disabled={!testTo.trim() || test.isPending || dirty}
>
{test.isPending ? t("auth.sending") : t("admin.sendTest")}
</button>
</form>
{test.isSuccess && <p className="text-xs text-emerald-600">{t("admin.inviteTestSent")}</p>}
{test.error instanceof ApiError && <p className="text-xs text-red-500">{test.error.message}</p>}
</section>
);
}
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<string | null>(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 (
<section className="mt-10 space-y-3">
<h2 className="text-lg font-semibold">{t("admin.users")}</h2>
<input
className={inputClass}
type="search"
placeholder={t("admin.userSearch")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{users.error instanceof ApiError && (
<p className="text-sm text-red-500">{users.error.message}</p>
)}
{results && results.length === 0 && (
<p className="text-sm text-app-muted">{t("admin.noUsers")}</p>
)}
{results && results.length > 0 && (
<ul className="divide-y divide-app-border rounded-lg border border-app-border">
{results.map((u) => (
<li key={u.id}>
<button
type="button"
onClick={() => setOpenId(u.id)}
className="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-app-hover"
>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{u.handle}</span>
<Badges user={u} self={u.id === selfId} />
</span>
<span className="block truncate text-xs text-app-muted">
{u.accounts.length ? u.accounts.join(", ") : t("admin.noMailAccounts")}
</span>
</span>
<span className="shrink-0 text-xs tabular-nums text-app-muted">
{t("admin.usage", {
used: formatBytes(u.used_bytes),
quota: formatBytes(u.quota_bytes),
})}
</span>
</button>
</li>
))}
</ul>
)}
{open && <UserDialog user={open} self={open.id === selfId} onClose={() => setOpenId(null)} />}
</section>
);
}
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 && <span className={`${badge} bg-app-hover text-app-muted`}>{t("admin.you")}</span>}
{user.is_admin && (
<span className={`${badge} bg-app-accent/15 text-app-accent`}>{t("admin.adminBadge")}</span>
)}
{user.blocked_at && (
<span className={`${badge} bg-red-500/15 text-red-500`}>{t("admin.blockedBadge")}</span>
)}
</>
);
}
/** 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<string | null>(null);
const [confirm, setConfirm] = useState<null | "block" | "delete">(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 (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div
role="dialog"
aria-modal="true"
aria-label={user.handle}
className="relative w-full max-w-md rounded-xl border border-app-border bg-app-elevated p-5 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h3 className="flex items-center gap-1.5 font-semibold">
<span className="truncate">{user.handle}</span>
<Badges user={user} self={self} />
</h3>
<p className="mt-0.5 text-xs text-app-muted">
{user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")}
</p>
</div>
<button
type="button"
onClick={onClose}
title={t("admin.close")}
aria-label={t("admin.close")}
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
<div className="border-y border-app-border py-3">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="text-app-muted">{t("admin.userQuota")}</span>
<div className="flex items-center gap-2">
<input
className="w-24 rounded border border-app-border bg-app-surface px-2 py-1 text-right focus:border-app-accent focus:outline-none"
type="number"
min="0.1"
step="0.5"
value={quotaValue}
onChange={(e) => setQuotaDraft(e.target.value)}
/>
<span className="text-app-muted">GiB</span>
<button
className="rounded bg-app-accent px-3 py-1 font-medium text-app-accent-fg hover:opacity-90 disabled:opacity-50"
disabled={!quotaDirty || !quotaValid || busy}
onClick={() => update.mutate({ quota_bytes: Math.round(quotaGib * GIB) })}
>
{update.isPending && quotaDirty ? t("admin.saving") : t("admin.save")}
</button>
</div>
</div>
<p className="mt-1 text-xs text-app-faint">
{t("admin.usage", {
used: formatBytes(user.used_bytes),
quota: formatBytes(user.quota_bytes),
})}
</p>
{!quotaValid && <p className="mt-1 text-xs text-red-500">{t("admin.quotaInvalid")}</p>}
</div>
{user.blocked_at && (
<p className="mt-3 text-sm text-red-500">
{t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })}
</p>
)}
{self ? (
<p className="mt-3 text-xs text-app-muted">{t("admin.selfNote")}</p>
) : (
<div className="mt-4 flex flex-wrap justify-end gap-2">
{user.blocked_at ? (
<button
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
disabled={busy}
onClick={() => update.mutate({ blocked: false })}
>
{t("admin.unblock")}
</button>
) : (
<button
className="rounded-lg border border-app-border px-3 py-1.5 text-sm hover:bg-app-hover disabled:opacity-50"
disabled={busy}
onClick={() => setConfirm("block")}
>
{t("admin.block")}
</button>
)}
<button
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={busy}
onClick={() => setConfirm("delete")}
>
{t("admin.deleteUser")}
</button>
</div>
)}
{error && <p className="mt-2 text-xs text-red-500">{error.message}</p>}
{confirm && (
<div
className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/40 p-4"
onClick={() => setConfirm(null)}
>
<div
role="alertdialog"
aria-modal="true"
className="w-full max-w-sm rounded-lg border border-app-border bg-app-elevated p-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<p className="mb-4 text-sm">
{t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", {
handle: user.handle,
})}
</p>
<div className="flex justify-end gap-2">
<button
className="rounded-md px-3 py-1.5 text-sm text-app-muted hover:bg-app-hover"
onClick={() => setConfirm(null)}
autoFocus
>
{t("admin.cancel")}
</button>
<button
className="rounded-md bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
disabled={busy}
onClick={() =>
confirm === "block" ? update.mutate({ blocked: true }) : remove.mutate()
}
>
{t(confirm === "block" ? "admin.block" : "admin.deleteUser")}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}