You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
resolveRealUser returns null when row.deletedAt is set (lib/auth/server.ts:43) — after a valid Neon session has already resolved. So a deactivated user with working credentials is redirected to /login, authenticates successfully, and lands back on /login. A silent infinite loop, with no explanation.
There is no "your account has been deactivated" screen anywhere in the app — the only such text is prose in the Terms page. There is also no way to reactivate anyone: deactivateUser is one-way and nothing sets deletedAt back to null.
Decision
Reactivation should be manually possible — a separate admin-only page under /users.
Acceptance criteria
Deactivated users see an explanatory screen instead of a login loop
Admin-only reactivation page under /users, listing deactivated accounts
Reactivation restores access and records updatedById via the existing audit columns
The reactivation action is admin-gated with the shared authorization helpers
Known limitation to note in the implementation: a soft-deleted User keeps its email and neonAuthId, which are total unique constraints — so a deactivated person cannot re-sign-up with the same email, and the existing P2002 handler (lib/auth/server.ts:32) exists precisely because of this. Fixing that requires a partial unique index (WHERE "deletedAt" IS NULL), which Prisma cannot express declaratively and needs raw migration SQL. Worth tracking separately; admin reactivation is the workaround in the meantime.
The sign-in-attempt path is already covered since this ticket was written: checkSignInAllowed blocks the OTP send, the session.create.before hook throws ACCOUNT_DEACTIVATED, and LoginView shows the copy. The remaining gap is the live session — a user deactivated mid-session still holds a valid Better Auth session, so getOptionalUser returns null, every gated route bounces to /login, and /login renders a bare sign-in form with no explanation. Fix it by distinguishing "no session" from "session for a deactivated user" in lib/auth/server.ts and routing that case to a dedicated /login/deactivated screen with a sign-out escape. Then add the admin-only /users/deactivated page and a reactivateUser action that clears the soft delete.
Changes
lib/auth/server.ts — split the session row lookup out of resolveRealUser; add getDeactivatedSessionUser(); getCurrentUser routes a deactivated session to /login/deactivated.
app/login/deactivated/page.tsx — the explanatory screen (server component, inherits the centered app/login/layout.tsx).
app/login/page.tsx — bounce a deactivated session to /login/deactivated instead of rendering the sign-in form.
prisma/actions/auth.ts — signOutDeactivatedSession() (signOutUser can't serve this: its getCurrentUser() would redirect the deactivated caller without signing them out).
Add export const getDeactivatedSessionUser = cache(...) returning the row only when deletedAt is set — shares the cached lookup, so it costs no extra query.
In getCurrentUser, before the bypass/login redirects: if (await getDeactivatedSessionUser()) redirect('/login/deactivated') — routing, not denial (annotate).
Add signOutDeactivatedSession() to prisma/actions/auth.ts: throw when there is no deactivated session, else auth.api.signOut, revalidatePath('/', 'layout'); never redirect() (awaited from an event handler).
Build app/login/deactivated/page.tsx: getOptionalUser() → redirect /positions if active; getDeactivatedSessionUser() → redirect /login if absent (complementary conditions, so no loop with /login); render the copy below. metadata: { title: 'Account Deactivated', robots: { index: false } }.
In app/login/page.tsx, after the getOptionalUser() call: when there is no user but getDeactivatedSessionUser() returns one, redirect('/login/deactivated').
Add getDeactivatedUsersForAdmin() to prisma/data/users.ts and AdminDeactivatedUserListItem to lib/types.ts (next to AdminUserListItem, same Prisma.UserGetPayload shape).
Add reactivateUser() to prisma/actions/users.ts per Data & contracts.
Update createUser's pre-check to select: { id: true, deletedAt: true } and branch the error copy; leave the P2002 catch as-is.
Build app/(main)/(auth)/users/deactivated/page.tsx with requireAdminOr404() + PageHeader (backHref="/users", backLabel="Users"), and a loading.tsx skeleton modelled on app/(main)/(auth)/users/loading.tsx (5 columns, no search-count row).
Scope: updateMany({ where: { id: userId, deletedAt: { not: null } }, data: { deletedAt: null, deletedById: null, updatedById: admin.id } }) — the deletedAt filter is what makes a double-submit a no-op instead of a spurious audit write.
count === 0 → throw new Error('User not found or already active') — unreachable from the freshly-rendered list, and not user-actionable.
No self-check needed: the caller is active by definition, so they can never appear in this list.
revalidatePath('/users') and revalidatePath('/users/deactivated').
Auth check: if (!(await getDeactivatedSessionUser())) throw new Error('Forbidden: no deactivated session') — a denial, so it throws (client catches → generic toast).
createUser (modified) — when the pre-check finds a soft-deleted row: { error: 'That email belongs to a deactivated account. Reactivate it from Users → Deactivated accounts.' }; an active row keeps 'A user with this email already exists.'.
UX states
/login/deactivated (centered, no nav — a locked-out user gets no app chrome):
Body: "Your account has been deactivated, so you can't access Aplio right now." Then, muted: "If you think this is a mistake, contact an administrator to have it restored."
text-xs text-muted-foreground: "Signed in as {email}" — so they know which account is locked.
Primary Sign out button (spinner + disabled while pending) → toast "Signed out." → /login. Secondary ghost link Browse open positions → /positions (public, still works).
Error: sign-out returns { error } → that exact toast; throws → "Something went wrong. Please try again."
No loading state needed — the page's own fetch is the route render (a fast cached session lookup).
A11y: single h1, real <button>, focus ring intact, both themes via tokens only.
Empty: EmptyState icon UserRoundCheck, title "No deactivated accounts", description "Accounts you deactivate from Users appear here."
Filtered-empty: single row "No accounts match your search."
Rows: User (name over muted email), Roles (Admin badge, else —), Deactivated (LocalTime precision="date"), Deactivated by (name/email or —), Actions (Reactivate, size="sm").
Search by name/email + aria-live="polite" count via formatTableCount({ noun: 'account' }); default sort deactivated desc; sortable User/Deactivated headers carry aria-sort.
Confirm dialog — title Reactivate {name}?, description "They'll be able to sign in again immediately with the same email and role. Their applications and answers are untouched.", confirm "Reactivate" / pending "Reactivating…", non-destructive. Success toast "User reactivated."; { error } → its own toast; throw → generic toast.
Errors surface as toasts only — no per-page error.tsx; the route-group boundary covers render failures.
/users: variant="outline"Deactivated accounts link left of Create user; /users/deactivated keeps the Users nav item highlighted (isActive matches the prefix). Deactivate dialog's last sentence becomes "You can restore them later from Deactivated accounts."
Testing
Bypass-login as admin → /users → deactivate a test user → toast, row disappears.
Click Deactivated accounts → the user is listed with the right date and "Deactivated by" = the admin; search filters; both column sorts work.
Reactivate → confirm dialog → toast "User reactivated." → row disappears there and reappears on /users; DB shows deletedAt/deletedById null and updatedById = admin.
Empty state: reactivate everyone → "No deactivated accounts". Search with no match → "No accounts match your search."
Live-session lockout: sign in with real auth in a second browser profile → in the admin session deactivate that user → refresh the second profile on any gated page → /login/deactivated, not the sign-in form; its own email is shown.
From that screen: Browse open positions works; Sign out toasts and lands on /login; signing in again toasts "Your account has been deactivated. Please contact an administrator."
Reactivate that user, then sign in again in the second profile → normal access restored.
Direct-navigate to /login/deactivated while signed in normally → redirected to /positions; while signed out → redirected to /login (no loop either way).
/users/deactivated as a non-admin (applicant and manager bypass users) → 404; signed out → login redirect.
Create a user with a deactivated account's email → toast pointing at Deactivated accounts; with an active user's email → the existing duplicate message.
375px / 768px / 1280px, light + dark: the screen and the table stay readable, buttons wrap rather than overflow.
Risks / notes
Clearing deletedById on reactivation drops the record of who deactivated them (there is no audit-log table); updatedById records the reactivating admin, which is what the AC asks for. Keeping a deletedById on an active row would misread as "still deleted".
The email/neonAuthId total unique constraints are unchanged — reactivation is the workaround, and the improved createUser copy makes it discoverable. Partial unique indexes stay deferred to schema.prisma data-integrity & indexing gaps #306.
A dev-bypass cookie pointing at a deactivated user still lands on /login/bypass (the bypass picker) rather than the new screen — dev-only, and the three bypass identities are never deactivated.
Public pages continue to treat a deactivated session as anonymous; only gated routes route to the screen. Intentional — they can still browse open positions.
Problem
resolveRealUserreturnsnullwhenrow.deletedAtis set (lib/auth/server.ts:43) — after a valid Neon session has already resolved. So a deactivated user with working credentials is redirected to/login, authenticates successfully, and lands back on/login. A silent infinite loop, with no explanation.There is no "your account has been deactivated" screen anywhere in the app — the only such text is prose in the Terms page. There is also no way to reactivate anyone:
deactivateUseris one-way and nothing setsdeletedAtback to null.Decision
Reactivation should be manually possible — a separate admin-only page under
/users.Acceptance criteria
/users, listing deactivated accountsupdatedByIdvia the existing audit columnsKnown limitation to note in the implementation: a soft-deleted
Userkeeps itsemailandneonAuthId, which are total unique constraints — so a deactivated person cannot re-sign-up with the same email, and the existing P2002 handler (lib/auth/server.ts:32) exists precisely because of this. Fixing that requires a partial unique index (WHERE "deletedAt" IS NULL), which Prisma cannot express declaratively and needs raw migration SQL. Worth tracking separately; admin reactivation is the workaround in the meantime.Related: #153 (deactivation enforcement in
getCurrentUser), #306 (partial unique indexes, deferred).From the 2026-08-10 full platform audit.
Implementation Plan
Overview
The sign-in-attempt path is already covered since this ticket was written:
checkSignInAllowedblocks the OTP send, thesession.create.beforehook throwsACCOUNT_DEACTIVATED, andLoginViewshows the copy. The remaining gap is the live session — a user deactivated mid-session still holds a valid Better Auth session, sogetOptionalUserreturnsnull, every gated route bounces to/login, and/loginrenders a bare sign-in form with no explanation. Fix it by distinguishing "no session" from "session for a deactivated user" inlib/auth/server.tsand routing that case to a dedicated/login/deactivatedscreen with a sign-out escape. Then add the admin-only/users/deactivatedpage and areactivateUseraction that clears the soft delete.Changes
lib/auth/server.ts— split the session row lookup out ofresolveRealUser; addgetDeactivatedSessionUser();getCurrentUserroutes a deactivated session to/login/deactivated.app/login/deactivated/page.tsx— the explanatory screen (server component, inherits the centeredapp/login/layout.tsx).components/features/deactivated-sign-out.tsx— client leaf: sign-out button + toast +router.push('/login').app/login/page.tsx— bounce a deactivated session to/login/deactivatedinstead of rendering the sign-in form.prisma/actions/auth.ts—signOutDeactivatedSession()(signOutUsercan't serve this: itsgetCurrentUser()would redirect the deactivated caller without signing them out).prisma/data/users.ts—getDeactivatedUsersForAdmin().lib/types.ts—AdminDeactivatedUserListItem.prisma/actions/users.ts—reactivateUser(); makecreateUser's duplicate-email error point admins at reactivation.app/(main)/(auth)/users/deactivated/page.tsx+loading.tsx— admin-only page and skeleton.components/features/deactivated-users-table.tsx— search + sortable table + reactivate confirm dialog.app/(main)/(auth)/users/page.tsx— "Deactivated accounts" link in the header actions.components/features/users-table.tsx— deactivate dialog no longer claims the action is irreversible.tests/db/authorization.test.ts—reactivateUsercases alongside the existing user-action suite.Implementation
lib/auth/server.ts, extract acachedresolveSessionUserRow()(session →prisma.user.findUnique, deleted rows included);resolveRealUserbecomesrow && !row.deletedAt ? row : null.export const getDeactivatedSessionUser = cache(...)returning the row only whendeletedAtis set — shares the cached lookup, so it costs no extra query.getCurrentUser, before the bypass/login redirects:if (await getDeactivatedSessionUser()) redirect('/login/deactivated')— routing, not denial (annotate).signOutDeactivatedSession()toprisma/actions/auth.ts: throw when there is no deactivated session, elseauth.api.signOut,revalidatePath('/', 'layout'); neverredirect()(awaited from an event handler).app/login/deactivated/page.tsx:getOptionalUser()→ redirect/positionsif active;getDeactivatedSessionUser()→ redirect/loginif absent (complementary conditions, so no loop with/login); render the copy below.metadata: { title: 'Account Deactivated', robots: { index: false } }.components/features/deactivated-sign-out.tsx—useTransition,isErrorcheck, generic toast incatch(withunstable_rethrow, matchinguser-menu.tsx).app/login/page.tsx, after thegetOptionalUser()call: when there is no user butgetDeactivatedSessionUser()returns one,redirect('/login/deactivated').getDeactivatedUsersForAdmin()toprisma/data/users.tsandAdminDeactivatedUserListItemtolib/types.ts(next toAdminUserListItem, samePrisma.UserGetPayloadshape).reactivateUser()toprisma/actions/users.tsper Data & contracts.createUser's pre-check toselect: { id: true, deletedAt: true }and branch the error copy; leave the P2002 catch as-is.app/(main)/(auth)/users/deactivated/page.tsxwithrequireAdminOr404()+PageHeader(backHref="/users",backLabel="Users"), and aloading.tsxskeleton modelled onapp/(main)/(auth)/users/loading.tsx(5 columns, no search-count row).components/features/deactivated-users-table.tsxreusinguseSortableTable,SortableHeader,formatTableCount,LocalTime,ConfirmDialog,EmptyState— mirrorusers-table.tsx's target-snapshot + transition + toast pattern./usersand fix the deactivate dialog copy inusers-table.tsx.tests/db/authorization.test.ts: non-admin caller rejects; admin reactivation clearsdeletedAt/deletedByIdand setsupdatedById; reactivating an already-active user throws.Data & contracts
No Prisma schema change, no migration —
deletedAt/deletedById/updatedByIdalready exist onUser.getDeactivatedUsersForAdmin()(prisma/data/users.ts,server-only, admin-gated callers only):where: { deletedAt: { not: null } },selectid, name, email, isAdmin, deletedAt, deletedBy: { select: { name, email } },orderBy: { deletedAt: 'desc' }.deletedAtstaysDate | nullin the generated type despite the filter — render—on the null branch rather than asserting.reactivateUser(input: unknown): Promise<ActionError | void>await requireAdmin()first, before any DB read.z.object({ userId: z.string().min(1) }); parse failure →{ error: 'Invalid input' }.updateMany({ where: { id: userId, deletedAt: { not: null } }, data: { deletedAt: null, deletedById: null, updatedById: admin.id } })— thedeletedAtfilter is what makes a double-submit a no-op instead of a spurious audit write.count === 0→throw new Error('User not found or already active')— unreachable from the freshly-rendered list, and not user-actionable.revalidatePath('/users')andrevalidatePath('/users/deactivated').signOutDeactivatedSession(): Promise<ErrorType | void>if (!(await getDeactivatedSessionUser())) throw new Error('Forbidden: no deactivated session')— a denial, so it throws (client catches → generic toast).auth.api.signOutfailure →console.error+{ error: 'Could not sign out. Please try again.' }, mirroringsignOutUser.revalidatePath('/', 'layout'); noredirect().createUser(modified) — when the pre-check finds a soft-deleted row:{ error: 'That email belongs to a deactivated account. Reactivate it from Users → Deactivated accounts.' }; an active row keeps'A user with this email already exists.'.UX states
/login/deactivated(centered, no nav — a locked-out user gets no app chrome):UserRoundXicon (size-10 text-muted-foreground),h1"Account deactivated".text-xs text-muted-foreground: "Signed in as {email}" — so they know which account is locked./login. Secondary ghost link Browse open positions →/positions(public, still works).{ error }→ that exact toast; throws → "Something went wrong. Please try again."h1, real<button>, focus ring intact, both themes via tokens only./users/deactivated:loading.tsxskeleton matching header + 5-column table.EmptyStateiconUserRoundCheck, title "No deactivated accounts", description "Accounts you deactivate from Users appear here."Adminbadge, else—), Deactivated (LocalTime precision="date"), Deactivated by (name/email or—), Actions (Reactivate,size="sm").aria-live="polite"count viaformatTableCount({ noun: 'account' }); default sortdeactivateddesc; sortable User/Deactivated headers carryaria-sort.Reactivate {name}?, description "They'll be able to sign in again immediately with the same email and role. Their applications and answers are untouched.", confirm "Reactivate" / pending "Reactivating…", non-destructive. Success toast "User reactivated.";{ error }→ its own toast; throw → generic toast.error.tsx; the route-group boundary covers render failures./users:variant="outline"Deactivated accounts link left of Create user;/users/deactivatedkeeps the Users nav item highlighted (isActivematches the prefix). Deactivate dialog's last sentence becomes "You can restore them later from Deactivated accounts."Testing
/users→ deactivate a test user → toast, row disappears./users; DB showsdeletedAt/deletedByIdnull andupdatedById= admin./login/deactivated, not the sign-in form; its own email is shown./login; signing in again toasts "Your account has been deactivated. Please contact an administrator."/login/deactivatedwhile signed in normally → redirected to/positions; while signed out → redirected to/login(no loop either way)./users/deactivatedas a non-admin (applicant and manager bypass users) → 404; signed out → login redirect.Risks / notes
deletedByIdon reactivation drops the record of who deactivated them (there is no audit-log table);updatedByIdrecords the reactivating admin, which is what the AC asks for. Keeping adeletedByIdon an active row would misread as "still deleted".neonAuthIdtotal unique constraints are unchanged — reactivation is the workaround, and the improvedcreateUsercopy makes it discoverable. Partial unique indexes stay deferred to schema.prisma data-integrity & indexing gaps #306./login/bypass(the bypass picker) rather than the new screen — dev-only, and the three bypass identities are never deactivated.