feat(auth): add runtime toggle for user sign-ups and enforce signup policy

Introduce `enableUserSignups` runtime setting to allow administrators to control
whether new accounts can be created. Update environment variable and documentation
references to support this feature. UI elements for account creation are now
conditionally rendered based on this flag. Signup attempts are blocked server-side
when disabled, including email, OAuth, and anonymous upgrades.

Add `assertUserSignupAllowed` utility for consistent enforcement and corresponding
unit tests to verify policy behavior.
This commit is contained in:
Richard R 2026-05-14 12:47:05 -06:00
parent fef3775ad4
commit 522540452c
18 changed files with 154 additions and 27 deletions

View file

@ -90,9 +90,10 @@ FFMPEG_BIN=
# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true
# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true
# NEXT_PUBLIC_ENABLE_USER_SIGNUPS=true
# NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true
# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai
# NEXT_PUBLIC_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
# NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro
# NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false
# NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=true

View file

@ -66,6 +66,7 @@ Runtime-editable settings, one row per key:
| --- | --- |
| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
| `changelogFeedUrl` | Public changelog manifest URL used by the Settings modal changelog panel. |
| `enableUserSignups` | Controls whether new accounts can be created. Existing accounts can still sign in when this is `false`. |
| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |

View file

@ -31,7 +31,7 @@ ADMIN_EMAILS=alice@example.com,bob@example.com
Admins see a new **Admin** tab in **Settings** with two sub-tabs:
- **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users.
- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (default TTS provider/model, word highlighting, audiobook export, etc.).
- **Site features** — runtime overrides for what were previously `NEXT_PUBLIC_*` build-time flags (including account signup availability, default TTS provider/model, word highlighting, audiobook export, etc.).
Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference.

View file

@ -53,6 +53,7 @@ After the first successful deploy and admin login, open **Settings → Admin** a
- `enableDocxConversion=false` on Vercel (`soffice` unavailable).
- `enableDestructiveDeleteActions=false` for safer public deployments.
- `enableTtsProvidersTab=false` if you want shared-provider-only UX.
- `enableUserSignups=true` unless you explicitly want an invite-only deployment.
- `restrictUserApiKeys=true` to block user BYOK through the hosted server.
- `defaultTtsProvider=replicate` (or your preferred shared slug).
- `showAllProviderModels=false` if you want users locked to each provider's default model.

View file

@ -18,6 +18,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers |
| `NEXT_PUBLIC_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features |
| `NEXT_PUBLIC_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features |
| `NEXT_PUBLIC_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features |
| `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size |
| `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL |
| `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx |
@ -387,6 +388,15 @@ Controls whether the **TTS Provider** section appears in the user-facing Setting
- Set `false` to hide provider/model/API controls in the per-user Settings modal (the admin panel is unaffected).
- Runtime key: `enableTtsProvidersTab`
### NEXT_PUBLIC_ENABLE_USER_SIGNUPS
Controls whether new user accounts can be created.
- Default: `true` (enabled)
- When `false`, new account creation is blocked for email sign-up, first-time OAuth signup, and anonymous-to-account upgrades.
- Existing users can still sign in.
- Runtime key: `enableUserSignups`
### NEXT_PUBLIC_RESTRICT_USER_API_KEYS
Controls whether users can supply personal API keys/base URLs for built-in providers.

View file

