238 lines
8.8 KiB
TypeScript
238 lines
8.8 KiB
TypeScript
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 (
|
||
|
|
<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>
|
||
|
|
<main className="mx-auto max-w-2xl px-6 py-10">
|
||
|
|
{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 && <Panel handle={me.data.handle} />}
|
||
|
|
</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);
|
||
|
|
|
||
|
|
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.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>
|
||
|
|
);
|
||
|
|
}
|