Add disable_registration toggle to site configuration

Implemented comprehensive registration control system that allows administrators to disable new user registrations through the admin panel. This provides better control over user access and supports closed registration scenarios.

Backend changes:
- Added disable_registration field to SiteConfiguration model (db.py)
- Created migration #39 to add disable_registration column with default false
- Updated SiteConfigurationBase, Update, and Read schemas
- Enhanced registration_allowed() dependency to check database toggle
- Returns 403 with clear message when registration is disabled

Frontend changes:
- Added disable_registration to SiteConfig interface and default config
- Updated LocalLoginForm to conditionally hide "Sign up" link when disabled
- Added RouteGuard support for "registration" route key
- Protected /register page with RouteGuard (shows 404 when disabled)
- Added "Registration Control" section to admin site-settings page

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ojārs Kapteinis 2025-11-18 14:28:16 +02:00
parent e79fd35a94
commit b8bb74ec3e
9 changed files with 120 additions and 26 deletions

View file

@ -0,0 +1,31 @@
"""add disable_registration to site_configuration
Revision ID: 39_add_disable_registration_to_site_configuration
Revises: 38_add_site_configuration_table
Create Date: 2025-11-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '39_add_disable_registration_to_site_configuration'
down_revision: Union[str, None] = '38_add_site_configuration_table'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add disable_registration column to site_configuration table
op.add_column(
'site_configuration',
sa.Column('disable_registration', sa.Boolean(), nullable=False, server_default='false')
)
def downgrade() -> None:
# Remove disable_registration column
op.drop_column('site_configuration', 'disable_registration')

View file

@ -2,11 +2,12 @@ from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from app.config import config
from app.db import User, create_db_and_tables, get_async_session
from app.db import SiteConfiguration, User, create_db_and_tables, get_async_session
from app.routes import router as crud_router
from app.schemas import UserCreate, UserRead, UserUpdate
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
@ -19,11 +20,24 @@ async def lifespan(app: FastAPI):
yield
def registration_allowed():
async def registration_allowed(session: AsyncSession = Depends(get_async_session)):
# Check environment variable first
if not config.REGISTRATION_ENABLED:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Registration is disabled"
status_code=status.HTTP_403_FORBIDDEN,
detail="Registration is disabled by system configuration"
)
# Check site configuration database toggle
result = await session.execute(select(SiteConfiguration).where(SiteConfiguration.id == 1))
site_config = result.scalar_one_or_none()
if site_config and site_config.disable_registration:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Registration is currently disabled. Please contact the administrator if you need access."
)
return True

View file

@ -411,6 +411,9 @@ class SiteConfiguration(Base):
disable_terms_route = Column(Boolean, nullable=False, default=True)
disable_privacy_route = Column(Boolean, nullable=False, default=True)
# Registration control
disable_registration = Column(Boolean, nullable=False, default=False)
# Custom text
custom_copyright = Column(String(200), nullable=True, default="SurfSense 2025")

View file

@ -24,6 +24,9 @@ class SiteConfigurationBase(BaseModel):
disable_terms_route: bool = True
disable_privacy_route: bool = True
# Registration control
disable_registration: bool = False
# Custom text
custom_copyright: str | None = Field(default="SurfSense 2025", max_length=200)
@ -44,6 +47,7 @@ class SiteConfigurationUpdate(SiteConfigurationBase):
disable_contact_route: bool | None = None
disable_terms_route: bool | None = None
disable_privacy_route: bool | None = None
disable_registration: bool | None = None
custom_copyright: str | None = None

View file

@ -8,12 +8,14 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { useSiteConfig } from "@/contexts/SiteConfigContext";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { ValidationError } from "@/lib/error";
export function LocalLoginForm() {
const t = useTranslations("auth");
const tCommon = useTranslations("common");
const { config } = useSiteConfig();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
@ -227,17 +229,19 @@ export function LocalLoginForm() {
</button>
</form>
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
{t("dont_have_account")}{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
{t("sign_up")}
</Link>
</p>
</div>
{!config.disable_registration && (
<div className="mt-4 text-center text-sm">
<p className="text-gray-600 dark:text-gray-400">
{t("dont_have_account")}{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
{t("sign_up")}
</Link>
</p>
</div>
)}
</div>
);
}

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { RouteGuard } from "@/components/RouteGuard";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AppError, ValidationError } from "@/lib/error";
import { AmbientBackground } from "../login/AmbientBackground";
@ -136,13 +137,14 @@ export default function RegisterPage() {
};
return (
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
{t("create_account")}
</h1>
<RouteGuard routeKey="registration">
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<h1 className="my-8 text-xl font-bold text-neutral-800 dark:text-neutral-100 md:text-4xl">
{t("create_account")}
</h1>
<div className="w-full max-w-md">
<form onSubmit={handleSubmit} className="space-y-4">
@ -296,5 +298,6 @@ export default function RegisterPage() {
</div>
</div>
</div>
</RouteGuard>
);
}

View file

@ -28,6 +28,9 @@ interface SiteConfigForm {
disable_terms_route: boolean;
disable_privacy_route: boolean;
// Registration control
disable_registration: boolean;
// Custom text
custom_copyright: string;
}
@ -49,6 +52,7 @@ export default function SiteSettingsPage() {
disable_contact_route: true,
disable_terms_route: true,
disable_privacy_route: true,
disable_registration: false,
custom_copyright: "SurfSense 2025",
});
const [isSaving, setIsSaving] = useState(false);
@ -71,6 +75,7 @@ export default function SiteSettingsPage() {
disable_contact_route: config.disable_contact_route,
disable_terms_route: config.disable_terms_route,
disable_privacy_route: config.disable_privacy_route,
disable_registration: config.disable_registration,
custom_copyright: config.custom_copyright || "SurfSense 2025",
});
}
@ -257,6 +262,26 @@ export default function SiteSettingsPage() {
</div>
</section>
{/* Registration Control Section */}
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
Registration Control
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Control user registration availability. When disabled, the Sign Up link will be hidden
and the registration page will show a 404 error. The backend will also block registration
requests with a 403 error.
</p>
<div className="space-y-3">
<ToggleSwitch
label="Disable Registration"
description="Prevent new users from creating accounts"
checked={formData.disable_registration}
onChange={() => handleToggle("disable_registration")}
/>
</div>
</section>
{/* Custom Text Section */}
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">

View file

@ -6,7 +6,7 @@ import { useSiteConfig } from "@/contexts/SiteConfigContext";
interface RouteGuardProps {
children: React.ReactNode;
routeKey: "pricing" | "docs" | "contact" | "terms" | "privacy";
routeKey: "pricing" | "docs" | "contact" | "terms" | "privacy" | "registration";
}
export function RouteGuard({ children, routeKey }: RouteGuardProps) {
@ -16,8 +16,11 @@ export function RouteGuard({ children, routeKey }: RouteGuardProps) {
useEffect(() => {
if (isLoading) return;
const disableKey = `disable_${routeKey}_route` as keyof typeof config;
const isDisabled = config[disableKey];
// Special case: registration uses disable_registration instead of disable_registration_route
const disableKey = routeKey === "registration"
? "disable_registration"
: (`disable_${routeKey}_route` as keyof typeof config);
const isDisabled = config[disableKey as keyof typeof config];
if (isDisabled) {
router.replace("/404");
@ -34,8 +37,11 @@ export function RouteGuard({ children, routeKey }: RouteGuardProps) {
}
// Check if route is disabled
const disableKey = `disable_${routeKey}_route` as keyof typeof config;
const isDisabled = config[disableKey];
// Special case: registration uses disable_registration instead of disable_registration_route
const disableKey = routeKey === "registration"
? "disable_registration"
: (`disable_${routeKey}_route` as keyof typeof config);
const isDisabled = config[disableKey as keyof typeof config];
if (isDisabled) {
return null;

View file

@ -25,6 +25,9 @@ export interface SiteConfig {
disable_terms_route: boolean;
disable_privacy_route: boolean;
// Registration control
disable_registration: boolean;
// Custom text
custom_copyright: string | null;
}
@ -44,6 +47,7 @@ const defaultConfig: SiteConfig = {
disable_contact_route: true,
disable_terms_route: true,
disable_privacy_route: true,
disable_registration: false,
custom_copyright: "SurfSense 2025",
};