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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-06 11:24:27 +02:00
co-authored by Claude Opus 5.5
parent 6e18ab96ce
commit de7ee759f9
12 changed files with 136 additions and 17 deletions
+4
View File
@@ -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
+2
View File
@@ -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(
+14
View File
@@ -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
+4
View File
@@ -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.
+26 -8
View File
@@ -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"
+1
View File
@@ -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
+13
View File
@@ -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: {
File diff suppressed because one or more lines are too long
+9
View File
@@ -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 = ({
+9
View File
@@ -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()
+14 -7
View File
@@ -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<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
@@ -132,12 +137,14 @@ function Login() {
</LoadingButton>
</div>
<div className="text-center text-sm">
Don't have an account yet?{" "}
<RouterLink to="/signup" className="underline underline-offset-4">
Sign up
</RouterLink>
</div>
{signupEnabled ? (
<div className="text-center text-sm">
Don't have an account yet?{" "}
<RouterLink to="/signup" className="underline underline-offset-4">
Sign up
</RouterLink>
</div>
) : null}
</form>
</Form>
</AuthLayout>
+26 -1
View File
@@ -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<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
@@ -79,6 +80,30 @@ function SignUp() {
signUpMutation.mutate(submitData)
}
if (signupEnabled === false) {
return (
<AuthLayout>
<div
className="flex flex-col gap-6 text-center"
data-testid="signup-disabled"
>
<div className="flex flex-col items-center gap-2">
<h1 className="text-2xl font-bold">Sign up is off</h1>
<p className="text-sm text-muted-foreground">
Accounts on this instance are created by an administrator.
</p>
</div>
<div className="text-sm">
Already have an account?{" "}
<RouterLink to="/login" className="underline underline-offset-4">
Log in
</RouterLink>
</div>
</div>
</AuthLayout>
)
}
return (
<AuthLayout>
<Form {...form}>