feat(admin): an account panel, and a set-password link that needs no email

Design option C, chosen by the user 2026-09-05. Nothing in the admin UI
ever showed an account's GATE STATE: an admin could see a "Pending
approval" chip but not whether the address was confirmed, whether SEC-10
had cleared the password, or which Google account was attached — so
every support conversation started by guessing. Clicking a row now opens
a drawer that says which gate the person is actually stuck behind, with
the recovery actions under that readout rather than in the row.

The drawer is `Modal placement="drawer"`, not a second dialog: the layer
stack, ESC, the focus trap, Back-to-close and the scroll lock are the
hard parts, and duplicating them is how two shells drift apart. It
slides in, and honours prefers-reduced-motion like the rest.

The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone
forgot theirs") reuses the existing token machinery and the SAME
`#reset=` entry the mailed link uses, so the signed-out card consumes it
with no new flow. It is a distinct token KIND for one reason: a mailed
reset arrives in the mailbox, so completing it proves ownership and
confirms the address; an admin-issued link proves nothing about the
mailbox — the admin hands it over by whatever means they choose — so it
must not satisfy the email gate. Claiming otherwise would be the same
false attestation SEC-15 is about.

Issuing does not revoke the current password: a misclick must not lock
anyone out. USING it rewrites the hash and bumps the session epoch,
which is what evicts a squatter — SEC-16's actual remedy. Spending
either kind burns the siblings of both, so a completed reset cannot
leave a live admin link behind. The non-consuming probe learned the new
kind too, or every admin link would have opened onto "this link is
spent". The audit log records that a link was issued, by whom, for whom
— never the link, which is a live credential-setting secret and the last
thing that belongs in a long-lived log a human reads.

Not gated on account state on purpose: it is useful for a pre-empted
address, a revoked password, a Google-only account that wants one, and a
plain forgotten password. Narrowing it would only make it refuse exactly
when the admin needs it. The shared demo account is the one exception.

