diff --git a/apps/backend/app/api/routes/auth.py b/apps/backend/app/api/routes/auth.py index 244f1fe8..173cc68d 100644 --- a/apps/backend/app/api/routes/auth.py +++ b/apps/backend/app/api/routes/auth.py @@ -8,7 +8,14 @@ from app.core.config import Settings, get_settings from app.core.exceptions import UnauthorizedError from app.models.user import User -from app.schemas.auth import AccountDeletionRequest, AuthResponse, LoginRequest, RegisterRequest, UserResponse +from app.schemas.auth import ( + AccountDeletionRequest, + AuthResponse, + LoginRequest, + RegisterRequest, + RegistrationPolicyResponse, + UserResponse, +) from app.services.account_deletion_service import AccountDeletionService router = APIRouter(prefix="/auth", tags=["auth"]) @@ -51,6 +58,18 @@ def _set_refresh_cookie(response: Response, raw_token: str, settings: Settings) ) +@router.get( + "/registration-policy", + response_model=RegistrationPolicyResponse, + responses=documented_responses( + 200, "Installation registration mode; no account or bootstrap state.", {"mode": "approval"}, 429, 500 + ), + openapi_extra=suppress_automatic_validation_error(), +) +def registration_policy(settings: Settings = Depends(get_settings)) -> RegistrationPolicyResponse: + return RegistrationPolicyResponse(mode="open" if settings.app_env == "development" else "approval") + + @router.post( "/register", response_model=AuthResponse, diff --git a/apps/backend/app/schemas/auth.py b/apps/backend/app/schemas/auth.py index e1bc1d5f..82d4a8b4 100644 --- a/apps/backend/app/schemas/auth.py +++ b/apps/backend/app/schemas/auth.py @@ -73,3 +73,7 @@ class OAuthLinkedIdentity(CamelModel): class OAuthLinkedIdentitiesResponse(CamelModel): identities: list[OAuthLinkedIdentity] + + +class RegistrationPolicyResponse(CamelModel): + mode: Literal["open", "approval"] diff --git a/apps/backend/tests/test_auth.py b/apps/backend/tests/test_auth.py index e1baf511..9c0590dd 100644 --- a/apps/backend/tests/test_auth.py +++ b/apps/backend/tests/test_auth.py @@ -578,3 +578,13 @@ def test_anonymous_repository_access_is_rejected(client): # an empty seed-user view. response = client.get("/repositories") assert response.status_code == 401 + + +def test_registration_policy_is_public_and_exposes_only_installation_mode(client, monkeypatch): + from app.core.config import get_settings + + for environment, expected in [("development", "open"), ("test", "approval"), ("production", "approval")]: + monkeypatch.setattr(get_settings(), "app_env", environment) + response = client.get("/auth/registration-policy") + assert response.status_code == 200 + assert response.json() == {"mode": expected} diff --git a/apps/backend/tests/test_openapi_contract.py b/apps/backend/tests/test_openapi_contract.py index 910d72c8..95207180 100644 --- a/apps/backend/tests/test_openapi_contract.py +++ b/apps/backend/tests/test_openapi_contract.py @@ -12,6 +12,7 @@ PUBLIC_OPERATIONS = { + ("GET", "/auth/registration-policy"), ("POST", "/auth/register"), ("POST", "/auth/login"), # Logout is deliberately idempotent when its refresh cookie is absent. @@ -37,6 +38,7 @@ COOKIE_REQUIRED_OPERATIONS = {("POST", "/auth/refresh")} EXPECTED_RESPONSES = { + ("GET", "/auth/registration-policy"): {200, 429, 500}, ("POST", "/auth/register"): {201, 409, 422, 429, 500}, ("POST", "/waitlist"): {201, 422, 429, 500}, ("POST", "/auth/login"): {200, 401, 422, 429, 500}, diff --git a/apps/frontend/src/app/pages/RegisterPage.test.tsx b/apps/frontend/src/app/pages/RegisterPage.test.tsx index ad36c598..49eba94a 100644 --- a/apps/frontend/src/app/pages/RegisterPage.test.tsx +++ b/apps/frontend/src/app/pages/RegisterPage.test.tsx @@ -10,6 +10,7 @@ import { ApiError } from '@/shared/services/api/errors'; describe('RegisterPage (#374 approved-email allowlist)', () => { beforeEach(() => { vi.restoreAllMocks(); + vi.spyOn(authService, 'registrationPolicy').mockResolvedValue({mode:'approval'}); }); function renderPage() { @@ -27,10 +28,10 @@ describe('RegisterPage (#374 approved-email allowlist)', () => { expect(screen.getByLabelText('Password')).toBeInTheDocument(); }); - it('explains who approves access on a self-hosted install', () => { + it('explains who approves access on a self-hosted install', async () => { renderPage(); - expect(screen.getByText(/first account on a new install becomes its owner/i)).toBeInTheDocument(); + expect(await screen.findByText(/new emails must be approved/i)).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Get in touch' })).not.toBeInTheDocument(); }); @@ -56,3 +57,12 @@ describe('RegisterPage (#374 approved-email allowlist)', () => { expect(await screen.findByRole('alert')).toHaveTextContent("hasn't been approved"); }); }); + + +it('describes open development registration without an owner privilege claim', async () => { + vi.spyOn(authService, 'registrationPolicy').mockResolvedValue({mode:'open'}); + const router = createMemoryRouter([{path:'/register',element:}],{initialEntries:['/register']}); + render(); + expect(await screen.findByText(/development install allows any email/i)).toBeInTheDocument(); + expect(screen.queryByText(/becomes its owner/i)).not.toBeInTheDocument(); +}); diff --git a/apps/frontend/src/app/pages/RegisterPage.tsx b/apps/frontend/src/app/pages/RegisterPage.tsx index c412dda8..9befd2e7 100644 --- a/apps/frontend/src/app/pages/RegisterPage.tsx +++ b/apps/frontend/src/app/pages/RegisterPage.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from 'react'; +import { authService } from '@/shared/services/api'; import { Link } from 'react-router-dom'; import { Loader2 } from 'lucide-react'; import { PASSWORD_MIN_LENGTH, useRegisterForm } from '@/features/auth/hooks/useRegisterForm'; @@ -5,6 +7,13 @@ import { AuthShell } from '@/shared/components/layout/AuthShell'; import { PasswordInput } from '@/shared/components/ui/PasswordInput'; export function RegisterPage() { + const [registrationMode, setRegistrationMode] = useState<'open' | 'approval' | null>(null); + useEffect(() => { + const controller = new AbortController(); + authService.registrationPolicy({signal:controller.signal}).then(policy => setRegistrationMode(policy.mode)).catch(() => {}); + return () => controller.abort(); + }, []); + const { email, setEmail, password, setPassword, submitting, error, submit, redirectState } = useRegisterForm(); return ( @@ -26,13 +35,12 @@ export function RegisterPage() { onChange={(event) => setEmail(event.target.value)} className="partha-input h-[46px] w-full px-4 text-sm" /> - {/* #374: registration is now gated by an admin-approved email - allowlist, not an invite code -- an unapproved email surfaces - as the `error` alert below once submitted, but this stays - visible up front so it isn't a surprise. */}

- The first account on a new install becomes its owner. After that, each new email must be approved - by whoever runs this install. + {registrationMode === 'open' + ? 'This development install allows any email to register. Registration does not verify email ownership.' + : registrationMode === 'approval' + ? 'After the initial setup account, new emails must be approved by whoever runs this install. Setup does not grant administrator privileges.' + : 'Registration depends on this installation: development permits any email; other environments require approval after initial setup.'}

diff --git a/apps/frontend/src/shared/services/api/auth.ts b/apps/frontend/src/shared/services/api/auth.ts index ad7678d6..8bee6597 100644 --- a/apps/frontend/src/shared/services/api/auth.ts +++ b/apps/frontend/src/shared/services/api/auth.ts @@ -1,3 +1,4 @@ +import type { components } from './generated'; import { api } from './client'; import type { RequestConfig } from './client'; import type { @@ -14,6 +15,10 @@ import type { } from './types'; export const authService = { + registrationPolicy(config?: RequestConfig): Promise { + return api.get('/auth/registration-policy', config); + }, + register(request: RegisterRequest, config?: RequestConfig): Promise { return api.post('/auth/register', request, config); }, diff --git a/apps/frontend/src/shared/services/api/generated.ts b/apps/frontend/src/shared/services/api/generated.ts index 3a53adf1..2ae40db9 100644 --- a/apps/frontend/src/shared/services/api/generated.ts +++ b/apps/frontend/src/shared/services/api/generated.ts @@ -518,6 +518,23 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/registration-policy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Registration Policy */ + get: operations["registration_policy_auth_registration_policy_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/documentation/generate": { parameters: { query?: never; @@ -1919,6 +1936,14 @@ export interface components { /** Password */ password: string; }; + /** RegistrationPolicyResponse */ + RegistrationPolicyResponse: { + /** + * Mode + * @enum {string} + */ + mode: "open" | "approval"; + }; /** RepositoryFileResponse */ RepositoryFileResponse: { /** Content */ @@ -6356,6 +6381,66 @@ export interface operations { }; }; }; + registration_policy_auth_registration_policy_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Installation registration mode; no account or bootstrap state. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "mode": "approval" + * } + */ + "application/json": components["schemas"]["RegistrationPolicyResponse"]; + }; + }; + /** @description The request-rate limit has been exceeded. */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "rate_limited", + * "message": "Too many requests. Try again shortly.", + * "details": { + * "retryAfterSeconds": 30 + * }, + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description An unexpected server error occurred. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "code": "internal_server_error", + * "message": "An unexpected error occurred.", + * "request_id": "req_01HXYZEXAMPLE" + * } + */ + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; generate_documentation_documentation_generate_post: { parameters: { query?: never;