From de7ee759f9751eac8d9112a4ac8cf36dda2553e9 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 6 Oct 2026 11:24:27 +0200 Subject: [PATCH] Make self-service signup opt-in via SIGNUP_ENABLED /users/signup answers 403 unless SIGNUP_ENABLED is set. A public GET /utils/config/ tells the login page, which hides the sign-up link, and the signup page explains that an administrator creates accounts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ --- .env.example | 4 +++ backend/fluksio/api/routes/users.py | 2 ++ backend/fluksio/api/routes/utils.py | 14 +++++++++++ backend/fluksio/core/config.py | 4 +++ backend/tests/api/routes/test_users.py | 34 ++++++++++++++++++++------ docs/reference/configuration.md | 1 + frontend/src/client/schemas.gen.ts | 13 ++++++++++ frontend/src/client/sdk.gen.ts | 15 +++++++++++- frontend/src/client/types.gen.ts | 9 +++++++ frontend/src/hooks/useAuth.ts | 9 +++++++ frontend/src/routes/login.tsx | 21 ++++++++++------ frontend/src/routes/signup.tsx | 27 +++++++++++++++++++- 12 files changed, 136 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index a457867..4d65eea 100644 --- a/.env.example +++ b/.env.example @@ -63,5 +63,9 @@ DOCKER_IMAGE_FRONTEND=fluksio-frontend # The MCP endpoint agents connect to, and the OAuth server behind it. MCP_ENABLED=true +# Self-service accounts from the login page. Off: a superuser adds accounts +# under Admin. +SIGNUP_ENABLED=false + # Local time for schedules; set from the root .env by scripts/setup.sh. TZ=UTC diff --git a/backend/fluksio/api/routes/users.py b/backend/fluksio/api/routes/users.py index 103e6c5..6f47d7f 100644 --- a/backend/fluksio/api/routes/users.py +++ b/backend/fluksio/api/routes/users.py @@ -154,6 +154,8 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any: """ Create new user without the need to be logged in. """ + if not settings.SIGNUP_ENABLED: + raise HTTPException(status_code=403, detail="Signup is disabled") user = crud.get_user_by_email(session=session, email=user_in.email) if user: raise HTTPException( diff --git a/backend/fluksio/api/routes/utils.py b/backend/fluksio/api/routes/utils.py index b7b23b7..99abbc7 100644 --- a/backend/fluksio/api/routes/utils.py +++ b/backend/fluksio/api/routes/utils.py @@ -3,9 +3,11 @@ from typing import Any from fastapi import APIRouter, Depends, Request, Response from fastapi.concurrency import run_in_threadpool +from pydantic import BaseModel from pydantic.networks import EmailStr from fluksio.api.deps import get_current_active_superuser +from fluksio.core.config import settings from fluksio.flow.state import RedisState from fluksio.models import Message from fluksio.utils import generate_test_email, send_email @@ -31,6 +33,18 @@ def test_email(email_to: EmailStr) -> Message: return Message(message="Test email sent") +class PublicConfig(BaseModel): + """What a browser needs to know about this instance before signing in.""" + + signup_enabled: bool + + +@router.get("/config/", response_model=PublicConfig) +def read_config() -> Any: + """The settings the login page reads. Unauthenticated, so nothing secret.""" + return PublicConfig(signup_enabled=settings.SIGNUP_ENABLED) + + @router.get("/health-check/") async def health_check() -> bool: return True diff --git a/backend/fluksio/core/config.py b/backend/fluksio/core/config.py index d58cbe5..efe77c4 100644 --- a/backend/fluksio/core/config.py +++ b/backend/fluksio/core/config.py @@ -99,6 +99,10 @@ class Settings(BaseSettings): # opt-in on top of ENVIRONMENT=local, so a deployment that merely kept the # default environment never exposes them. PRIVATE_API_ENABLED: bool = False + # Whether anyone who reaches the login page may create an account. Off + # unless asked for: an account can change what flows do, so who gets one + # is a superuser's call (Admin → Users). + SIGNUP_ENABLED: bool = False DOMAIN: str = "localhost" OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem") # Written only when someone enrols this instance with a portal. diff --git a/backend/tests/api/routes/test_users.py b/backend/tests/api/routes/test_users.py index 57cc8da..162ea35 100644 --- a/backend/tests/api/routes/test_users.py +++ b/backend/tests/api/routes/test_users.py @@ -316,15 +316,32 @@ def test_update_password_me_same_password_error( ) +def test_signup_is_off_unless_enabled(client: TestClient, db: Session) -> None: + """An instance nobody configured creates accounts only through Admin.""" + assert client.get(f"{settings.API_V1_STR}/utils/config/").json() == { + "signup_enabled": False + } + username = random_email() + data = {"email": username, "password": random_lower_string(), "full_name": "x"} + r = client.post(f"{settings.API_V1_STR}/users/signup", json=data) + assert r.status_code == 403 + assert r.json()["detail"] == "Signup is disabled" + assert db.exec(select(User).where(User.email == username)).first() is None + + def test_register_user(client: TestClient, db: Session) -> None: username = random_email() password = random_lower_string() full_name = random_lower_string() data = {"email": username, "password": password, "full_name": full_name} - r = client.post( - f"{settings.API_V1_STR}/users/signup", - json=data, - ) + with patch("fluksio.core.config.settings.SIGNUP_ENABLED", True): + assert client.get(f"{settings.API_V1_STR}/utils/config/").json() == { + "signup_enabled": True + } + r = client.post( + f"{settings.API_V1_STR}/users/signup", + json=data, + ) assert r.status_code == 200 created_user = r.json() assert created_user["email"] == username @@ -347,10 +364,11 @@ def test_register_user_already_exists_error(client: TestClient) -> None: "password": password, "full_name": full_name, } - r = client.post( - f"{settings.API_V1_STR}/users/signup", - json=data, - ) + with patch("fluksio.core.config.settings.SIGNUP_ENABLED", True): + r = client.post( + f"{settings.API_V1_STR}/users/signup", + json=data, + ) assert r.status_code == 400 assert r.json()["detail"] == "The user with this email already exists in the system" diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index bacbf23..d072ad4 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -111,6 +111,7 @@ remember it is fixed at build time: changing it means rebuilding that image. |---|---|---| | `ENVIRONMENT` | `local` | `local`, `staging` or `production` | | `PRIVATE_API_ENABLED` | `false` | unauthenticated test-only endpoints; needs `ENVIRONMENT=local` too | +| `SIGNUP_ENABLED` | `false` | lets anyone who reaches the login page create an account; otherwise a superuser adds them under **Admin** | | `TZ` | `UTC` | the timezone every schedule is written in | `TZ` is the container's own, not a setting the code reads: an `inject` or a diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index d0e4b3c..325b5a4 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -2961,6 +2961,19 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; +export const PublicConfigSchema = { + properties: { + signup_enabled: { + type: 'boolean', + title: 'Signup Enabled' + } + }, + type: 'object', + required: ['signup_enabled'], + title: 'PublicConfig', + description: 'What a browser needs to know about this instance before signing in.' +} as const; + export const RegisteredClientSchema = { properties: { id: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index 5368d5c..6e1ba58 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; +import type { AiReadAiConfigResponse, AiUpdateAiConfigData, AiUpdateAiConfigResponse, AiDeleteAiConfigResponse, AiListAiModelsData, AiListAiModelsResponse, AiReadAiStatusResponse, AiReadControlNodesResponse, AiReadChatResponse, AiChatData, AiChatResponse, AiClearChatResponse, AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsReadWebpushKeyResponse, AlertsAddWebpushSubscriptionData, AlertsAddWebpushSubscriptionResponse, AlertsRemoveWebpushSubscriptionData, AlertsRemoveWebpushSubscriptionResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlavorsReadFlavorsResponse, FlavorsCreateFlavorData, FlavorsCreateFlavorResponse, FlavorsUpdateFlavorData, FlavorsUpdateFlavorResponse, FlavorsDeleteFlavorData, FlavorsDeleteFlavorResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsImportRunsData, RunsImportRunsResponse, RunsReadRunsData, RunsReadRunsResponse, RunsDeleteRunsData, RunsDeleteRunsResponse, RunsReadOverviewResponse, RunsExportMetricsData, RunsExportMetricsResponse, RunsExportRunsData, RunsExportRunsResponse, RunsReadMetricNamesData, RunsReadMetricNamesResponse, RunsReadRunData, RunsReadRunResponse, RunsDeleteRunData, RunsDeleteRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsRetryRunData, RunsRetryRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SearchReadSearchIndexResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsReadConfigResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersReadResourcesResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen'; export class AiService { /** @@ -2769,6 +2769,19 @@ export class UtilsService { }); } + /** + * Read Config + * The settings the login page reads. Unauthenticated, so nothing secret. + * @returns PublicConfig Successful Response + * @throws ApiError + */ + public static readConfig(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/utils/config/' + }); + } + /** * Health Check * @returns boolean Successful Response diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 708f99b..96faa99 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -1006,6 +1006,13 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +/** + * What a browser needs to know about this instance before signing in. + */ +export type PublicConfig = { + signup_enabled: boolean; +}; + /** * A registered agent, and whether anyone actually let it in. */ @@ -2156,6 +2163,8 @@ export type UtilsTestEmailData = { export type UtilsTestEmailResponse = (Message); +export type UtilsReadConfigResponse = (PublicConfig); + export type UtilsHealthCheckResponse = (boolean); export type UtilsHealthResponse = ({ diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 6828fa6..0c544c6 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -7,6 +7,7 @@ import { type UserPublic, type UserRegister, UsersService, + UtilsService, } from "@/client" import { liveStore } from "@/components/Flow/liveStore" import { isPortal } from "@/lib/portal" @@ -32,6 +33,14 @@ export function safeRedirect(target: string | undefined): string { return target } +/** Whether this instance lets a visitor create an account; unknown while loading. */ +export const useSignupEnabled = (): boolean | undefined => + useQuery({ + queryKey: ["config"], + queryFn: UtilsService.readConfig, + staleTime: Number.POSITIVE_INFINITY, + }).data?.signup_enabled + const useAuth = () => { const navigate = useNavigate() const queryClient = useQueryClient() diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index b6a24fd..f4eb512 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -20,7 +20,11 @@ import { import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" import { PasswordInput } from "@/components/ui/password-input" -import useAuth, { isLoggedIn, safeRedirect } from "@/hooks/useAuth" +import useAuth, { + isLoggedIn, + safeRedirect, + useSignupEnabled, +} from "@/hooks/useAuth" const formSchema = z.object({ username: z.email(), @@ -55,6 +59,7 @@ export const Route = createFileRoute("/login")({ function Login() { const { loginMutation } = useAuth() + const signupEnabled = useSignupEnabled() const form = useForm({ resolver: zodResolver(formSchema), mode: "onBlur", @@ -132,12 +137,14 @@ function Login() { -
- Don't have an account yet?{" "} - - Sign up - -
+ {signupEnabled ? ( +
+ Don't have an account yet?{" "} + + Sign up + +
+ ) : null} diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx index be6465a..e2e681b 100644 --- a/frontend/src/routes/signup.tsx +++ b/frontend/src/routes/signup.tsx @@ -18,7 +18,7 @@ import { import { Input } from "@/components/ui/input" import { LoadingButton } from "@/components/ui/loading-button" import { PasswordInput } from "@/components/ui/password-input" -import useAuth, { isLoggedIn } from "@/hooks/useAuth" +import useAuth, { isLoggedIn, useSignupEnabled } from "@/hooks/useAuth" const formSchema = z .object({ @@ -59,6 +59,7 @@ export const Route = createFileRoute("/signup")({ function SignUp() { const { signUpMutation } = useAuth() + const signupEnabled = useSignupEnabled() const form = useForm({ resolver: zodResolver(formSchema), mode: "onBlur", @@ -79,6 +80,30 @@ function SignUp() { signUpMutation.mutate(submitData) } + if (signupEnabled === false) { + return ( + +
+
+

Sign up is off

+

+ Accounts on this instance are created by an administrator. +

+
+
+ Already have an account?{" "} + + Log in + +
+
+
+ ) + } + return (