- 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>
991 lines
38 KiB
TypeScript
991 lines
38 KiB
TypeScript
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>
|
||
);
|
||
}
|