Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion apps/backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@ class OAuthLinkedIdentity(CamelModel):

class OAuthLinkedIdentitiesResponse(CamelModel):
identities: list[OAuthLinkedIdentity]


class RegistrationPolicyResponse(CamelModel):
mode: Literal["open", "approval"]
10 changes: 10 additions & 0 deletions apps/backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
2 changes: 2 additions & 0 deletions apps/backend/tests/test_openapi_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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},
Expand Down
14 changes: 12 additions & 2 deletions apps/frontend/src/app/pages/RegisterPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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();
});

Expand All @@ -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:<RegisterPage/>}],{initialEntries:['/register']});
render(<RouterProvider router={router}/>);
expect(await screen.findByText(/development install allows any email/i)).toBeInTheDocument();
expect(screen.queryByText(/becomes its owner/i)).not.toBeInTheDocument();
});
20 changes: 14 additions & 6 deletions apps/frontend/src/app/pages/RegisterPage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
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';
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 (
Expand All @@ -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. */}
<p className="mt-1.5 text-2xs text-muted-foreground">
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.'}
</p>
</div>
<div>
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/shared/services/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { components } from './generated';
import { api } from './client';
import type { RequestConfig } from './client';
import type {
Expand All @@ -14,6 +15,10 @@ import type {
} from './types';

export const authService = {
registrationPolicy(config?: RequestConfig): Promise<components['schemas']['RegistrationPolicyResponse']> {
return api.get('/auth/registration-policy', config);
},

register(request: RegisterRequest, config?: RequestConfig): Promise<AuthResponse> {
return api.post('/auth/register', request, config);
},
Expand Down
85 changes: 85 additions & 0 deletions apps/frontend/src/shared/services/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1919,6 +1936,14 @@ export interface components {
/** Password */
password: string;
};
/** RegistrationPolicyResponse */
RegistrationPolicyResponse: {
/**
* Mode
* @enum {string}
*/
mode: "open" | "approval";
};
/** RepositoryFileResponse */
RepositoryFileResponse: {
/** Content */
Expand Down Expand Up @@ -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;
Expand Down
Loading