@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
@ -31,6 +32,7 @@ function SignInContent() {
const [sessionExpired, setSessionExpired] = useState(false);
const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig();
const enableUserSignups = useFeatureFlag('enableUserSignups');
const { refresh: refreshRateLimit } = useAuthRateLimit();
const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
@ -240,12 +242,14 @@ function SignInContent() {
{/* Footer */}
<div className="mt-6 pt-4 border-t border-offbase text-center space-y-2">
<p className="text-xs text-muted">
Don&apos;t have an account?{' '}
<Link href="/signup" className="underline hover:text-foreground">
Sign up
</Link>
</p>
{enableUserSignups && (
<p className="text-xs text-muted">
Don&apos;t have an account?{' '}
<Link href="/signup" className="underline hover:text-foreground">
Sign up
</Link>
</p>
)}
<p className="text-xs text-muted">
By signing in, you agree to our{' '}
<button

View file

@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { getAuthClient } from '@/lib/client/auth-client';
import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
@ -20,6 +21,7 @@ export default function SignUpPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl } = useAuthConfig();
const enableUserSignups = useFeatureFlag('enableUserSignups');
const { refresh: refreshRateLimit } = useAuthRateLimit();
// Check if auth is enabled, redirect home if not
@ -47,6 +49,10 @@ export default function SignUpPage() {
const handleSignUp = async () => {
setError(null);
if (!enableUserSignups) {
setError('New account sign-ups are currently disabled by the site administrator.');
return;
}
if (!email.trim() || !validateEmail(email)) {
setError('Please enter a valid email address');
@ -107,6 +113,27 @@ export default function SignUpPage() {
return null;
}
if (!enableUserSignups) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
<div className="w-full max-w-md bg-base rounded-2xl shadow-xl p-6">
<h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1>
<p className="text-sm text-muted mt-1">
New account sign-ups are currently disabled by the site administrator.
</p>
<div className="mt-6 pt-4 border-t border-offbase text-center">
<p className="text-xs text-muted">
Already have an account?{' '}
<Link href="/signin" className="underline hover:text-foreground">
Sign in
</Link>
</p>
</div>
</div>
</div>
);
}
const { checks, strength } = validatePassword(password);
const strengthLabels = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'];
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];

View file

@ -1,7 +1,11 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
export default async function PublicLayout({ children }: { children: ReactNode }) {
const runtimeConfig = await getResolvedRuntimeConfig();
const enableUserSignups = runtimeConfig.enableUserSignups;
export default function PublicLayout({ children }: { children: ReactNode }) {
return (
<>
<style>{`
@ -279,7 +283,7 @@ export default function PublicLayout({ children }: { children: ReactNode }) {
<nav className="landing-header-nav">
<Link href="/app" className="landing-primary">Open App</Link>
<Link href="/signin">Sign In</Link>
<Link href="/signup">Sign Up</Link>
{enableUserSignups && <Link href="/signup">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/">Docs</Link>
</nav>
</header>

View file

@ -1,5 +1,6 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
export const metadata: Metadata = {
title: 'Read and Listen to Documents',
@ -40,7 +41,10 @@ export const metadata: Metadata = {
},
};
export default function LandingPage() {
export default async function LandingPage() {
const runtimeConfig = await getResolvedRuntimeConfig();
const enableUserSignups = runtimeConfig.enableUserSignups;
return (
<>
<style>{`
@ -342,7 +346,7 @@ export default function LandingPage() {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4" /></svg>
</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
</div>
</section>
@ -448,7 +452,7 @@ export default function LandingPage() {
<div className="landing-cta-actions">
<Link href="/app" className="landing-btn landing-btn-accent">Open App</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
<Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
</div>
</div>

View file

@ -422,7 +422,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const client = getAuthClient(authBaseUrl);
await client.signOut();
window.location.href = '/signup';
window.location.href = runtimeConfig.enableUserSignups ? '/signup' : '/signin';
} catch (error) {
console.error('Failed to delete account:', error);
}
@ -1305,8 +1305,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="pt-2 border-t border-offbase">
<p className="text-sm text-muted mb-3">
{session?.user?.isAnonymous
? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.'
: 'No active session. Please sign in or create an account.'}
? (runtimeConfig.enableUserSignups
? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.'
: 'You are using an anonymous session. New account sign-ups are currently disabled by the site administrator.')
: (runtimeConfig.enableUserSignups
? 'No active session. Please sign in or create an account.'
: 'No active session. Please sign in.')}
</p>
<div className="flex flex-wrap gap-2">
<Link href="/signin">
@ -1314,11 +1318,13 @@ export function SettingsModal({ className = '' }: { className?: string }) {
Connect
</Button>
</Link>
<Link href="/signup">
<Button className={buttonClass({ variant: 'primary', size: 'md', className: 'hover:scale-[1.04]' })}>
Create account
</Button>
</Link>
{runtimeConfig.enableUserSignups && (
<Link href="/signup">
<Button className={buttonClass({ variant: 'primary', size: 'md', className: 'hover:scale-[1.04]' })}>
Create account
</Button>
</Link>
)}
<Link href="/?redirect=false">
<Button className={buttonClass({ variant: 'outline', size: 'md', className: 'hover:scale-[1.04]' })}>
Back to landing page

View file

@ -295,6 +295,14 @@ export function AdminFeaturesPanel() {
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
/>
</div>
<ToggleRow
label="Allow new account sign-ups"
description="When off, new accounts cannot be created. Existing accounts can still sign in."
checked={Boolean(draft.enableUserSignups)}
onChange={(checked) => updateDraft('enableUserSignups', checked)}
right={renderSource('enableUserSignups')}
variant="flat"
/>
<ToggleRow
label="Word-level highlighting"
description="Highlight words during TTS playback."

View file

@ -1,6 +1,7 @@
'use client';
import { useAuthRateLimit, formatCharCount } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import Link from 'next/link';
interface RateLimitBannerProps {
@ -9,6 +10,7 @@ interface RateLimitBannerProps {
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit, timeUntilReset, authEnabled } = useAuthRateLimit();
const enableUserSignups = useFeatureFlag('enableUserSignups');
// Don't show banner if auth is not enabled or if not at limit
if (!authEnabled || !status?.authEnabled || !isAtLimit) {
@ -30,7 +32,7 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
</span>
</div>
{isAnonymous && (
{isAnonymous && enableUserSignups && (
<Link
href="/signup"
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md

View file

@ -3,12 +3,14 @@
import { Button } from '@headlessui/react';
import Link from 'next/link';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client';
import { useRouter } from 'next/navigation';
export function UserMenu({ className = '' }: { className?: string }) {
const { authEnabled, baseUrl } = useAuthConfig();
const enableUserSignups = useFeatureFlag('enableUserSignups');
const { data: session, isPending } = useAuthSession();
const router = useRouter();
@ -28,11 +30,13 @@ export function UserMenu({ className = '' }: { className?: string }) {
Connect
</Button>
</Link>
<Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]">
Create account
</Button>
</Link>
{enableUserSignups && (
<Link href="/signup">
<Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]">
Create account
</Button>
</Link>
)}
</div>
);
}

View file

@ -15,6 +15,7 @@ export interface RuntimeConfig {
defaultTtsProvider: string;
changelogFeedUrl: string;
appVersion: string;
enableUserSignups: boolean;
restrictUserApiKeys: boolean;
enableTtsProvidersTab: boolean;
enableWordHighlight: boolean;
@ -28,6 +29,7 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
defaultTtsProvider: 'custom-openai',
changelogFeedUrl: 'https://docs.openreader.richardr.dev/changelog/manifest.json',
appVersion: '0.0.0',
enableUserSignups: true,
restrictUserApiKeys: true,
enableTtsProvidersTab: true,
enableWordHighlight: true,

View file

@ -77,6 +77,7 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
export const RUNTIME_CONFIG_SCHEMA = {
defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'),
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'NEXT_PUBLIC_CHANGELOG_FEED_URL'),
enableUserSignups: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_USER_SIGNUPS'),
restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'),
// Historically the env semantics were "true unless explicitly 'false'",
// i.e. the feature defaults to ON.

View file

@ -7,6 +7,8 @@ import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config";
import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync";
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy';
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
@ -99,6 +101,11 @@ const createAuth = () => betterAuth({
user: {
create: {
before: async (user) => {
const runtimeConfig = await getResolvedRuntimeConfig();
assertUserSignupAllowed({
enableUserSignups: runtimeConfig.enableUserSignups,
isAnonymous: Boolean((user as { isAnonymous?: boolean }).isAnonymous),
});
// Stamp newly-created users with the correct isAdmin value if their
// email matches ADMIN_EMAILS. This avoids a follow-up UPDATE on
// first signup. The `input: false` above prevents clients from

View file

@ -0,0 +1,12 @@
import { APIError } from 'better-auth/api';
export function assertUserSignupAllowed(input: {
enableUserSignups: boolean;
isAnonymous?: boolean;
}): void {
if (input.enableUserSignups || input.isAnonymous) return;
throw new APIError('BAD_REQUEST', {
message: 'New account sign-ups are disabled by the site administrator.',
});
}

View file

@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
import { RUNTIME_CONFIG_SCHEMA } from '../../src/lib/server/admin/settings';
import { assertUserSignupAllowed } from '../../src/lib/server/auth/signup-policy';
test.describe('enableUserSignups runtime config', () => {
test('defaults to enabled', () => {
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.default).toBe(true);
});
test('parses first-boot env seed values', () => {
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('true')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('false')).toBe(false);
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('1')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('0')).toBe(false);
});
});
test.describe('signup policy enforcement', () => {
test('allows new non-anonymous users when signups are enabled', () => {
expect(() => assertUserSignupAllowed({ enableUserSignups: true, isAnonymous: false })).not.toThrow();
});
test('blocks new non-anonymous users when signups are disabled', () => {
expect(() => assertUserSignupAllowed({ enableUserSignups: false, isAnonymous: false })).toThrow(
/sign-ups are disabled/i,
);
});
test('does not block anonymous-session user creation when signups are disabled', () => {
expect(() => assertUserSignupAllowed({ enableUserSignups: false, isAnonymous: true })).not.toThrow();
});
});