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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = ({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user