diff --git a/surfsense_backend/alembic/versions/39_add_disable_registration_to_site_configuration.py b/surfsense_backend/alembic/versions/39_add_disable_registration_to_site_configuration.py new file mode 100644 index 000000000..5a9130ea2 --- /dev/null +++ b/surfsense_backend/alembic/versions/39_add_disable_registration_to_site_configuration.py @@ -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') diff --git a/surfsense_backend/app/app.py b/surfsense_backend/app/app.py index 3fc0b14f7..7925a8945 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -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 diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 12d07e5fe..f67d06215 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -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") diff --git a/surfsense_backend/app/schemas/site_configuration.py b/surfsense_backend/app/schemas/site_configuration.py index ca790931b..48329335d 100644 --- a/surfsense_backend/app/schemas/site_configuration.py +++ b/surfsense_backend/app/schemas/site_configuration.py @@ -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 diff --git a/surfsense_web/app/(home)/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx index 5bb4cbc40..b3d7d146c 100644 --- a/surfsense_web/app/(home)/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -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() { -
-

- {t("dont_have_account")}{" "} - - {t("sign_up")} - -

-
+ {!config.disable_registration && ( +
+

+ {t("dont_have_account")}{" "} + + {t("sign_up")} + +

+
+ )} ); } diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 58a729630..4bb97dbac 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -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 ( -
- -
- -

- {t("create_account")} -

+ +
+ +
+ +

+ {t("create_account")} +

@@ -296,5 +298,6 @@ export default function RegisterPage() {
+
); } diff --git a/surfsense_web/app/dashboard/site-settings/page.tsx b/surfsense_web/app/dashboard/site-settings/page.tsx index 4e157f3a3..787acf863 100644 --- a/surfsense_web/app/dashboard/site-settings/page.tsx +++ b/surfsense_web/app/dashboard/site-settings/page.tsx @@ -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() {
+ {/* Registration Control Section */} +
+

+ Registration Control +

+

+ 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. +

+
+ handleToggle("disable_registration")} + /> +
+
+ {/* Custom Text Section */}

diff --git a/surfsense_web/components/RouteGuard.tsx b/surfsense_web/components/RouteGuard.tsx index bbde9d1fc..f7f1ed83e 100644 --- a/surfsense_web/components/RouteGuard.tsx +++ b/surfsense_web/components/RouteGuard.tsx @@ -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; diff --git a/surfsense_web/contexts/SiteConfigContext.tsx b/surfsense_web/contexts/SiteConfigContext.tsx index 6364cd94b..7d4a63a74 100644 --- a/surfsense_web/contexts/SiteConfigContext.tsx +++ b/surfsense_web/contexts/SiteConfigContext.tsx @@ -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", };