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:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||
<title>Webmail admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/admin/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,237 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Entry point for the ADMIN app — a separate bundle from the mail client, served
|
||||
// only on nginx's admin listener (docker/nginx.conf :81 -> ADMIN_PORT). It shares
|
||||
// the API client, i18n and theme, but nothing of the mail UI: no router, no
|
||||
// plugins, no mailbox state. See docs/nas-install.md §5.1.
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { I18nProvider } from "../i18n";
|
||||
import { ThemeProvider } from "../lib/theme";
|
||||
import { AdminApp } from "./AdminApp";
|
||||
import "../index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AdminApp />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -36,6 +36,13 @@ export const en = {
|
||||
"auth.noAccount": "No account?",
|
||||
"auth.haveAccount": "Already have an account?",
|
||||
"auth.register": "Register",
|
||||
"auth.checking": "Checking…",
|
||||
"auth.firstUserTitle": "Create the administrator account",
|
||||
"auth.firstUserHint":
|
||||
"This is the first account on this server, so it becomes the administrator. Registration closes automatically once it exists.",
|
||||
"auth.registrationClosedTitle": "Registration is closed",
|
||||
"auth.registrationClosed":
|
||||
"This server is not accepting new accounts. Ask the administrator to open registration.",
|
||||
|
||||
"nav.mail": "Mail",
|
||||
"nav.back": "Back",
|
||||
@@ -399,6 +406,27 @@ export const en = {
|
||||
"settings.addToCalendar": "Add to calendar",
|
||||
"settings.alsoFile": "Also mark the message as read and file it",
|
||||
"settings.integrations": "Integrations",
|
||||
|
||||
"admin.title": "Webmail administration",
|
||||
"admin.signInTitle": "Administrator sign-in",
|
||||
"admin.signInHint": "Sign in with an administrator account to manage this server.",
|
||||
"admin.signOut": "Sign out",
|
||||
"admin.signedInAs": "Signed in as {handle}",
|
||||
"admin.notAdminTitle": "Not an administrator",
|
||||
"admin.notAdmin":
|
||||
"{handle} is signed in, but does not have administrator rights on this server.",
|
||||
"admin.registration": "New user registration",
|
||||
"admin.registrationOn": "Open",
|
||||
"admin.registrationOff": "Closed",
|
||||
"admin.registrationHelp":
|
||||
"While closed, the signup page is hidden and the server refuses new accounts.",
|
||||
"admin.defaultQuota": "Default quota for new users",
|
||||
"admin.quotaHelp":
|
||||
"Applies to accounts created from now on. Existing mailboxes keep the quota they were given.",
|
||||
"admin.save": "Save",
|
||||
"admin.saving": "Saving…",
|
||||
"admin.saved": "Saved",
|
||||
"admin.quotaInvalid": "Enter a size greater than 0.",
|
||||
"settings.jitsi": "Jitsi Meet",
|
||||
"settings.jitsiHint":
|
||||
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import type {
|
||||
AccountDetail,
|
||||
AdminSettings,
|
||||
AccountOut,
|
||||
AttachmentOut,
|
||||
CalendarOut,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
MailboxOut,
|
||||
QuotaOut,
|
||||
RecipientSuggestion,
|
||||
RegistrationStatus,
|
||||
RemoteDraftOut,
|
||||
SavedSearch,
|
||||
SearchOut,
|
||||
@@ -77,6 +79,11 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
}
|
||||
|
||||
export const api = {
|
||||
adminSettings: () => apiFetch<AdminSettings>("/admin/settings"),
|
||||
// PUT merges: send only the fields you are changing.
|
||||
saveAdminSettings: (body: Partial<AdminSettings>) =>
|
||||
apiFetch<AdminSettings>("/admin/settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||
registrationStatus: () => apiFetch<RegistrationStatus>("/auth/registration"),
|
||||
register: (localpart: string, password: string) =>
|
||||
apiFetch<UserOut>("/auth/register", {
|
||||
method: "POST",
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface UserOut {
|
||||
recovery_email: string | null;
|
||||
settings: Record<string, unknown>;
|
||||
totp_enabled: boolean;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
registration_open: boolean;
|
||||
/** Quota given to users created from now on; existing mailboxes keep theirs. */
|
||||
default_quota_bytes: number;
|
||||
}
|
||||
|
||||
export interface RegistrationStatus {
|
||||
open: boolean;
|
||||
/** No users yet: the next signup creates the administrator. */
|
||||
bootstrap: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadSummary {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
content: ["./index.html", "./admin.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
/// <reference types="vitest" />
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// SPEC dev-environment §5: Vite dev proxy /api -> :8000 with ws:true for /api/ws.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
// Two apps, one dist: the mail client (index.html) and the admin panel
|
||||
// (admin.html). nginx serves each from its own listener — see
|
||||
// docker/nginx.conf. In dev, the panel is at /admin.html.
|
||||
input: {
|
||||
main: resolve(__dirname, "index.html"),
|
||||
admin: resolve(__dirname, "admin.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user