feat(auth): ALLOW_SELF_REGISTER toggle + public /auth/config endpoint (0.42.0)

Lets operators run a closed-membership deployment by setting
ALLOW_SELF_REGISTER=false (default true, so existing deploys are
unaffected). When off, POST /auth/register returns 403 forbidden. The
rate-limit token is consumed BEFORE the disabled check so the timing
doesn't distinguish enabled-but-rejected from disabled — closes the
toggle-state probe channel.

New public GET /auth/config returns { self_register_enabled: bool }
so the frontend can render its register affordances correctly
without conflating "disabled" with "rate-limited" (which a probe
attempt would).

Frontend: a lightweight reactive `authConfig` store loads the flag
once on root-layout mount (and again on /register direct navigation,
which bypasses the layout's onMount). Header hides the Register link
when the toggle is off; /register renders a "self-registration is
disabled — ask an administrator" notice instead of the form.

Admin-create endpoint that pairs with this toggle is intentionally
not in this PR — it lands as the next branch (feat/admin-user-create).
The toggle alone is independently useful for deployments that want
to lock down enrollment without yet wiring an admin UI.
This commit is contained in:
MechaCat02
2026-05-31 13:56:18 +02:00
parent 6dd21451a8
commit 2f47faa11c
12 changed files with 182 additions and 5 deletions

View File

@@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { logout } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte';
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
@@ -21,6 +22,7 @@
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
if (!authConfig.loaded) authConfig.load();
// Publish the header's measured height as a CSS custom
// property so sticky descendants (e.g. the reader nav) can
@@ -115,7 +117,9 @@
</button>
{:else}
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
{#if authConfig.self_register_enabled}
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
{/if}
{/if}
</div>
</header>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { register } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte';
import { session } from '$lib/session.svelte';
let username = $state('');
@@ -8,6 +10,13 @@
let error: string | null = $state(null);
let submitting = $state(false);
// Direct navigation to /register bypasses the root layout's
// onMount — re-trigger the config load here so the disabled state
// renders correctly even when this is the first page hit.
onMount(() => {
if (!authConfig.loaded) authConfig.load();
});
async function submit(e: SubmitEvent) {
e.preventDefault();
error = null;
@@ -25,6 +34,12 @@
</script>
<h1>Register</h1>
{#if authConfig.loaded && !authConfig.self_register_enabled}
<p class="notice" role="status" data-testid="register-disabled">
Self-registration is disabled on this server. Ask an administrator to
create an account for you.
</p>
{:else}
<form onsubmit={submit} action="javascript:void(0)" data-testid="register-form">
<label class="form-field">
<span>Username</span>
@@ -56,6 +71,7 @@
<p class="form-error" role="alert" data-testid="register-error">{error}</p>
{/if}
</form>
{/if}
<p class="hint">
Already have an account? <a href="/login">Log in</a>.
</p>
@@ -90,4 +106,14 @@
color: var(--text-muted);
font-size: var(--font-sm);
}
.notice {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-elevated);
color: var(--text);
max-width: 32rem;
margin: 0;
}
</style>