Skip to content

Add an Account Deactivated Screen and Admin Reactivation #387

Description

@b-at-neu

Problem

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.

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: 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).
  • 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/deactivated instead of rendering the sign-in form.
  • prisma/actions/auth.tssignOutDeactivatedSession() (signOutUser can't serve this: its getCurrentUser() would redirect the deactivated caller without signing them out).
  • prisma/data/users.tsgetDeactivatedUsersForAdmin().
  • lib/types.tsAdminDeactivatedUserListItem.
  • prisma/actions/users.tsreactivateUser(); make createUser'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.tsreactivateUser cases alongside the existing user-action suite.

Implementation

  • In lib/auth/server.ts, extract a cached resolveSessionUserRow() (session → prisma.user.findUnique, deleted rows included); resolveRealUser becomes row && !row.deletedAt ? row : null.
  • 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 } }.
  • Build components/features/deactivated-sign-out.tsxuseTransition, isError check, generic toast in catch (with unstable_rethrow, matching user-menu.tsx).
  • 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).
  • Build components/features/deactivated-users-table.tsx reusing useSortableTable, SortableHeader, formatTableCount, LocalTime, ConfirmDialog, EmptyState — mirror users-table.tsx's target-snapshot + transition + toast pattern.
  • Add the header link on /users and fix the deactivate dialog copy in users-table.tsx.
  • Extend tests/db/authorization.test.ts: non-admin caller rejects; admin reactivation clears deletedAt/deletedById and sets updatedById; reactivating an already-active user throws.

Data & contracts

No Prisma schema change, no migrationdeletedAt/deletedById/updatedById already exist on User.

getDeactivatedUsersForAdmin() (prisma/data/users.ts, server-only, admin-gated callers only): where: { deletedAt: { not: null } }, select id, name, email, isAdmin, deletedAt, deletedBy: { select: { name, email } }, orderBy: { deletedAt: 'desc' }. deletedAt stays Date | null in 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.
  • zod: z.object({ userId: z.string().min(1) }); parse failure → { error: 'Invalid input' }.
  • 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 === 0throw 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').

signOutDeactivatedSession(): Promise<ErrorType | void>

  • Auth check: if (!(await getDeactivatedSessionUser())) throw new Error('Forbidden: no deactivated session') — a denial, so it throws (client catches → generic toast).
  • auth.api.signOut failure → console.error + { error: 'Could not sign out. Please try again.' }, mirroring signOutUser.
  • revalidatePath('/', 'layout'); no redirect().

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):

  • UserRoundX icon (size-10 text-muted-foreground), h1 "Account deactivated".
  • 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.

/users/deactivated:

  • Loading: loading.tsx skeleton matching header + 5-column table.
  • 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.

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by ClaudeenhancementNew feature or requestpr openedPull request has been opened

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions