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:
parent
fef3775ad4
commit
522540452c
18 changed files with 154 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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't have an account?{' '}
|
||||
<Link href="/signup" className="underline hover:text-foreground">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
{enableUserSignups && (
|
||||
<p className="text-xs text-muted">
|
||||
Don'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
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
12
src/lib/server/auth/signup-policy.ts
Normal file
12
src/lib/server/auth/signup-policy.ts
Normal 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.',
|
||||
});
|
||||
}
|
||||
|
||||
33
tests/unit/signup-policy.spec.ts
Normal file
33
tests/unit/signup-policy.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue