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