Verified on localdev, both languages, end to end: issue from the panel
-> the signed-in tab shows the honest "reset links open in a signed-out
window" banner with the token still in the address bar -> signing out
draws the "Set a new password" form, which is only possible because the
probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
This commit is contained in:
2026-09-05 01:46:58 +02:00
parent bc9e34d0ae
commit f98b060e2b
14 changed files with 778 additions and 10 deletions
+1
View File
@@ -31,6 +31,7 @@ class AuditAction:
# recorded because both are the admin acting ON someone's access, not merely reading a list. # recorded because both are the admin acting ON someone's access, not merely reading a list.
USER_ACTIVATE = "user_activate" USER_ACTIVATE = "user_activate"
USER_VERIFY_RESEND = "user_verify_resend" USER_VERIFY_RESEND = "user_verify_resend"
USER_SETPW_LINK = "user_setpw_link"
INVITE_APPROVE = "invite_approve" INVITE_APPROVE = "invite_approve"
INVITE_DENY = "invite_deny" INVITE_DENY = "invite_deny"
INVITE_ADD = "invite_add" INVITE_ADD = "invite_add"
+71 -1
View File
@@ -32,7 +32,12 @@ from app.models import (
User, User,
) )
from app.routes._common import get_or_404 from app.routes._common import get_or_404
from app.routes.auth import APPROVAL_VERIFY_TTL, issue_verification_link, send_verification_link from app.routes.auth import (
APPROVAL_VERIFY_TTL,
issue_set_password_link,
issue_verification_link,
send_verification_link,
)
from app.utils import canonical_email, iso, valid_email from app.utils import canonical_email, iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -393,7 +398,15 @@ def _serialize_user(u: User) -> dict:
"email_verified": u.email_verified, "email_verified": u.email_verified,
"is_demo": u.is_demo, "is_demo": u.is_demo,
"has_password": u.password_hash is not None, "has_password": u.password_hash is not None,
# Distinguishes "this account never had a password" (Google-only) from "SEC-10 cleared it" —
# the same distinction _send_reset_if_eligible needs, and the one an admin has to see before
# deciding what kind of help someone needs.
"password_revoked_at": iso(u.password_revoked_at),
"has_google": u.google_sub is not None, "has_google": u.google_sub is not None,
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same
# statement as "connected as someone else", and the second one is the one that explains a
# confusing sign-in.
"google_email": u.google_email if u.google_email != u.email else None,
"created_at": iso(u.created_at), "created_at": iso(u.created_at),
} }
@@ -608,6 +621,63 @@ def list_pending_access(_: User = Depends(admin_user), db: Session = Depends(get
return out return out
@router.get("/users/{user_id}/access")
def user_access(
user_id: int,
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Everything the account panel shows about why this person can or cannot get in (E12 S5).
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
per row on every load to answer a question nobody asked yet."""
target = get_or_404(db, User, user_id, detail="No such user")
return _serialize_user(target) | {
"is_allowed": is_allowed(db, target.email),
"can_activate": can_activate(db, target),
"email_configured": email_mod.email_enabled(db),
}
@router.post("/users/{user_id}/set-password-link")
def create_set_password_link(
user_id: int,
admin: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5).
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
hands the link over by whatever channel they trust; opening it sets a password and signs out
whoever held the old one.
Deliberately NOT gated on the account's state. It is useful for a pre-empted address, for a
revoked password, for a Google-only account that wants one, and for a plain forgotten password on
an instance with no mail — narrowing it to one of those would only make the admin's real case the
one it refuses. The demo account is the single exception: it is shared, so handing anyone a way to
change its password is handing them everyone else's session."""
target = get_or_404(db, User, user_id, detail="No such user")
if target.is_demo:
raise HTTPException(
status_code=400, detail={"code": "setpw", "reason": "demo", "email": target.email}
)
url, expires_at = issue_set_password_link(db, target)
# The URL itself is NOT recorded: the audit log is read by humans and kept long, and a live
# credential-setting link is the last thing that belongs in it. That the link was issued, by whom,
# for whom, is the auditable fact.
audit.record(
db,
admin.id,
AuditAction.USER_SETPW_LINK,
target_type="user",
target_id=target.id,
summary=target.email,
)
db.commit()
return {"ok": True, "url": url, "expires_at": iso(expires_at), "email": target.email}
@router.post("/users/{user_id}/activate") @router.post("/users/{user_id}/activate")
def activate_user( def activate_user(
user_id: int, user_id: int,
+51 -5
View File
@@ -52,6 +52,16 @@ VERIFY_TTL = timedelta(hours=24)
# in and sets no password. # in and sets no password.
APPROVAL_VERIFY_TTL = timedelta(days=7) APPROVAL_VERIFY_TTL = timedelta(days=7)
RESET_TTL = timedelta(hours=1) RESET_TTL = timedelta(hours=1)
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
# the affordance the admin was shown.
ADMIN_SETPW_TTL = timedelta(hours=24)
# Both kinds set a password from a link, and the SIGNED-OUT card consumes them through the same
# `#reset=` entry, so the user-facing flow is one flow. They stay separate kinds for one reason: a
# `reset` token proves control of the MAILBOX (it was mailed there) and therefore confirms the address;
# an admin-issued one proves nothing about the mailbox — the admin could hand it to anyone — so it must
# not silently satisfy the email gate. See password_reset_confirm.
_PASSWORD_TOKEN_KINDS = ("reset", "setpw")
_DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
@@ -773,6 +783,35 @@ def _consume_token(db: Session, raw: str | None, kind: str) -> User | None:
return db.get(User, row.user_id) return db.get(User, row.user_id)
def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] | None:
"""Spend a password-setting token of either kind; returns (user, kind). The kind is returned, not
swallowed, because the caller has to treat them differently — only a mailed reset attests the
address."""
for kind in _PASSWORD_TOKEN_KINDS:
user = _consume_token(db, raw, kind)
if user is not None:
return user, kind
return None
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5).
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
stays takeable until its owner resets, and password-reset needs working mail), and the everyday
"no SMTP configured and someone forgot their password".
Issuing does NOT revoke the current password: an admin misclick must not lock anyone out, and the
account may be perfectly healthy. USING the link replaces the hash and bumps the session epoch,
which is what evicts a squatter — see password_reset_confirm."""
expires_at = datetime.now(timezone.utc) + ADMIN_SETPW_TTL
raw = _issue_token(db, user, "setpw", ADMIN_SETPW_TTL)
# The same `#reset=` fragment the mailed link uses: the signed-out card already knows how to
# consume it, and a second entry shape would be a second thing to keep correct.
return f"{_app_base()}/#reset={raw}", expires_at
@router.post("/register") @router.post("/register")
def register( def register(
body: EmailPasswordIn, body: EmailPasswordIn,
@@ -1105,7 +1144,7 @@ def password_reset_check(body: TokenIn, request: Request, db: Session = Depends(
""" """
if not _reset_check_limiter.allow(_client_ip(request)): if not _reset_check_limiter.allow(_client_ip(request)):
return {"ok": True} return {"ok": True}
return {"ok": _peek_token(db, body.token, "reset") is not None} return {"ok": any(_peek_token(db, body.token, k) is not None for k in _PASSWORD_TOKEN_KINDS)}
@router.post("/password-reset/confirm") @router.post("/password-reset/confirm")
@@ -1118,16 +1157,23 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
status_code=400, status_code=400,
detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN}, detail={"code": "reset", "reason": "weak_password", "min": PASSWORD_MIN_LEN},
) )
user = _consume_token(db, token, "reset") spent = _consume_password_token(db, token)
if user is None: if spent is None:
raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"}) raise HTTPException(status_code=400, detail={"code": "reset", "reason": "invalid"})
user, kind = spent
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent user.password_revoked_at = None # the account has a password again — SEC-10's trace is spent
# A valid reset link proves control of the mailbox, so completing a reset also verifies the # A valid reset link proves control of the mailbox, so completing a reset also verifies the
# email — this rescues an account that registered but never clicked the verification link (it # email — this rescues an account that registered but never clicked the verification link (it
# can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched # can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched
# here: a reset proves email ownership, not that an admin approved the account. # here: a reset proves email ownership, not that an admin approved the account.
user.email_verified = True #
# An admin-issued `setpw` link proves NOTHING about the mailbox — the admin hands it over by
# whatever means they choose — so it must not satisfy the email gate. Claiming otherwise would let
# an admin confirm an address nobody ever proved, which is the same false attestation SEC-15 is
# about. The person still confirms their address the normal way.
if kind == "reset":
user.email_verified = True
bump_session_epoch( bump_session_epoch(
user user
) # SA4: a reset (often a compromise response) kills all existing sessions. ) # SA4: a reset (often a compromise response) kills all existing sessions.
@@ -1135,7 +1181,7 @@ def password_reset_confirm(body: TokenPasswordIn, db: Session = Depends(get_db))
for row in db.execute( for row in db.execute(
select(AuthToken).where( select(AuthToken).where(
AuthToken.user_id == user.id, AuthToken.user_id == user.id,
AuthToken.kind == "reset", AuthToken.kind.in_(_PASSWORD_TOKEN_KINDS),
AuthToken.used_at.is_(None), AuthToken.used_at.is_(None),
) )
).scalars(): ).scalars():
+280
View File
@@ -0,0 +1,280 @@
"""E12 · S5 — the admin-issued set-password link.
One mechanism answering three things: SEC-15 (a no-SMTP instance auto-verifies every registration, so
a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address stays takeable until
its owner resets, and reset needs working mail), and the everyday "no SMTP and someone forgot theirs".
The property that must not regress is the one that makes it honest: an admin-issued link proves
NOTHING about the mailbox — the admin hands it over by whatever means they like — so it must not
confirm the address the way a mailed reset does.
"""
from app.models import AuthToken
def _link(client_admin, user_id: int):
r = client_admin.post(f"/api/admin/users/{user_id}/set-password-link")
assert r.status_code == 200, r.text
return r.json()["url"].split("#reset=", 1)[1]
def test_the_link_sets_a_password_without_confirming_the_address(db, as_user, make_user, client):
"""The whole point of a separate token kind. A mailed reset arrives IN the mailbox, so completing
it proves ownership and confirms the address. An admin-issued link does not, and claiming
otherwise would let an admin confirm an address nobody ever proved."""
admin = make_user(role="admin")
target = make_user(email_verified=False)
db.commit()
token = _link(as_user(admin), target.id)
r = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
)
assert r.status_code == 200, r.text
db.refresh(target)
assert target.password_hash is not None
assert target.email_verified is False # the address is still unproven — and says so
def test_a_mailed_reset_still_confirms_the_address(db, as_user, make_user, client):
"""The mutation check for the test above: if `email_verified` were simply never set, this would
fail — and the rescue path for "registered but never clicked the link" would be gone."""
from datetime import timedelta
from app.routes.auth import _issue_token
user = make_user(email_verified=False)
db.commit()
token = _issue_token(db, user, "reset", timedelta(hours=1))
r = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "a brand new passphrase"}
)
assert r.status_code == 200, r.text
db.refresh(user)
assert user.email_verified is True
def test_using_the_link_evicts_whoever_held_the_old_password(db, as_user, make_user, client):
"""SEC-16's actual remedy: the squatter's session dies the moment the real owner sets a password.
The epoch bump is what does it, and it is the reason this reuses the reset confirm path rather
than writing a second one that could forget to bump."""
target = make_user()
db.commit()
before = target.session_epoch
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "the owner's own choice"}
)
db.refresh(target)
assert target.session_epoch != before
def test_issuing_does_not_touch_the_current_password(db, as_user, make_user):
"""An admin misclick must not lock anyone out. Issuing is an offer; only USING it changes
anything — which is also why it is safe to offer on a perfectly healthy account."""
target = make_user()
db.commit()
original = target.password_hash
admin = make_user(role="admin")
db.commit()
_link(as_user(admin), target.id)
db.refresh(target)
assert target.password_hash == original
assert target.password_revoked_at is None
def test_the_link_is_single_use(db, as_user, make_user, client):
target = make_user()
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
first = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "first passphrase here"}
)
second = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "second passphrase here"}
)
assert first.status_code == 200
assert second.status_code == 400
def test_a_new_link_burns_the_outstanding_ones(db, as_user, make_user, client):
"""Spending any password token burns the siblings of BOTH kinds. Without that, a spent reset
would leave a live admin link behind — a second key to a door someone just changed the lock on."""
target = make_user()
admin = make_user(role="admin")
db.commit()
first = _link(as_user(admin), target.id)
second = _link(as_user(admin), target.id)
ok = client.post(
"/auth/password-reset/confirm", json={"token": second, "password": "the newer passphrase"}
)
assert ok.status_code == 200
stale = client.post(
"/auth/password-reset/confirm", json={"token": first, "password": "the older passphrase"}
)
assert stale.status_code == 400
def test_the_probe_recognises_an_admin_link(db, as_user, make_user, client):
"""`/auth/password-reset/check` is what stops the card drawing a form for a dead link. It peeked
only at `reset` tokens, so without teaching it the new kind every admin-issued link would open
onto "this link is spent"."""
target = make_user()
admin = make_user(role="admin")
db.commit()
token = _link(as_user(admin), target.id)
r = client.post("/auth/password-reset/check", json={"token": token})
assert r.json() == {"ok": True}
def test_only_an_admin_can_mint_one(db, as_user, make_user):
plain = make_user()
victim = make_user()
db.commit()
r = as_user(plain).post(f"/api/admin/users/{victim.id}/set-password-link")
assert r.status_code in (401, 403)
assert db.query(AuthToken).filter(AuthToken.kind == "setpw").count() == 0
def test_the_shared_demo_account_is_refused(db, as_user, make_user):
"""The demo account is shared, so handing anyone a way to change its password hands them
everyone else's session on it."""
admin = make_user(role="admin")
demo = make_user(is_demo=True)
db.commit()
r = as_user(admin).post(f"/api/admin/users/{demo.id}/set-password-link")
assert r.status_code == 400
assert r.json()["detail"]["reason"] == "demo"
def test_the_access_panel_reports_the_gates(db, as_user, make_user):
admin = make_user(role="admin")
target = make_user(email_verified=False, is_active=False)
db.commit()
body = as_user(admin).get(f"/api/admin/users/{target.id}/access").json()
assert body["email_verified"] is False
assert body["is_active"] is False
assert set(body) >= {
"is_allowed",
"can_activate",
"email_configured",
"password_revoked_at",
"google_email",
"has_password",
"has_google",
}
def test_the_panel_names_a_google_address_only_when_it_differs(db, as_user, make_user):
""" "Connected" and "connected as someone else" are different statements, and only the second one
explains a confusing sign-in. Repeating the account's own address would be noise on every row."""
admin = make_user(role="admin")
same = make_user(email="same@example.com")
same.google_email = "same@example.com"
other = make_user(email="typed@example.com")
other.google_email = "google-primary@example.com"
db.commit()
c = as_user(admin)
assert c.get(f"/api/admin/users/{same.id}/access").json()["google_email"] is None
assert (
c.get(f"/api/admin/users/{other.id}/access").json()["google_email"]
== "google-primary@example.com"
)
def test_the_link_is_audited_without_recording_the_link(db, as_user, make_user):
"""The audit log is read by humans and kept long; a live credential-setting link is the last
thing that belongs in it. That one was ISSUED, by whom, for whom, is the auditable fact."""
from app.models import AuditLog
admin = make_user(role="admin")
target = make_user()
db.commit()
url = as_user(admin).post(f"/api/admin/users/{target.id}/set-password-link").json()["url"]
secret = url.split("#reset=", 1)[1]
rows = db.query(AuditLog).filter(AuditLog.action == "user_setpw_link").all()
assert len(rows) == 1
assert rows[0].summary == target.email
assert secret not in str(rows[0].summary) + str(rows[0].before) + str(rows[0].after)
def test_a_google_only_account_can_be_given_a_password(db, as_user, make_user, client):
"""Deliberately not gated on the account's state: the admin's real case is whichever one it is,
and narrowing this would only make it refuse exactly then."""
admin = make_user(role="admin")
google_only = make_user(password=None)
db.commit()
assert google_only.password_hash is None
token = _link(as_user(admin), google_only.id)
r = client.post(
"/auth/password-reset/confirm", json={"token": token, "password": "now it has one too"}
)
assert r.status_code == 200
db.refresh(google_only)
assert google_only.password_hash is not None
def test_an_unknown_user_is_a_404(db, as_user, make_user):
admin = make_user(role="admin")
db.commit()
assert as_user(admin).post("/api/admin/users/999999/set-password-link").status_code == 404
assert as_user(admin).get("/api/admin/users/999999/access").status_code == 404
def test_the_issued_token_is_stored_hashed(db, as_user, make_user):
"""Same rule as every other token here: the DB holds a hash, so a database read cannot hand
someone a working link."""
admin = make_user(role="admin")
target = make_user()
db.commit()
raw = _link(as_user(admin), target.id)
rows = db.query(AuthToken).filter(AuthToken.kind == "setpw").all()
assert len(rows) == 1
assert rows[0].token_hash != raw
assert raw not in rows[0].token_hash
def test_the_link_outlives_the_one_hour_reset_window(db, as_user, make_user):
"""It travels out of band — the admin has to actually reach the person — so the mailed reset's
one hour is far too short."""
from datetime import datetime, timedelta, timezone
admin = make_user(role="admin")
target = make_user()
db.commit()
_link(as_user(admin), target.id)
row = db.query(AuthToken).filter(AuthToken.kind == "setpw").one()
assert row.expires_at - datetime.now(timezone.utc) > timedelta(hours=2)
def test_users_listing_still_serializes(db, as_user, make_user):
"""The list gained two fields; this pins that it did not gain the two per-row QUERIES the panel
endpoint exists to keep out of it."""
admin = make_user(role="admin")
db.commit()
rows = as_user(admin).get("/api/admin/users").json()
assert rows and "password_revoked_at" in rows[0] and "google_email" in rows[0]
assert "can_activate" not in rows[0]
+238
View File
@@ -0,0 +1,238 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
import Modal from "./Modal";
import { LoadingState, StateMessage } from "./QueryState";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
//
// It exists because nothing in the admin UI showed an account's GATE STATE: an admin could see a
// "Pending approval" chip but not whether the address was confirmed, whether the password had been
// cleared by SEC-10, or which Google account was attached. Every support conversation started by
// guessing. The recovery actions sit under that readout rather than in the row, so the admin picks
// one having read what is actually wrong.
/** One line of the Access readout: an icon, a label, and the state in the tone that state deserves. */
function AccessRow({
icon: Icon,
label,
value,
tone = "muted",
}: {
icon: typeof Mail;
label: string;
value: string;
tone?: "muted" | "warn" | "ok";
}) {
const toneCls = tone === "warn" ? "text-warn" : tone === "ok" ? "text-success" : "text-muted";
return (
<div className="flex items-center gap-2 text-xs">
<Icon className={`w-3.5 h-3.5 shrink-0 ${toneCls}`} />
<span className="text-muted w-28 shrink-0">{label}</span>
<span className={`${toneCls} min-w-0 truncate`}>{value}</span>
</div>
);
}
export default function AccountPanel({
user,
onClose,
}: {
user: AdminUserRow;
onClose: () => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
const [copied, setCopied] = useState(false);
const q = useQuery({
queryKey: qk.adminUserAccess(user.id),
queryFn: () => api.adminUserAccess(user.id),
});
const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
});
const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
});
const activate = useMutation({
mutationFn: () => api.activateUser(user.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.adminUsers() });
qc.invalidateQueries({ queryKey: qk.adminUserAccess(user.id) });
},
});
const copy = async () => {
if (!link) return;
try {
await navigator.clipboard.writeText(link.url);
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard access can be refused (permissions, an insecure origin). The link is on screen and
// selectable, so this is a convenience that failed — not the action failing.
notify({ level: "warning", message: t("users.account.copyFailed") });
}
};
const a: AdminUserAccess | undefined = q.data;
return (
<Modal
title={user.display_name ?? user.email.split("@")[0]}
onClose={onClose}
placement="drawer"
maxWidth="max-w-md"
>
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
{q.isError && !a ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : !a ? (
<LoadingState />
) : (
<>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
{t("users.account.access")}
</div>
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
<AccessRow
icon={Mail}
label={t("users.account.email")}
value={
a.email_verified
? t("users.account.emailConfirmed")
: t("users.account.emailUnconfirmed")
}
tone={a.email_verified ? "ok" : "warn"}
/>
<AccessRow
icon={Clock}
label={t("users.account.approval")}
value={
a.is_active
? t("users.account.approvalDone")
: a.can_activate
? t("users.account.approvalWaitingOnYou")
: t("users.account.approvalNotRequested")
}
tone={a.is_active ? "ok" : "warn"}
/>
<AccessRow
icon={KeyRound}
label={t("users.account.password")}
value={
a.has_password
? t("users.account.passwordSet")
: a.password_revoked_at
? t("users.account.passwordRevoked")
: t("users.account.passwordNone")
}
// Not having a password is only a PROBLEM when it was taken away; a Google-only
// account never had one and is working exactly as intended.
tone={a.has_password ? "ok" : a.password_revoked_at ? "warn" : "muted"}
/>
<AccessRow
icon={Globe}
label={t("users.account.google")}
value={
!a.has_google
? t("users.account.googleNone")
: a.google_email
? t("users.account.googleAs", { email: a.google_email })
: t("users.account.googleConnected")
}
tone={a.has_google ? "ok" : "muted"}
/>
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
{!a.is_allowed && (
<AccessRow
icon={ShieldAlert}
label={t("users.account.invite")}
value={t("users.account.inviteMissing")}
tone="warn"
/>
)}
</div>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
{t("users.account.recovery")}
</div>
<div className="space-y-2">
{a.can_activate && (
<button
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
>
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</button>
)}
<button
onClick={() => resend.mutate()}
disabled={!a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
>
<Send className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
whole reason the link below exists, so naming it here is the explanation. */}
{!a.email_configured && (
<span className="text-muted">{t("users.account.mailOff")}</span>
)}
{a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
)}
</button>
<button
onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
>
<KeyRound className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span>
</button>
</div>
{link && (
<div className="mt-4 pt-4 border-t border-border space-y-2">
<div className="flex gap-2">
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
{link.url}
</div>
<button
onClick={copy}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? t("users.account.copied") : t("users.account.copy")}
</button>
</div>
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
</div>
)}
</>
)}
</Modal>
);
}
+11 -2
View File
@@ -31,6 +31,7 @@ import { Section } from "./ui/form";
import SegmentedToggle from "./SegmentedToggle"; import SegmentedToggle from "./SegmentedToggle";
import { LANGUAGES, type LangCode } from "../i18n"; import { LANGUAGES, type LangCode } from "../i18n";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
@@ -107,6 +108,9 @@ function UsersRoles({ me }: { me: Me }) {
// Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since // Which rows have an action in flight, so we disable ONLY those rows' buttons (SC1) — a Set, since
// with per-row disabling the admin can now start actions on several rows concurrently. // with per-row disabling the admin can now start actions on several rows concurrently.
const [pendingIds, setPendingIds] = useState<Set<number>>(new Set()); const [pendingIds, setPendingIds] = useState<Set<number>>(new Set());
// The account panel (E12 S5): which user it is showing, or null. Held here rather than per row so
// only one is ever mounted.
const [panelFor, setPanelFor] = useState<AdminUserRow | null>(null);
const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id)); const startPending = (id: number) => setPendingIds((s) => new Set(s).add(id));
const endPending = (id: number) => const endPending = (id: number) =>
setPendingIds((s) => { setPendingIds((s) => {
@@ -192,6 +196,7 @@ function UsersRoles({ me }: { me: Me }) {
return ( return (
<Section card title={t("users.roles.title")}> <Section card title={t("users.roles.title")}>
{panelFor && <AccountPanel user={panelFor} onClose={() => setPanelFor(null)} />}
<p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p> <p className="text-xs text-muted leading-relaxed mb-3">{t("users.roles.intro")}</p>
{q.isError && !q.data ? ( {q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}> <StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -207,13 +212,17 @@ function UsersRoles({ me }: { me: Me }) {
const self = u.id === me.id; const self = u.id === me.id;
return ( return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl"> <div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<div className="min-w-0 flex-1"> <button
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="min-w-0 flex-1 text-left rounded-lg -m-1 p-1 hover:bg-hover transition"
>
<div className="text-sm truncate"> <div className="text-sm truncate">
{u.display_name ?? u.email.split("@")[0]} {u.display_name ?? u.email.split("@")[0]}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>} {self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div> </div>
<div className="text-2xs text-muted truncate">{u.email}</div> <div className="text-2xs text-muted truncate">{u.email}</div>
</div> </button>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>} {u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>} {u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>} {u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
+15 -2
View File
@@ -25,11 +25,17 @@ export default function Modal({
onClose, onClose,
children, children,
maxWidth = "max-w-lg", maxWidth = "max-w-lg",
placement = "center",
}: { }: {
title: ReactNode; title: ReactNode;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
maxWidth?: string; maxWidth?: string;
/** `drawer` slides in from the right edge and runs full height; everything else about the shell —
* the layer stack, ESC, the focus trap, Back-to-close, the scroll lock — is identical. A drawer
* is a placement, not a second dialog implementation: duplicating that behaviour is exactly how
* two shells drift apart. */
placement?: "center" | "drawer";
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Browser/mouse Back closes the topmost modal instead of navigating away. // Browser/mouse Back closes the topmost modal instead of navigating away.
@@ -106,9 +112,12 @@ export default function Modal({
}; };
}, [restoreTo]); }, [restoreTo]);
const drawer = placement === "drawer";
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 overlay-backdrop" className={`fixed inset-0 z-overlay flex bg-black/70 overlay-backdrop ${
drawer ? "justify-end" : "items-center justify-center p-4 sm:p-6"
}`}
onMouseDown={(e) => { onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget; pressedOnBackdrop.current = e.target === e.currentTarget;
}} }}
@@ -122,7 +131,11 @@ export default function Modal({
<div <div
ref={panelRef} ref={panelRef}
tabIndex={-1} tabIndex={-1}
className={`glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`} className={
drawer
? `glass relative w-full ${maxWidth} h-full overflow-y-auto rounded-none sm:rounded-l-2xl outline-none overlay-drawer`
: `glass relative w-full ${maxWidth} max-h-full overflow-y-auto rounded-2xl outline-none`
}
> >
<div className="flex items-start justify-between gap-3 p-5 pb-3"> <div className="flex items-start justify-between gap-3 p-5 pb-3">
<h2 id={titleId} className="text-lg font-semibold leading-snug"> <h2 id={titleId} className="text-lg font-semibold leading-snug">
@@ -5,6 +5,7 @@
"user_delete": "User deleted", "user_delete": "User deleted",
"user_activate": "Account activated", "user_activate": "Account activated",
"user_verify_resend": "Confirmation link re-sent", "user_verify_resend": "Confirmation link re-sent",
"user_setpw_link": "Set-password link issued",
"invite_approve": "Access request approved", "invite_approve": "Access request approved",
"invite_deny": "Access request denied", "invite_deny": "Access request denied",
"invite_add": "Email whitelisted", "invite_add": "Email whitelisted",
+33
View File
@@ -59,5 +59,38 @@
"title": "Delete account?", "title": "Delete account?",
"confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.", "confirm": "Permanently delete {{email}} and all their data — subscriptions, tags, watch history, playlists and settings? This can't be undone.",
"done": "{{email}} deleted" "done": "{{email}} deleted"
},
"account": {
"access": "Access",
"activate": "Let this account in",
"alreadyConfirmed": "already confirmed",
"approval": "Admin approval",
"approvalDone": "let in",
"approvalNotRequested": "no approved invite yet",
"approvalWaitingOnYou": "waiting on you",
"copied": "Copied",
"copy": "Copy",
"copyFailed": "Couldn't copy — select the link and copy it by hand.",
"email": "Email",
"emailConfirmed": "confirmed",
"emailUnconfirmed": "not confirmed",
"google": "Google",
"googleAs": "connected as {{email}}",
"googleConnected": "connected",
"googleNone": "not connected",
"invite": "Invite",
"inviteMissing": "this address is not on the allow-list",
"linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.",
"mailOff": "no mail configured",
"open": "Open the account panel",
"password": "Password",
"passwordNone": "none — signs in with Google",
"passwordRevoked": "cleared for security",
"passwordSet": "set",
"recovery": "Recovery",
"resend": "Send a confirmation link",
"resendSent": "Confirmation link sent.",
"setPassword": "Create a set-password link",
"setPasswordAgain": "Create another link"
} }
} }
@@ -5,6 +5,7 @@
"user_delete": "Felhasználó törölve", "user_delete": "Felhasználó törölve",
"user_activate": "Fiók aktiválva", "user_activate": "Fiók aktiválva",
"user_verify_resend": "Megerősítő link újraküldve", "user_verify_resend": "Megerősítő link újraküldve",
"user_setpw_link": "Jelszóbeállító link kiadva",
"invite_approve": "Hozzáférési kérelem jóváhagyva", "invite_approve": "Hozzáférési kérelem jóváhagyva",
"invite_deny": "Hozzáférési kérelem elutasítva", "invite_deny": "Hozzáférési kérelem elutasítva",
"invite_add": "Email fehérlistázva", "invite_add": "Email fehérlistázva",
+33
View File
@@ -59,5 +59,38 @@
"title": "Törlöd a fiókot?", "title": "Törlöd a fiókot?",
"confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.", "confirm": "Véglegesen törlöd: {{email}} és minden adatát — feliratkozások, címkék, megtekintési előzmény, lejátszási listák és beállítások? Ez nem visszavonható.",
"done": "{{email}} törölve" "done": "{{email}} törölve"
},
"account": {
"access": "Hozzáférés",
"activate": "Fiók beengedése",
"alreadyConfirmed": "már megerősítve",
"approval": "Admin jóváhagyás",
"approvalDone": "beengedve",
"approvalNotRequested": "még nincs jóváhagyott meghívó",
"approvalWaitingOnYou": "rád vár",
"copied": "Másolva",
"copy": "Másolás",
"copyFailed": "Nem sikerült másolni — jelöld ki a linket, és másold kézzel.",
"email": "E-mail",
"emailConfirmed": "megerősítve",
"emailUnconfirmed": "nincs megerősítve",
"google": "Google",
"googleAs": "összekötve mint {{email}}",
"googleConnected": "összekötve",
"googleNone": "nincs összekötve",
"invite": "Meghívó",
"inviteMissing": "ez a cím nincs az engedélyezési listán",
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
"mailOff": "nincs beállítva levelezés",
"open": "Fiókpanel megnyitása",
"password": "Jelszó",
"passwordNone": "nincs — Google-lel lép be",
"passwordRevoked": "biztonsági okból törölve",
"passwordSet": "be van állítva",
"recovery": "Helyreállítás",
"resend": "Megerősítő link küldése",
"resendSent": "Megerősítő link elküldve.",
"setPassword": "Jelszóbeállító link készítése",
"setPasswordAgain": "Új link készítése"
} }
} }
+18
View File
@@ -657,6 +657,24 @@ html[data-perf="1"] .overlay-backdrop {
backdrop-filter: none; backdrop-filter: none;
} }
/* Right-edge drawer (Modal placement="drawer"). The slide is what makes it read as a panel that
came from the side rather than a dialog that happens to be right-aligned — so it is part of the
affordance, not decoration. Honours prefers-reduced-motion, like every other motion here. */
.overlay-drawer {
animation: drawer-in 180ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}
@keyframes drawer-in {
from {
transform: translateX(1.5rem);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.overlay-drawer {
animation: none;
}
}
/* Skip layout/paint for off-screen feed cards so long lists stay smooth while /* Skip layout/paint for off-screen feed cards so long lists stay smooth while
scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar
stays stable; `auto` lets the browser remember each card's real size after it stays stable; `auto` lets the browser remember each card's real size after it
+24
View File
@@ -75,10 +75,26 @@ export interface AdminUserRow {
email_verified: boolean; email_verified: boolean;
is_demo: boolean; is_demo: boolean;
has_password: boolean; has_password: boolean;
/** When SEC-10 cleared this account's password, or null. Separates "never had one" (a Google-only
* account) from "we took it away" — different problems needing different help. */
password_revoked_at: string | null;
has_google: boolean; has_google: boolean;
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
google_email: string | null;
created_at: string | null; created_at: string | null;
} }
/** The account panel's view of why someone can or cannot get in (E12 S5). */
export interface AdminUserAccess extends AdminUserRow {
/** The address passes the invite gate — so Google sign-in will be let through, not filed as a
* fresh access request. */
is_allowed: boolean;
can_activate: boolean;
/** Whether this instance has working mail at all. Decides whether the two mail-shaped recovery
* actions are offered or shown as unavailable — rather than failing after the click. */
email_configured: boolean;
}
export interface AuditRow { export interface AuditRow {
id: number; id: number;
created_at: string | null; created_at: string | null;
@@ -197,6 +213,14 @@ export const adminApi = {
// because the caller is already an authenticated admin. // because the caller is already an authenticated admin.
resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> => resendUserVerification: (id: number): Promise<{ ok: boolean; email: string }> =>
req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }), req(`/api/admin/users/${id}/resend-verification`, { method: "POST" }, { quiet: true }),
// What the account panel shows: the two computed gate answers, resolved for one user on open.
adminUserAccess: (id: number): Promise<AdminUserAccess> => req(`/api/admin/users/${id}/access`),
// Mint a one-time, no-email link that lets this account set a password (SEC-15 / SEC-16, and the
// everyday "no SMTP and someone forgot theirs"). Issuing does not revoke the current password.
createSetPasswordLink: (
id: number,
): Promise<{ ok: boolean; url: string; expires_at: string | null; email: string }> =>
req(`/api/admin/users/${id}/set-password-link`, { method: "POST" }, { quiet: true }),
adminInvites: (status?: string): Promise<Invite[]> => adminInvites: (status?: string): Promise<Invite[]> =>
req(`/api/admin/invites${status ? `?status=${status}` : ""}`), req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }), approveInvite: (id: number) => req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
+1
View File
@@ -85,6 +85,7 @@ export const qk = {
// --- admin --- // --- admin ---
adminUsers: () => ["admin-users"] as const, adminUsers: () => ["admin-users"] as const,
adminUserAccess: (id: number) => ["admin-user-access", id] as const,
adminInvites: () => ["admin-invites"] as const, adminInvites: () => ["admin-invites"] as const,
adminPendingAccess: () => ["admin-pending-access"] as const, adminPendingAccess: () => ["admin-pending-access"] as const,
adminConfig: () => ["admin-config"] as const, adminConfig: () => ["admin-config"] as const,