use-two-factor.tsfeat(security): make the security settings page mean what it says
Three controls on Settings -> Security were wired to nothing. The two-factor
switch ran a setTimeout that console.logged its values; the login-notifications
switch had no storage, no trigger and no sender behind its fourteen translations;
Delete Account had no onClick at all. A user could turn 2FA on and believe they
had it.
Two-factor, for an explicit allowlist (currently one user, at his organization's
request). better-auth's twoFactor plugin alone would have recorded 2FA without
ever enforcing it: emailAndPassword is disabled and every sign-in call site is
signIn.social, but the plugin's hook matcher covers only /sign-in/{email,username,
phone-number}, so the Google OAuth callback is never challenged. The plugin is
registered for its primitives only -- TOTP secrets, encryption at rest,
constant-time verify, single-use recovery codes, none of which should be
hand-rolled -- and the enforcement is Cedar's own gate in http/app.ts, keyed on a
per-session marker. Per-session so that a new sign-in is challenged again and
revoking a session revokes its clearance with it. Two traps are documented where
they bite: the marker is read from the database because the 30-day session cookie
cache would otherwise report a stale value forever, and the first successful
verification rotates the session while echoing back the PRE-rotation token.
Enrolling also enforces, or the settings toggle would be decoration for anyone off
the allowlist. The asymmetry is deliberate -- un-enrolling opts you out only if the
allowlist was not what put you there -- and /two-factor/disable is rejected by the
gate for required users, not merely hidden in the UI.
Login notifications fire on a device not seen before, not on every sign-in.
Comparing raw user_agent strings looks correct and is not: one live account shows
46 sessions across 6 user agents but only 3 real devices, because Chrome 148, 149,
150 and 151 each got their own row. That version would have sent three false "new
device" warnings for routine auto-updates, and an alert that cries wolf monthly is
one nobody reads. It compares a digit-stripped signature, tested against those
exact strings. Delivered over email rather than the primary channel, which can be
Slack reached with the very Google account the alert is about. Default off:
defaulting it true would start mailing every existing user the moment this deploys.
Deploy order matters. two_factor_auth.sql is additive and must land BEFORE this
code -- registering the plugin makes better-auth select two_factor_enabled on
every user query, so a missing column is a full outage, not a degraded feature.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Aug 27, 2026, 11:36 PMuse-viewer-user-id.tsfix(admin-view): an agent's files open for the user being viewed, not the staff member
Under Cedar-staff admin view every tRPC request carries X-Admin-View-User and the
server swaps ctx.sessionUser to the impersonated user. The session is the one thing
that does not move with it: better-auth talks to /auth/get-session, which the
impersonation middleware explicitly skips. So a `user` file scope built from
useSession() asked, AS the impersonated user, for the staff member's own workspace —
a cross-user request assertAuthorizedScope correctly refuses. The refusal is a tRPC
FORBIDDEN that nothing logs, so the symptom was an agent's Files tab that was simply
empty against a clean server.
Replaces the eight hand-rolled answers to "who am I" with useViewerUserId. It
deliberately does not read targetUserId: admin view is a property of the whole
client, so reading it ambiently is safe, while the org-admin picker is a property of
one surface and an ambient read of it is the documented failure on useScopedInput.
Fixes the same class where the viewer id was not merely cosmetic — it also drives
isOtherPersonsAgent on the deal Files tab and the task-ownership split, so the viewed
user's own agents and follow-ups all read as a teammate's.
Co-Authored-By: Claude Opus 5 (1M context) <<email>>Sep 10, 2026, 12:20 PM