diff --git a/backend/alembic/versions/2026-08-24_e7b2c94a63d1_username_changes_enabled.py b/backend/alembic/versions/2026-08-24_e7b2c94a63d1_username_changes_enabled.py new file mode 100644 index 0000000..e6f6429 --- /dev/null +++ b/backend/alembic/versions/2026-08-24_e7b2c94a63d1_username_changes_enabled.py @@ -0,0 +1,38 @@ +"""username_changes_enabled — site toggle for self-service renames (#298) + +Adds ``site_settings.username_changes_enabled``: whether accounts may rename +themselves from the profile page. Server default TRUE, so existing installs +keep today's behaviour and nothing is backfilled. Off gates only the +self-service route — an admin rename (manage_users) is unaffected. + +Revision ID: e7b2c94a63d1 +Revises: d1a6b83f47c2 +Create Date: 2026-08-24 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "e7b2c94a63d1" +down_revision = "d1a6b83f47c2" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("site_settings") as batch: + batch.add_column( + sa.Column( + "username_changes_enabled", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("site_settings") as batch: + batch.drop_column("username_changes_enabled") diff --git a/backend/models/site_settings.py b/backend/models/site_settings.py index 9c4ef0b..4fb4179 100644 --- a/backend/models/site_settings.py +++ b/backend/models/site_settings.py @@ -93,6 +93,12 @@ class SiteSettings(Base, TimestampMixin): registration_open: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1" ) + # Whether accounts may rename themselves from the profile page (#298). Off + # pins usernames to what was provisioned — only the self-service path is + # gated; manage_users holders can always rename from Admin → Users. + username_changes_enabled: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="1" + ) # Outbound SMTP for the send_email automation action (§5.3). When smtp_host # is set these override the env config; unset = fall back to env (or, if that # too is unset, email is a logged no-op). diff --git a/backend/routers/auth.py b/backend/routers/auth.py index e0c4a81..0855f21 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -536,6 +536,16 @@ async def change_username( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts — please wait a few minutes and try again", ) + # Site policy gate (#298), before the password check so a disabled endpoint + # never verifies passwords. Only this self-service path is governed — an + # admin rename (manage_users, routers/users.py) is how a provisioned name + # gets fixed while renames are off. + site = await db.get(SiteSettings, SITE_SETTINGS_ID) + if site is not None and not site.username_changes_enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Username changes are disabled on this platform", + ) if not verify_password(body.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/backend/routers/site_settings.py b/backend/routers/site_settings.py index 1019797..521441d 100644 --- a/backend/routers/site_settings.py +++ b/backend/routers/site_settings.py @@ -372,6 +372,7 @@ def _operational_out( ) -> OperationalSettingsOut: return OperationalSettingsOut( registration_open=settings.registration_open, + username_changes_enabled=settings.username_changes_enabled, smtp_host=settings.smtp_host, smtp_port=settings.smtp_port, smtp_username=settings.smtp_username, @@ -423,6 +424,9 @@ async def update_operational_settings( ) settings = await get_or_create_settings(db) settings.registration_open = body.registration_open + # Omitted = unchanged (#298), the update_checks_enabled pattern below. + if body.username_changes_enabled is not None: + settings.username_changes_enabled = body.username_changes_enabled settings.smtp_host = body.smtp_host or None settings.smtp_port = body.smtp_port settings.smtp_username = body.smtp_username or None diff --git a/backend/schemas/site_settings.py b/backend/schemas/site_settings.py index 99b79c8..99f866b 100644 --- a/backend/schemas/site_settings.py +++ b/backend/schemas/site_settings.py @@ -90,6 +90,10 @@ class SiteSettingsOut(BaseModel): # (issue #74). Public so the join button / profile banner can explain a # 403 without a round-trip to admin-only settings. email_verification_enabled: bool = False + # Whether accounts may rename themselves (#298). Public so the profile page + # can hide its username card without an admin-only round-trip; a policy + # bit, not infrastructure detail. + username_changes_enabled: bool = True class SiteSettingsUpdate(BaseModel): @@ -143,6 +147,8 @@ class OperationalSettingsOut(BaseModel): model_config = ConfigDict(from_attributes=True) registration_open: bool + # Self-service rename toggle (#298); admin rename is never gated by it. + username_changes_enabled: bool smtp_host: str | None smtp_port: int smtp_username: str | None @@ -191,6 +197,11 @@ class BackupImportRequest(BaseModel): class OperationalSettingsUpdate(BaseModel): registration_open: bool + # Self-service rename toggle (#298). **Omitted / null = leave unchanged**, + # the update_checks_enabled pattern below: this PUT replaces the whole + # object, and a `= True` default would let a scripted client that tweaks + # SMTP and omits this field silently re-enable renames an admin turned off. + username_changes_enabled: bool | None = None smtp_host: str | None = Field(default=None, max_length=255) smtp_port: int = Field(default=587, ge=1, le=65535) smtp_username: str | None = Field(default=None, max_length=255) diff --git a/backend/tests/test_site_settings.py b/backend/tests/test_site_settings.py index 6ae981b..5f335d3 100644 --- a/backend/tests/test_site_settings.py +++ b/backend/tests/test_site_settings.py @@ -38,6 +38,7 @@ async def test_public_read_returns_defaults_without_auth(client): "archive_retention_days": 30, "email_required": False, "email_verification_enabled": False, + "username_changes_enabled": True, } # Public shape only — no internal fields leak. assert "updated_at" not in body @@ -73,6 +74,7 @@ async def test_admin_update_round_trips(client): "archive_retention_days": 30, "email_required": False, "email_verification_enabled": False, + "username_changes_enabled": True, } diff --git a/backend/tests/test_username_change.py b/backend/tests/test_username_change.py index e6c0302..e2e1478 100644 --- a/backend/tests/test_username_change.py +++ b/backend/tests/test_username_change.py @@ -12,6 +12,7 @@ from db import SessionLocal, utcnow from models.audit_log import AuditLogEntry +from models.site_settings import SITE_SETTINGS_ID, SiteSettings from models.user import USERNAME_CHANGE_COOLDOWN, User from tests.conftest import admin_token @@ -167,3 +168,89 @@ async def test_admin_rename_bypasses_cooldown_and_audits_actor(client): admin_entry = next(e for e in entries if e.payload["actor_user_id"] == admin_id) assert admin_entry.payload["old_name"] == "rowdy2" assert admin_entry.payload["new_name"] == "clean-name" + + +# --- site toggle: username_changes_enabled (#298) ---------------------------- + + +async def _set_username_changes(enabled: bool) -> None: + """Flip the site policy directly — the API round-trip has its own test.""" + async with SessionLocal() as session: + settings = await session.get(SiteSettings, SITE_SETTINGS_ID) + if settings is None: + settings = SiteSettings(id=SITE_SETTINGS_ID) + session.add(settings) + settings.username_changes_enabled = enabled + await session.commit() + + +async def test_disabled_blocks_self_service_before_the_password_check(client): + token, _ = await _register(client, "pinned") + await _set_username_changes(False) + + resp = await _change(client, token, "renamed") + assert resp.status_code == 403 + assert "disabled" in resp.json()["detail"].lower() + + # The gate sits before password verification: a wrong password while + # disabled is still a 403, not a 400 — the endpoint never checks it. + wrong_pw = await _change(client, token, "renamed", pw="not-my-password") + assert wrong_pw.status_code == 403 + + me = await client.get("/api/auth/me", headers=_auth(token)) + assert me.json()["display_name"] == "pinned" # unchanged + + +async def test_disabled_still_allows_admin_rename(client): + _, user_id = await _register(client, "issued-name") + await _set_username_changes(False) + + admin = await admin_token(client) + resp = await client.patch( + f"/api/users/{user_id}", + json={"display_name": "corrected-name"}, + headers=_auth(admin), + ) + assert resp.status_code == 200, resp.text + assert resp.json()["display_name"] == "corrected-name" + + +async def test_reenabling_restores_self_service(client): + token, _ = await _register(client, "flippy") + await _set_username_changes(False) + assert (await _change(client, token, "flippy2")).status_code == 403 + + await _set_username_changes(True) + resp = await _change(client, token, "flippy2") + assert resp.status_code == 200, resp.text + assert resp.json()["display_name"] == "flippy2" + + +async def test_flag_is_public_and_operational_put_roundtrips(client): + # Default: on, and visible on the public payload (the profile page hides + # its card off this, pre-fetching no admin-only endpoint). + public = await client.get("/api/site-settings") + assert public.json()["username_changes_enabled"] is True + + admin = await admin_token(client) + # Turn it off through the admin surface. + off = await client.put( + "/api/site-settings/operational", + json={"registration_open": True, "username_changes_enabled": False}, + headers=_auth(admin), + ) + assert off.status_code == 200, off.text + assert off.json()["username_changes_enabled"] is False + assert (await client.get("/api/site-settings")).json()[ + "username_changes_enabled" + ] is False + + # Omitting the field leaves it unchanged (the update_checks_enabled + # omission contract) — a scripted PUT must not silently re-enable it. + omitted = await client.put( + "/api/site-settings/operational", + json={"registration_open": True}, + headers=_auth(admin), + ) + assert omitted.status_code == 200, omitted.text + assert omitted.json()["username_changes_enabled"] is False diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b67d0b7..eddbb48 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -2140,6 +2140,11 @@ "publicSignup": "Public sign-up", "signupOpen": "Open — anyone can register", "signupClosed": "Closed — invite / admin-created only", + "usernameChanges": "Username changes", + "usernameChangesDescription": "Whether accounts can rename themselves from the profile page (subject to the rename cooldown). Off pins usernames to what was issued — an administrator can still rename any account from Admin → Users.", + "selfServiceRenames": "Self-service username changes", + "renamesOn": "On — users may rename themselves", + "renamesOff": "Off — usernames are fixed; only admins rename", "updateChecks": "Update checks", "updateChecksDescription": "Once a day Flagpost asks updates.flagpost.io whether a newer release exists. The request sends only the version you're running — no identifier, no hostname, no competition or user data — and the count of those requests is how the project gauges how many deployments are live. See PRIVACY.md for the full detail.", "checkForUpdates": "Check for updates", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f71cf7c..dd633cd 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -2140,6 +2140,11 @@ "publicSignup": "Public sign-up", "signupOpen": "Open — anyone can register", "signupClosed": "Closed — invite / admin-created only", + "usernameChanges": "Username changes", + "usernameChangesDescription": "Whether accounts can rename themselves from the profile page (subject to the rename cooldown). Off pins usernames to what was issued — an administrator can still rename any account from Admin → Users.", + "selfServiceRenames": "Self-service username changes", + "renamesOn": "On — users may rename themselves", + "renamesOff": "Off — usernames are fixed; only admins rename", "updateChecks": "Update checks", "updateChecksDescription": "Once a day Flagpost asks updates.flagpost.io whether a newer release exists. The request sends only the version you're running — no identifier, no hostname, no competition or user data — and the count of those requests is how the project gauges how many deployments are live. See PRIVACY.md for the full detail.", "checkForUpdates": "Check for updates", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 42a5b54..57c77f5 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -2140,6 +2140,11 @@ "publicSignup": "Public sign-up", "signupOpen": "Open — anyone can register", "signupClosed": "Closed — invite / admin-created only", + "usernameChanges": "Username changes", + "usernameChangesDescription": "Whether accounts can rename themselves from the profile page (subject to the rename cooldown). Off pins usernames to what was issued — an administrator can still rename any account from Admin → Users.", + "selfServiceRenames": "Self-service username changes", + "renamesOn": "On — users may rename themselves", + "renamesOff": "Off — usernames are fixed; only admins rename", "updateChecks": "Update checks", "updateChecksDescription": "Once a day Flagpost asks updates.flagpost.io whether a newer release exists. The request sends only the version you're running — no identifier, no hostname, no competition or user data — and the count of those requests is how the project gauges how many deployments are live. See PRIVACY.md for the full detail.", "checkForUpdates": "Check for updates", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 1b62e75..c7b16f5 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -2140,6 +2140,11 @@ "publicSignup": "Public sign-up", "signupOpen": "Open — anyone can register", "signupClosed": "Closed — invite / admin-created only", + "usernameChanges": "Username changes", + "usernameChangesDescription": "Whether accounts can rename themselves from the profile page (subject to the rename cooldown). Off pins usernames to what was issued — an administrator can still rename any account from Admin → Users.", + "selfServiceRenames": "Self-service username changes", + "renamesOn": "On — users may rename themselves", + "renamesOff": "Off — usernames are fixed; only admins rename", "updateChecks": "Update checks", "updateChecksDescription": "Once a day Flagpost asks updates.flagpost.io whether a newer release exists. The request sends only the version you're running — no identifier, no hostname, no competition or user data — and the count of those requests is how the project gauges how many deployments are live. See PRIVACY.md for the full detail.", "checkForUpdates": "Check for updates", diff --git a/frontend/src/app/(app)/admin/settings/page.tsx b/frontend/src/app/(app)/admin/settings/page.tsx index 026615a..368bf3c 100644 --- a/frontend/src/app/(app)/admin/settings/page.tsx +++ b/frontend/src/app/(app)/admin/settings/page.tsx @@ -241,6 +241,7 @@ function SettingsForm({ const t = useTranslations("admin.settings"); const update = useUpdateOperationalSettings(); const [registrationOpen, setRegistrationOpen] = useState(data.registration_open); + const [usernameChanges, setUsernameChanges] = useState(data.username_changes_enabled); const [updateChecks, setUpdateChecks] = useState(data.update_checks_enabled); const [host, setHost] = useState(data.smtp_host ?? ""); const [port, setPort] = useState(String(data.smtp_port)); @@ -317,6 +318,7 @@ function SettingsForm({ update.mutate( { registration_open: registrationOpen, + username_changes_enabled: usernameChanges, smtp_host: host.trim() || null, smtp_port: Number(port) || 587, smtp_username: username.trim() || null, @@ -369,6 +371,27 @@ function SettingsForm({ + + + {t("usernameChanges")} + {t("usernameChangesDescription")} + + +
+ + +
+
+
+ {t("updateChecks")} diff --git a/frontend/src/components/profile/username-card.test.tsx b/frontend/src/components/profile/username-card.test.tsx index 2fd3d8b..d13f5ca 100644 --- a/frontend/src/components/profile/username-card.test.tsx +++ b/frontend/src/components/profile/username-card.test.tsx @@ -26,10 +26,19 @@ vi.mock("@/stores/auth", () => ({ vi.mock("@/lib/hooks/use-users", () => ({ useChangeUsername: () => ({ mutate: vi.fn(), isPending: false }), })); +// Site policy (#298): the card reads only `username_changes_enabled` off the +// public settings; default it on so the pre-existing tests are unaffected. +let usernameChangesEnabled = true; +vi.mock("@/lib/hooks/use-site-settings", () => ({ + useSiteSettings: () => ({ + data: { username_changes_enabled: usernameChangesEnabled }, + }), +})); vi.mock("@/stores/toast", () => ({ toast: vi.fn() })); describe("UsernameCard", () => { it("shows the change form when no cooldown is active", () => { + usernameChangesEnabled = true; current = { ...base, username_change_allowed_at: null }; renderWithIntl(); expect(screen.getByLabelText("Username")).toHaveValue("ada"); @@ -37,10 +46,18 @@ describe("UsernameCard", () => { }); it("hides the form and shows the dated notice during the cooldown", () => { + usernameChangesEnabled = true; const future = new Date(Date.now() + 20 * 864e5).toISOString(); current = { ...base, username_change_allowed_at: future }; renderWithIntl(); expect(screen.queryByLabelText("Username")).toBeNull(); expect(screen.getByText(/change it again on/i)).toBeInTheDocument(); }); + + it("renders nothing at all when the site has renames disabled (#298)", () => { + usernameChangesEnabled = false; + current = { ...base, username_change_allowed_at: null }; + const { container } = renderWithIntl(); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/frontend/src/components/profile/username-card.tsx b/frontend/src/components/profile/username-card.tsx index 9178e64..2d3cd31 100644 --- a/frontend/src/components/profile/username-card.tsx +++ b/frontend/src/components/profile/username-card.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useSiteSettings } from "@/lib/hooks/use-site-settings"; import { useChangeUsername } from "@/lib/hooks/use-users"; import { useAuthStore } from "@/stores/auth"; import { toast } from "@/stores/toast"; @@ -26,11 +27,15 @@ import { toast } from "@/stores/toast"; export function UsernameCard() { const t = useTranslations("profile.username"); const user = useAuthStore((s) => s.user); + const site = useSiteSettings(); const change = useChangeUsername(); const [name, setName] = useState(user?.display_name ?? ""); const [password, setPassword] = useState(""); if (!user) return null; + // Site policy (#298): renames disabled ⇒ no card at all — the server 403s + // anyway, so offering the form would only manufacture a dead end. + if (site.data && !site.data.username_changes_enabled) return null; const allowedAt = user.username_change_allowed_at ? new Date(user.username_change_allowed_at) diff --git a/frontend/src/lib/hooks/use-site-settings.ts b/frontend/src/lib/hooks/use-site-settings.ts index d7d9a41..d25e7a1 100644 --- a/frontend/src/lib/hooks/use-site-settings.ts +++ b/frontend/src/lib/hooks/use-site-settings.ts @@ -39,6 +39,7 @@ export const FALLBACK_SETTINGS: SiteSettings = { archive_retention_days: 30, email_required: false, email_verification_enabled: false, + username_changes_enabled: true, }; /** Rewrite the backend-relative `logo_url` to the API origin so an `` diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d685f09..3d95ab8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1156,6 +1156,9 @@ export interface SiteSettings { // Whether an unverified account is blocked from joining a competition // (#74). Public so the join button / profile banner can explain a 403. email_verification_enabled: boolean; + // Whether accounts may rename themselves (#298). Public so the profile page + // can hide its username card; admin rename is never gated by it. + username_changes_enabled: boolean; } // Admin shape adds the last-updated timestamp. @@ -1166,6 +1169,8 @@ export interface SiteSettingsAdmin extends SiteSettings { /** Operational site config (Admin → Site settings): registration + SMTP. */ export interface OperationalSettings { registration_open: boolean; + /** Self-service rename toggle (#298); admin rename is unaffected. */ + username_changes_enabled: boolean; smtp_host: string | null; smtp_port: number; smtp_username: string | null; @@ -1232,6 +1237,10 @@ export type BackupImportResult = Record