Admin panel on its own port, and a registration gate

Three strands that only make sense together, so they land together.

Registration gate (migration 0016). POST /auth/register was open to anyone
who could reach the port. Now the first account on an empty instance is
always allowed, becomes the administrator, and closes registration behind
itself; after that it takes an admin reopening it. The decision is made
under a row lock on app_settings so two simultaneous first signups cannot
both see an empty table and both claim administrator. Existing installs
keep their oldest account as admin and start closed.

Instance default quota (migration 0017). users.quota_bytes is snapshotted
at creation from app_settings.default_quota_bytes, so changing the default
affects later accounts only and never resizes a mailbox that exists.

The panel is a separate app, not a page in the mail client. admin.html is
its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the
vendor chunk), with its own administrator sign-in. One nginx now serves two
listeners: :80 is the mail client and 404s both /admin.html and
/api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT
and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not
process isolation — CurrentAdmin still guards every admin route — so
ADMIN_PORT must be restricted to the LAN, which the runbook now covers for
both DSM and QNAP.

Also QNAP: deploy/webmail.sh finds Container Station's docker under
/share/*/.qpkg/container-station/bin, which is off an SSH session's PATH,
before anything shells out to it. docs/nas-install.md gains a QuTS hero
runbook (§2.6), splits the DSM firewall section into container-to-container
(§8.1) and the egress case that makes account-add time out while
autodiscovery appears to work (§8.2), and scopes both to Synology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
2026-09-16 20:34:08 +02:00
co-authored by Claude Opus 5
parent cc63efa968
commit 8fce86559e
27 changed files with 1409 additions and 125 deletions
+11 -7
View File
@@ -1,4 +1,4 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -13,6 +13,8 @@ export function Login() {
const [needTotp, setNeedTotp] = useState(false);
const navigate = useNavigate();
const queryClient = useQueryClient();
// Only offer the signup link when the server would actually accept one.
const registration = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
const mutation = useMutation({
mutationFn: () => api.login(handle, password, needTotp ? totp : undefined),
@@ -80,12 +82,14 @@ export function Login() {
</button>
{showError && <p className="text-sm text-red-500">{error.message}</p>}
</form>
<p className="mt-4 text-sm text-app-muted">
{t("auth.noAccount")}{" "}
<Link className="text-app-accent" to="/register">
{t("auth.register")}
</Link>
</p>
{registration.data?.open && (
<p className="mt-4 text-sm text-app-muted">
{t("auth.noAccount")}{" "}
<Link className="text-app-accent" to="/register">
{t("auth.register")}
</Link>
</p>
)}
</div>
);
}
+51 -13
View File
@@ -1,26 +1,69 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useT } from "../i18n";
import { api, ApiError } from "../lib/api";
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";
export function Register() {
const t = useT();
const [localpart, setLocalpart] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();
const queryClient = useQueryClient();
// Gates the whole page: an instance with users and registration closed offers
// no form at all. The server enforces this too — this is only the UI.
const status = useQuery({ queryKey: ["registration"], queryFn: api.registrationStatus });
const mutation = useMutation({
mutationFn: () => api.register(localpart, password),
onSuccess: (user) => {
queryClient.setQueryData(["me"], user);
// The first signup closes registration behind itself; drop the cached answer.
void queryClient.invalidateQueries({ queryKey: ["registration"] });
navigate("/mail");
},
});
const signInLink = (
<p className="mt-4 text-sm text-app-muted">
{t("auth.haveAccount")}{" "}
<Link className="text-app-accent" to="/login">
{t("auth.signIn")}
</Link>
</p>
);
if (status.isPending) {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<p className="text-sm text-app-muted">{t("auth.checking")}</p>
</div>
);
}
if (status.data && !status.data.open) {
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<h1 className="mb-3 text-2xl font-semibold">{t("auth.registrationClosedTitle")}</h1>
<p className="text-sm text-app-muted">{t("auth.registrationClosed")}</p>
{signInLink}
</div>
);
}
const bootstrap = status.data?.bootstrap ?? false;
return (
<div className="mx-auto mt-24 max-w-sm px-4">
<h1 className="mb-6 text-2xl font-semibold">Create account</h1>
<h1 className="mb-3 text-2xl font-semibold">
{bootstrap ? t("auth.firstUserTitle") : t("auth.registerTitle")}
</h1>
{bootstrap && <p className="mb-5 text-sm text-app-muted">{t("auth.firstUserHint")}</p>}
<form
className="space-y-3"
onSubmit={(e) => {
@@ -29,16 +72,16 @@ export function Register() {
}}
>
<input
className="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"
placeholder="handle (3–30 chars, a-z 0-9 . _ -)"
className={inputClass}
placeholder={t("auth.handlePlaceholder")}
value={localpart}
onChange={(e) => setLocalpart(e.target.value)}
autoFocus
/>
<input
className="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"
className={inputClass}
type="password"
placeholder="password (min 8 chars)"
placeholder={t("auth.password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
@@ -47,18 +90,13 @@ export function Register() {
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending ? "Creating…" : "Create account"}
{mutation.isPending ? t("auth.creating") : t("auth.create")}
</button>
{mutation.error instanceof ApiError && (
<p className="text-sm text-red-500">{mutation.error.message}</p>
)}
</form>
<p className="mt-4 text-sm text-app-muted">
Already have an account?{" "}
<Link className="text-app-accent" to="/login">
Sign in
</Link>
</p>
{signInLink}
</div>
);
}