Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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")
6 changes: 6 additions & 0 deletions backend/models/site_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 10 additions & 0 deletions backend/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions backend/routers/site_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions backend/schemas/site_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/test_site_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}


Expand Down
87 changes: 87 additions & 0 deletions backend/tests/test_username_change.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
5 changes: 5 additions & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mono>updates.flagpost.io</mono> whether a newer release exists. The request sends <strong>only the version you're running</strong> — 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 <link>PRIVACY.md</link> for the full detail.",
"checkForUpdates": "Check for updates",
Expand Down
5 changes: 5 additions & 0 deletions frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mono>updates.flagpost.io</mono> whether a newer release exists. The request sends <strong>only the version you're running</strong> — 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 <link>PRIVACY.md</link> for the full detail.",
"checkForUpdates": "Check for updates",
Expand Down
5 changes: 5 additions & 0 deletions frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mono>updates.flagpost.io</mono> whether a newer release exists. The request sends <strong>only the version you're running</strong> — 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 <link>PRIVACY.md</link> for the full detail.",
"checkForUpdates": "Check for updates",
Expand Down
5 changes: 5 additions & 0 deletions frontend/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mono>updates.flagpost.io</mono> whether a newer release exists. The request sends <strong>only the version you're running</strong> — 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 <link>PRIVACY.md</link> for the full detail.",
"checkForUpdates": "Check for updates",
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/app/(app)/admin/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -369,6 +371,27 @@ function SettingsForm({
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>{t("usernameChanges")}</CardTitle>
<CardDescription>{t("usernameChangesDescription")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-2">
<Label htmlFor="username-changes">{t("selfServiceRenames")}</Label>
<Select
id="username-changes"
value={usernameChanges ? "on" : "off"}
onChange={(e) => setUsernameChanges(e.target.value === "on")}
className="max-w-xs"
>
<option value="on">{t("renamesOn")}</option>
<option value="off">{t("renamesOff")}</option>
</Select>
</div>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>{t("updateChecks")}</CardTitle>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/components/profile/username-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,38 @@ 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(<UsernameCard />);
expect(screen.getByLabelText("Username")).toHaveValue("ada");
expect(screen.getByLabelText("Current password")).toBeInTheDocument();
});

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(<UsernameCard />);
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(<UsernameCard />);
expect(container).toBeEmptyDOMElement();
});
});
5 changes: 5 additions & 0 deletions frontend/src/components/profile/username-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,23 @@ 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";

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)
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/hooks/use-site-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>`
Expand Down
Loading