From 5f950f7a0f29ac30170b2ed9a33c597ff6a56738 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:11:51 -0400 Subject: [PATCH 1/6] #387 route a deactivated live session to an explanatory screen resolveRealUser used to return null after a valid session had already resolved, silently bouncing a deactivated user back to /login forever. Distinguishes that case and sends it to /login/deactivated instead. Co-Authored-By: Claude Sonnet 4.6 --- app/login/deactivated/page.tsx | 49 ++++++++++++++++++++ app/login/page.tsx | 24 +++------- components/features/deactivated-sign-out.tsx | 43 +++++++++++++++++ lib/auth/server.ts | 28 +++++++---- lib/constants.ts | 7 +-- prisma/actions/auth.ts | 18 ++++++- tests/stubs/auth-server.ts | 6 +++ 7 files changed, 141 insertions(+), 34 deletions(-) create mode 100644 app/login/deactivated/page.tsx create mode 100644 components/features/deactivated-sign-out.tsx diff --git a/app/login/deactivated/page.tsx b/app/login/deactivated/page.tsx new file mode 100644 index 00000000..2b991e2f --- /dev/null +++ b/app/login/deactivated/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { UserRoundX } from 'lucide-react'; + +import { getDeactivatedSessionUser, getOptionalUser } from '@/lib/auth/server'; + +import { DeactivatedSignOut } from '@/components/features/deactivated-sign-out'; +import { Button } from '@/components/ui/button'; + +export const metadata: Metadata = { + title: 'Account Deactivated', + robots: { index: false }, +}; + +export default async function AccountDeactivatedPage() { + const activeUser = await getOptionalUser(); + if (activeUser) redirect('/positions'); + + const deactivatedUser = await getDeactivatedSessionUser(); + if (!deactivatedUser) redirect('/login'); + + return ( +
+ +
+

Account deactivated

+

+ Your account has been deactivated, so you can't access Aplio + right now. +

+

+ If you think this is a mistake, contact an administrator to have it + restored. +

+
+

+ Signed in as {deactivatedUser.email} +

+
+ + +
+
+ ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 4a6c053b..b960d5cb 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,21 +2,13 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { redirect } from 'next/navigation'; -import { TriangleAlert } from 'lucide-react'; - import { safeRedirectTo, withRedirectTo } from '@/lib/auth/redirect'; -import { getOptionalUser } from '@/lib/auth/server'; -import { - ACCOUNT_DEACTIVATED_MESSAGE, - LOGIN_DEACTIVATED_REASON, - PRIVACY_HREF, - TERMS_HREF, -} from '@/lib/constants'; +import { getDeactivatedSessionUser, getOptionalUser } from '@/lib/auth/server'; +import { PRIVACY_HREF, TERMS_HREF } from '@/lib/constants'; import { isBypassAllowed } from '@/lib/utils'; import { LoginView } from '@/components/features/login-view'; import { NameField } from '@/components/features/name-field'; -import { WarningCallout } from '@/components/ui/warning-callout'; export const metadata: Metadata = { title: 'Sign In' }; @@ -30,16 +22,19 @@ export default async function SignInPage({ }: { searchParams: Promise>; }) { - const { redirectTo, reason } = await searchParams; + const { redirectTo } = await searchParams; const safeTo = safeRedirectTo(redirectTo); const applyContext = isApplyRedirect(safeTo); - const isDeactivated = reason === LOGIN_DEACTIVATED_REASON; const user = await getOptionalUser(); // Authenticated user with a name set — send them into the app. if (user?.name?.trim()) redirect(safeTo); // Authenticated user with no name — fall through to render the name form below. + // A live session for a deactivated row — routing, not denial. + if (!user && (await getDeactivatedSessionUser())) + redirect('/login/deactivated'); + // Must match isBypassAllowed, so the affordance and the action agree. const isDev = isBypassAllowed(); @@ -60,11 +55,6 @@ export default async function SignInPage({ return (
- {isDeactivated && !user && ( - - {ACCOUNT_DEACTIVATED_MESSAGE} - - )} {user ? ( ) : ( diff --git a/components/features/deactivated-sign-out.tsx b/components/features/deactivated-sign-out.tsx new file mode 100644 index 00000000..4a76de9e --- /dev/null +++ b/components/features/deactivated-sign-out.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { unstable_rethrow, useRouter } from 'next/navigation'; +import { useTransition } from 'react'; + +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { signOutDeactivatedSession } from '@/prisma/actions/auth'; + +import { isError } from '@/lib/utils'; + +import { Button } from '@/components/ui/button'; + +export function DeactivatedSignOut() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + function handleSignOut() { + startTransition(async () => { + try { + const result = await signOutDeactivatedSession(); + if (isError(result)) { + toast.error(result.error); + return; + } + toast.success('Signed out.'); + router.push('/login'); + } catch (error) { + unstable_rethrow(error); + console.error('Deactivated sign-out failed unexpectedly', error); + toast.error('Something went wrong. Please try again.'); + } + }); + } + + return ( + + ); +} diff --git a/lib/auth/server.ts b/lib/auth/server.ts index e9af1893..8a54bc74 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -6,13 +6,12 @@ import type { User } from '@/prisma/client'; import { auth } from '@/lib/auth/config'; import { withRedirectTo } from '@/lib/auth/redirect'; -import { LOGIN_DEACTIVATED_REASON } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; import { isBypassAllowed } from '@/lib/utils'; type UserResolution = | { status: 'active'; user: User } - | { status: 'deactivated' } + | { status: 'deactivated'; user: User } | { status: 'anonymous' }; // Better Auth owns the User row, so the session id is the row id. @@ -26,7 +25,7 @@ const resolveUser = cache( }); if (row) return row.deletedAt - ? { status: 'deactivated' } + ? { status: 'deactivated', user: row } : { status: 'active', user: row }; } } @@ -39,7 +38,7 @@ const resolveUser = cache( }); if (!row) return { status: 'anonymous' }; return row.deletedAt - ? { status: 'deactivated' } + ? { status: 'deactivated', user: row } : { status: 'active', user: row }; }, ); @@ -49,7 +48,8 @@ export async function getIsBypass(): Promise { return Boolean((await cookies()).get('dev-bypass-user-id')?.value); } -// Treats a deactivated caller as anonymous. +// Treats a deactivated caller as anonymous — getDeactivatedSessionUser is the +// one that routes it to the explanatory screen. export const getOptionalUser = cache( async function getOptionalUser(): Promise { const resolution = await resolveUser(); @@ -57,6 +57,16 @@ export const getOptionalUser = cache( }, ); +// A live session whose row has since been soft-deleted — distinct from "no +// session" so getCurrentUser can route it to the explanatory screen instead +// of a silent sign-in loop. +export const getDeactivatedSessionUser = cache( + async function getDeactivatedSessionUser(): Promise { + const resolution = await resolveUser(); + return resolution.status === 'deactivated' ? resolution.user : null; + }, +); + export async function currentPath(): Promise { return (await headers()).get('x-current-path'); } @@ -67,11 +77,9 @@ export const getCurrentUser = cache( const resolution = await resolveUser(); if (resolution.status === 'active') return resolution.user; - if (resolution.status === 'deactivated') { - const loginUrl = withRedirectTo('/login', await currentPath()); - const separator = loginUrl.includes('?') ? '&' : '?'; - redirect(`${loginUrl}${separator}reason=${LOGIN_DEACTIVATED_REASON}`); - } + // Routing, not denial: a live session for a deactivated row goes to the + // explanatory screen instead of silently bouncing back to /login forever. + if (resolution.status === 'deactivated') redirect('/login/deactivated'); const base = isBypassAllowed() ? '/login/bypass' : '/login'; redirect(withRedirectTo(base, await currentPath())); diff --git a/lib/constants.ts b/lib/constants.ts index 9d87cdfc..66f754e5 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -535,15 +535,10 @@ export const nameSchema = z.object({ // branch on this specific refusal instead of a generic OTP failure. export const ACCOUNT_DEACTIVATED_ERROR_CODE = 'ACCOUNT_DEACTIVATED'; -// Shared across checkSignInAllowed, LoginView's OTP failure, and /login's -// reason=deactivated notice so the copy can't drift between surfaces. +// Shared between checkSignInAllowed and LoginView's OTP failure so the copy can't drift between surfaces. export const ACCOUNT_DEACTIVATED_MESSAGE = 'Your account has been deactivated. Please contact an administrator.'; -// Value of /login's ?reason= query param when getCurrentUser redirects a -// deactivated caller there. -export const LOGIN_DEACTIVATED_REASON = 'deactivated'; - // Shared between the checkSignInAllowed server action and LoginView's email step resolver. export const signInEmailSchema = z.object({ email: z.string().email('Please enter a valid email address'), diff --git a/prisma/actions/auth.ts b/prisma/actions/auth.ts index e5ebc90e..4f43f758 100644 --- a/prisma/actions/auth.ts +++ b/prisma/actions/auth.ts @@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; import { auth } from '@/lib/auth/config'; -import { getCurrentUser } from '@/lib/auth/server'; +import { getCurrentUser, getDeactivatedSessionUser } from '@/lib/auth/server'; import { ACCOUNT_DEACTIVATED_MESSAGE, OTP_RESEND_COOLDOWN_SECONDS, @@ -64,3 +64,19 @@ export async function signOutUser(): Promise { revalidatePath('/', 'layout'); } + +// signOutUser can't serve this: its getCurrentUser() call would redirect a +// deactivated caller to /login/deactivated instead of signing them out. +export async function signOutDeactivatedSession(): Promise { + if (!(await getDeactivatedSessionUser())) + throw new Error('Forbidden: no deactivated session'); + + try { + await auth.api.signOut({ headers: await headers() }); + } catch (error) { + console.error('signOutDeactivatedSession: signOut failed', error); + return { error: 'Could not sign out. Please try again.' }; + } + + revalidatePath('/', 'layout'); +} diff --git a/tests/stubs/auth-server.ts b/tests/stubs/auth-server.ts index 992d3921..c25ee73e 100644 --- a/tests/stubs/auth-server.ts +++ b/tests/stubs/auth-server.ts @@ -17,6 +17,12 @@ export async function getOptionalUser(): Promise { return caller; } +// No live-session concept in this stub — actAs(null) models "signed out", +// never "deactivated session", so this is always null. +export async function getDeactivatedSessionUser(): Promise { + return null; +} + export async function getIsBypass(): Promise { return false; } From 43a616e1efbbd9e86db1106b682c94edc772694a Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:12:00 -0400 Subject: [PATCH 2/6] #387 add admin-only page to reactivate deactivated accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deactivation was previously one-way, with no way to restore access. Adds Users → Deactivated accounts, listing soft-deleted rows with a reactivate action scoped by the existing authorization helpers, and points createUser's duplicate-email error at it. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/users/deactivated/loading.tsx | 44 ++++ app/(main)/(auth)/users/deactivated/page.tsx | 29 +++ app/(main)/(auth)/users/page.tsx | 10 +- .../features/deactivated-users-table.tsx | 229 ++++++++++++++++++ lib/types.ts | 12 + prisma/actions/users.ts | 38 ++- prisma/data/users.ts | 23 +- 7 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 app/(main)/(auth)/users/deactivated/loading.tsx create mode 100644 app/(main)/(auth)/users/deactivated/page.tsx create mode 100644 components/features/deactivated-users-table.tsx diff --git a/app/(main)/(auth)/users/deactivated/loading.tsx b/app/(main)/(auth)/users/deactivated/loading.tsx new file mode 100644 index 00000000..3507caf2 --- /dev/null +++ b/app/(main)/(auth)/users/deactivated/loading.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function DeactivatedUsersLoading() { + return ( +
+
+ + + +
+ + + +
+
+
+ + + + + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ + +
+ + + +
+ +
+
+ ))} +
+
+ ); +} diff --git a/app/(main)/(auth)/users/deactivated/page.tsx b/app/(main)/(auth)/users/deactivated/page.tsx new file mode 100644 index 00000000..95edb817 --- /dev/null +++ b/app/(main)/(auth)/users/deactivated/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; + +import { getDeactivatedUsersForAdmin } from '@/prisma/data/users'; + +import { requireAdminOr404 } from '@/lib/auth/guards'; + +import { DeactivatedUsersTable } from '@/components/features/deactivated-users-table'; +import { PageHeader } from '@/components/layouts/page-header'; + +export const metadata: Metadata = { title: 'Deactivated Accounts' }; + +export default async function DeactivatedUsersPage() { + await requireAdminOr404(); + + const users = await getDeactivatedUsersForAdmin(); + + return ( +
+ + + +
+ ); +} diff --git a/app/(main)/(auth)/users/page.tsx b/app/(main)/(auth)/users/page.tsx index 95628dad..83f1dd6d 100644 --- a/app/(main)/(auth)/users/page.tsx +++ b/app/(main)/(auth)/users/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next'; +import Link from 'next/link'; import { getUsersForAdmin } from '@/prisma/data/users'; @@ -21,7 +22,14 @@ export default async function UsersPage() { Create user} />} + actions={ + <> + + Create user} /> + + } /> diff --git a/components/features/deactivated-users-table.tsx b/components/features/deactivated-users-table.tsx new file mode 100644 index 00000000..3d16244f --- /dev/null +++ b/components/features/deactivated-users-table.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useState, useTransition } from 'react'; + +import { UserRoundCheck } from 'lucide-react'; +import { toast } from 'sonner'; + +import { reactivateUser } from '@/prisma/actions/users'; + +import type { AdminDeactivatedUserListItem } from '@/lib/types'; +import { + type SortableColumn, + useSortableTable, +} from '@/lib/use-sortable-table'; +import { formatDate, formatTableCount } from '@/lib/utils'; + +import { SortableHeader } from '@/components/features/sortable-header'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +interface DeactivatedUsersTableProps { + users: AdminDeactivatedUserListItem[]; +} + +const COLUMNS: SortableColumn[] = [ + { key: 'user', accessor: (u) => u.name ?? u.email }, + { key: 'deactivated', accessor: (u) => u.deletedAt }, +]; + +// Snapshot at click time — the dialog keeps naming the right user through +// Radix's exit animation even if a revalidation drops the row mid-flight. +interface ReactivateTarget { + id: string; + displayName: string; +} + +export function DeactivatedUsersTable({ users }: DeactivatedUsersTableProps) { + const [query, setQuery] = useState(''); + const [reactivateTarget, setReactivateTarget] = + useState(null); + const [reactivateDialogOpen, setReactivateDialogOpen] = useState(false); + const [isReactivating, startReactivateTransition] = useTransition(); + + const q = query.trim().toLowerCase(); + const filtered = q + ? users.filter( + (u) => + (u.name ?? '').toLowerCase().includes(q) || + u.email.toLowerCase().includes(q), + ) + : users; + + const { sortedRows, sort, toggle, ariaSort } = useSortableTable( + filtered, + COLUMNS, + { defaultSort: { key: 'deactivated', direction: 'desc' } }, + ); + + function handleReactivateConfirm() { + if (!reactivateTarget) return; + const target = reactivateTarget; + startReactivateTransition(async () => { + try { + const result = await reactivateUser({ userId: target.id }); + if (result?.error) { + toast.error(result.error); + return; + } + toast.success('User reactivated.'); + setReactivateDialogOpen(false); + } catch { + toast.error('Something went wrong. Please try again.'); + } + }); + } + + if (users.length === 0) + return ( + + ); + + return ( + <> +
+
+
+ + setQuery(e.target.value)} + className="max-w-sm" + /> +
+ +

+ {formatTableCount({ + shown: filtered.length, + total: users.length, + noun: 'account', + isFiltered: !!q, + })} +

+
+ + + + + + toggle('user')} + /> + Roles + toggle('deactivated')} + /> + Deactivated by + Actions + + + + {sortedRows.length === 0 ? ( + + + No accounts match your search. + + + ) : ( + sortedRows.map((user) => { + const displayName = user.name ?? user.email; + return ( + + +
+ {displayName} + {user.name && ( + + {user.email} + + )} +
+
+ + {user.isAdmin ? ( + Admin + ) : ( + + )} + + + {user.deletedAt ? ( + formatDate(user.deletedAt) + ) : ( + + )} + + + {user.deletedBy ? ( + (user.deletedBy.name ?? user.deletedBy.email) + ) : ( + + )} + + + + +
+ ); + }) + )} +
+
+
+
+ + + + ); +} diff --git a/lib/types.ts b/lib/types.ts index 9c1f34ff..5b933a2f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -310,6 +310,18 @@ export type AdminUserListItem = Prisma.UserGetPayload<{ }; }>; +// Exposes other users' identities — admin-gated contexts only, never a non-admin client. +export type AdminDeactivatedUserListItem = Prisma.UserGetPayload<{ + select: { + id: true; + name: true; + email: true; + isAdmin: true; + deletedAt: true; + deletedBy: { select: { name: true; email: true } }; + }; +}>; + // Aggregate only — never exposes individual applicant identity. export type PositionApplicationStats = { positionId: string; diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index 43c192d3..3d8cf0b1 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -17,6 +17,8 @@ const toggleAdminSchema = z.object({ const deactivateSchema = z.object({ userId: z.string().min(1) }); +const reactivateSchema = z.object({ userId: z.string().min(1) }); + type ActionError = { error: string }; export async function toggleUserAdmin( @@ -78,6 +80,32 @@ export async function deactivateUser( revalidatePath('/users'); } +// Caller is active by definition (getCurrentUser resolves only live rows), so +// they can never appear in the deactivated list — no self-reactivation guard needed. +export async function reactivateUser( + input: unknown, +): Promise { + const admin = await requireAdmin(); + + const parsed = reactivateSchema.safeParse(input); + if (!parsed.success) return { error: 'Invalid input' }; + + const { userId } = parsed.data; + + // Scoping to deletedAt: { not: null } makes a double-submit a no-op + // instead of a spurious audit write. + const result = await prisma.user.updateMany({ + where: { id: userId, deletedAt: { not: null } }, + data: { deletedAt: null, deletedById: null, updatedById: admin.id }, + }); + + // Not reachable from the freshly-rendered deactivated list → unexpected → throw. + if (result.count === 0) throw new Error('User not found or already active'); + + revalidatePath('/users'); + revalidatePath('/users/deactivated'); +} + export async function createUser(input: unknown): Promise { const admin = await requireAdmin(); @@ -89,9 +117,15 @@ export async function createUser(input: unknown): Promise { // Racy — the P2002 catch below is what actually guarantees uniqueness. const existing = await prisma.user.findFirst({ where: { email }, - select: { id: true }, + select: { id: true, deletedAt: true }, }); - if (existing) return { error: 'A user with this email already exists.' }; + if (existing) + return existing.deletedAt + ? { + error: + 'That email belongs to a deactivated account. Reactivate it from Users → Deactivated accounts.', + } + : { error: 'A user with this email already exists.' }; // Verified in better-auth's sign-in/email-otp route: it matches this row // by exact email before ever inserting, so it's found here, never duplicated. diff --git a/prisma/data/users.ts b/prisma/data/users.ts index 724ba87e..fa0b266c 100644 --- a/prisma/data/users.ts +++ b/prisma/data/users.ts @@ -2,7 +2,10 @@ import 'server-only'; import { PUBLISHED_POSITION_WHERE } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; -import type { AdminUserListItem } from '@/lib/types'; +import type { + AdminDeactivatedUserListItem, + AdminUserListItem, +} from '@/lib/types'; // Exposes user identities — admin-gated callers only. export async function getUsersForAdmin(): Promise { @@ -34,3 +37,21 @@ export async function getUsersForAdmin(): Promise { orderBy: { createdAt: 'desc' }, }); } + +// Exposes other users' identities — admin-gated callers only. +export async function getDeactivatedUsersForAdmin(): Promise< + AdminDeactivatedUserListItem[] +> { + return prisma.user.findMany({ + where: { deletedAt: { not: null } }, + select: { + id: true, + name: true, + email: true, + isAdmin: true, + deletedAt: true, + deletedBy: { select: { name: true, email: true } }, + }, + orderBy: { deletedAt: 'desc' }, + }); +} From 3880d2b2c10466e13dd826281d22311fbafa4032 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:12:05 -0400 Subject: [PATCH 3/6] #387 add authorization tests for reactivateUser Co-Authored-By: Claude Sonnet 4.6 --- tests/db/authorization.test.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 47b11263..4da78366 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -29,6 +29,7 @@ import { import { createUser, deactivateUser, + reactivateUser, toggleUserAdmin, } from '@/prisma/actions/users'; import type { Application, Position, User } from '@/prisma/client'; @@ -560,6 +561,41 @@ describe('toggleUserAdmin / deactivateUser / createUser', () => { }); }); +describe('reactivateUser', () => { + it('rejects a non-admin caller', async () => { + const target = await createTestUser({ deletedAt: new Date() }); + actAs(applicant); + await expect(reactivateUser({ userId: target.id })).rejects.toThrow(); + }); + + it('clears the soft delete and records the reactivating admin', async () => { + const deactivator = await createTestUser({ isAdmin: true }); + const target = await createTestUser({ + deletedAt: new Date(), + deletedBy: { connect: { id: deactivator.id } }, + }); + + actAs(admin); + const result = await reactivateUser({ userId: target.id }); + expect(result).toBeUndefined(); + + const updated = await prisma.user.findUniqueOrThrow({ + where: { id: target.id }, + select: { deletedAt: true, deletedById: true, updatedById: true }, + }); + expect(updated.deletedAt).toBeNull(); + expect(updated.deletedById).toBeNull(); + expect(updated.updatedById).toBe(admin.id); + }); + + it('throws when reactivating an already-active user', async () => { + actAs(admin); + await expect(reactivateUser({ userId: applicant.id })).rejects.toThrow( + 'User not found or already active', + ); + }); +}); + describe('createGlobalQuestion / updateGlobalQuestion / deleteGlobalQuestion', () => { it('rejects a manager and an applicant', async () => { const input = { From ad3ccb9807377aa74227fc75d011ea57d879f72c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Tue, 18 Aug 2026 19:20:00 -0400 Subject: [PATCH 4/6] #387 address review feedback Co-Authored-By: Claude Sonnet 4.6 --- prisma/actions/users.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index 3d8cf0b1..ee493eb3 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -15,9 +15,7 @@ const toggleAdminSchema = z.object({ makeAdmin: z.boolean(), }); -const deactivateSchema = z.object({ userId: z.string().min(1) }); - -const reactivateSchema = z.object({ userId: z.string().min(1) }); +const userIdSchema = z.object({ userId: z.string().min(1) }); type ActionError = { error: string }; @@ -52,7 +50,7 @@ export async function deactivateUser( ): Promise { const user = await requireAdmin(); - const parsed = deactivateSchema.safeParse(input); + const parsed = userIdSchema.safeParse(input); if (!parsed.success) return { error: 'Invalid input' }; const { userId } = parsed.data; @@ -87,7 +85,7 @@ export async function reactivateUser( ): Promise { const admin = await requireAdmin(); - const parsed = reactivateSchema.safeParse(input); + const parsed = userIdSchema.safeParse(input); if (!parsed.success) return { error: 'Invalid input' }; const { userId } = parsed.data; From f8e96d6346e8c8d55a136b2b558e4c8959d0e4e7 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 10:21:01 -0400 Subject: [PATCH 5/6] #387 remove in-app deactivated-user insight and reactivation #387 was rescoped: reactivation stays a database operation, so this drops the admin-only deactivated-accounts page/table, the reactivateUser action and its query, the entry point link, and the now-unused type. Copy that referenced the removed page is updated to point at contacting an administrator instead. Extends the authorization test suite to assert deactivated users are excluded from admin-facing queries. Co-Authored-By: Claude Sonnet 4.6 --- .../(auth)/users/deactivated/loading.tsx | 44 ---- app/(main)/(auth)/users/deactivated/page.tsx | 29 --- app/(main)/(auth)/users/page.tsx | 10 +- .../features/deactivated-users-table.tsx | 229 ------------------ lib/types.ts | 12 - prisma/actions/users.ts | 28 +-- prisma/data/users.ts | 23 +- tests/db/authorization.test.ts | 36 +-- 8 files changed, 13 insertions(+), 398 deletions(-) delete mode 100644 app/(main)/(auth)/users/deactivated/loading.tsx delete mode 100644 app/(main)/(auth)/users/deactivated/page.tsx delete mode 100644 components/features/deactivated-users-table.tsx diff --git a/app/(main)/(auth)/users/deactivated/loading.tsx b/app/(main)/(auth)/users/deactivated/loading.tsx deleted file mode 100644 index 3507caf2..00000000 --- a/app/(main)/(auth)/users/deactivated/loading.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton'; - -export default function DeactivatedUsersLoading() { - return ( -
-
- - - -
- - - -
-
-
- - - - - -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- - - -
- -
-
- ))} -
-
- ); -} diff --git a/app/(main)/(auth)/users/deactivated/page.tsx b/app/(main)/(auth)/users/deactivated/page.tsx deleted file mode 100644 index 95edb817..00000000 --- a/app/(main)/(auth)/users/deactivated/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { Metadata } from 'next'; - -import { getDeactivatedUsersForAdmin } from '@/prisma/data/users'; - -import { requireAdminOr404 } from '@/lib/auth/guards'; - -import { DeactivatedUsersTable } from '@/components/features/deactivated-users-table'; -import { PageHeader } from '@/components/layouts/page-header'; - -export const metadata: Metadata = { title: 'Deactivated Accounts' }; - -export default async function DeactivatedUsersPage() { - await requireAdminOr404(); - - const users = await getDeactivatedUsersForAdmin(); - - return ( -
- - - -
- ); -} diff --git a/app/(main)/(auth)/users/page.tsx b/app/(main)/(auth)/users/page.tsx index 83f1dd6d..95628dad 100644 --- a/app/(main)/(auth)/users/page.tsx +++ b/app/(main)/(auth)/users/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import Link from 'next/link'; import { getUsersForAdmin } from '@/prisma/data/users'; @@ -22,14 +21,7 @@ export default async function UsersPage() { - - Create user} /> - - } + actions={Create user} />} /> diff --git a/components/features/deactivated-users-table.tsx b/components/features/deactivated-users-table.tsx deleted file mode 100644 index 3d16244f..00000000 --- a/components/features/deactivated-users-table.tsx +++ /dev/null @@ -1,229 +0,0 @@ -'use client'; - -import { useState, useTransition } from 'react'; - -import { UserRoundCheck } from 'lucide-react'; -import { toast } from 'sonner'; - -import { reactivateUser } from '@/prisma/actions/users'; - -import type { AdminDeactivatedUserListItem } from '@/lib/types'; -import { - type SortableColumn, - useSortableTable, -} from '@/lib/use-sortable-table'; -import { formatDate, formatTableCount } from '@/lib/utils'; - -import { SortableHeader } from '@/components/features/sortable-header'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { Card } from '@/components/ui/card'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { EmptyState } from '@/components/ui/empty-state'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; - -interface DeactivatedUsersTableProps { - users: AdminDeactivatedUserListItem[]; -} - -const COLUMNS: SortableColumn[] = [ - { key: 'user', accessor: (u) => u.name ?? u.email }, - { key: 'deactivated', accessor: (u) => u.deletedAt }, -]; - -// Snapshot at click time — the dialog keeps naming the right user through -// Radix's exit animation even if a revalidation drops the row mid-flight. -interface ReactivateTarget { - id: string; - displayName: string; -} - -export function DeactivatedUsersTable({ users }: DeactivatedUsersTableProps) { - const [query, setQuery] = useState(''); - const [reactivateTarget, setReactivateTarget] = - useState(null); - const [reactivateDialogOpen, setReactivateDialogOpen] = useState(false); - const [isReactivating, startReactivateTransition] = useTransition(); - - const q = query.trim().toLowerCase(); - const filtered = q - ? users.filter( - (u) => - (u.name ?? '').toLowerCase().includes(q) || - u.email.toLowerCase().includes(q), - ) - : users; - - const { sortedRows, sort, toggle, ariaSort } = useSortableTable( - filtered, - COLUMNS, - { defaultSort: { key: 'deactivated', direction: 'desc' } }, - ); - - function handleReactivateConfirm() { - if (!reactivateTarget) return; - const target = reactivateTarget; - startReactivateTransition(async () => { - try { - const result = await reactivateUser({ userId: target.id }); - if (result?.error) { - toast.error(result.error); - return; - } - toast.success('User reactivated.'); - setReactivateDialogOpen(false); - } catch { - toast.error('Something went wrong. Please try again.'); - } - }); - } - - if (users.length === 0) - return ( - - ); - - return ( - <> -
-
-
- - setQuery(e.target.value)} - className="max-w-sm" - /> -
- -

- {formatTableCount({ - shown: filtered.length, - total: users.length, - noun: 'account', - isFiltered: !!q, - })} -

-
- - - - - - toggle('user')} - /> - Roles - toggle('deactivated')} - /> - Deactivated by - Actions - - - - {sortedRows.length === 0 ? ( - - - No accounts match your search. - - - ) : ( - sortedRows.map((user) => { - const displayName = user.name ?? user.email; - return ( - - -
- {displayName} - {user.name && ( - - {user.email} - - )} -
-
- - {user.isAdmin ? ( - Admin - ) : ( - - )} - - - {user.deletedAt ? ( - formatDate(user.deletedAt) - ) : ( - - )} - - - {user.deletedBy ? ( - (user.deletedBy.name ?? user.deletedBy.email) - ) : ( - - )} - - - - -
- ); - }) - )} -
-
-
-
- - - - ); -} diff --git a/lib/types.ts b/lib/types.ts index 5b933a2f..9c1f34ff 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -310,18 +310,6 @@ export type AdminUserListItem = Prisma.UserGetPayload<{ }; }>; -// Exposes other users' identities — admin-gated contexts only, never a non-admin client. -export type AdminDeactivatedUserListItem = Prisma.UserGetPayload<{ - select: { - id: true; - name: true; - email: true; - isAdmin: true; - deletedAt: true; - deletedBy: { select: { name: true; email: true } }; - }; -}>; - // Aggregate only — never exposes individual applicant identity. export type PositionApplicationStats = { positionId: string; diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index ee493eb3..e908bffd 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -78,32 +78,6 @@ export async function deactivateUser( revalidatePath('/users'); } -// Caller is active by definition (getCurrentUser resolves only live rows), so -// they can never appear in the deactivated list — no self-reactivation guard needed. -export async function reactivateUser( - input: unknown, -): Promise { - const admin = await requireAdmin(); - - const parsed = userIdSchema.safeParse(input); - if (!parsed.success) return { error: 'Invalid input' }; - - const { userId } = parsed.data; - - // Scoping to deletedAt: { not: null } makes a double-submit a no-op - // instead of a spurious audit write. - const result = await prisma.user.updateMany({ - where: { id: userId, deletedAt: { not: null } }, - data: { deletedAt: null, deletedById: null, updatedById: admin.id }, - }); - - // Not reachable from the freshly-rendered deactivated list → unexpected → throw. - if (result.count === 0) throw new Error('User not found or already active'); - - revalidatePath('/users'); - revalidatePath('/users/deactivated'); -} - export async function createUser(input: unknown): Promise { const admin = await requireAdmin(); @@ -121,7 +95,7 @@ export async function createUser(input: unknown): Promise { return existing.deletedAt ? { error: - 'That email belongs to a deactivated account. Reactivate it from Users → Deactivated accounts.', + 'That email belongs to a deactivated account. Contact an administrator to reactivate it.', } : { error: 'A user with this email already exists.' }; diff --git a/prisma/data/users.ts b/prisma/data/users.ts index fa0b266c..724ba87e 100644 --- a/prisma/data/users.ts +++ b/prisma/data/users.ts @@ -2,10 +2,7 @@ import 'server-only'; import { PUBLISHED_POSITION_WHERE } from '@/lib/constants'; import { prisma } from '@/lib/prisma'; -import type { - AdminDeactivatedUserListItem, - AdminUserListItem, -} from '@/lib/types'; +import type { AdminUserListItem } from '@/lib/types'; // Exposes user identities — admin-gated callers only. export async function getUsersForAdmin(): Promise { @@ -37,21 +34,3 @@ export async function getUsersForAdmin(): Promise { orderBy: { createdAt: 'desc' }, }); } - -// Exposes other users' identities — admin-gated callers only. -export async function getDeactivatedUsersForAdmin(): Promise< - AdminDeactivatedUserListItem[] -> { - return prisma.user.findMany({ - where: { deletedAt: { not: null } }, - select: { - id: true, - name: true, - email: true, - isAdmin: true, - deletedAt: true, - deletedBy: { select: { name: true, email: true } }, - }, - orderBy: { deletedAt: 'desc' }, - }); -} diff --git a/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 4da78366..a0d091e7 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -29,7 +29,6 @@ import { import { createUser, deactivateUser, - reactivateUser, toggleUserAdmin, } from '@/prisma/actions/users'; import type { Application, Position, User } from '@/prisma/client'; @@ -44,6 +43,7 @@ import { } from '@/prisma/data/applications'; import { checkPositionAccess, isManager } from '@/prisma/data/managers'; import { getManagedPositions } from '@/prisma/data/positions'; +import { getUsersForAdmin } from '@/prisma/data/users'; import { requireAdmin, @@ -561,37 +561,21 @@ describe('toggleUserAdmin / deactivateUser / createUser', () => { }); }); -describe('reactivateUser', () => { - it('rejects a non-admin caller', async () => { - const target = await createTestUser({ deletedAt: new Date() }); - actAs(applicant); - await expect(reactivateUser({ userId: target.id })).rejects.toThrow(); - }); - - it('clears the soft delete and records the reactivating admin', async () => { - const deactivator = await createTestUser({ isAdmin: true }); +describe('deactivated users excluded from admin queries', () => { + it('never appears in getUsersForAdmin or searchUsers', async () => { const target = await createTestUser({ + name: 'Deactivated Target', deletedAt: new Date(), - deletedBy: { connect: { id: deactivator.id } }, }); - actAs(admin); - const result = await reactivateUser({ userId: target.id }); - expect(result).toBeUndefined(); - - const updated = await prisma.user.findUniqueOrThrow({ - where: { id: target.id }, - select: { deletedAt: true, deletedById: true, updatedById: true }, - }); - expect(updated.deletedAt).toBeNull(); - expect(updated.deletedById).toBeNull(); - expect(updated.updatedById).toBe(admin.id); - }); + const adminList = (await getUsersForAdmin()).map((u) => u.id); + expect(adminList).not.toContain(target.id); - it('throws when reactivating an already-active user', async () => { actAs(admin); - await expect(reactivateUser({ userId: applicant.id })).rejects.toThrow( - 'User not found or already active', + const searchResult = await searchUsers({ query: target.email }); + if (isError(searchResult)) throw new Error('expected a result array'); + expect(searchResult.some((row) => row.primaryEmail === target.email)).toBe( + false, ); }); }); From 3e5a5b7cb4cf9400d3b78be1e884a893e4fafe15 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Wed, 19 Aug 2026 15:40:20 -0400 Subject: [PATCH 6/6] #387 address review feedback Points admin-facing deactivation copy at the real remediation (database change, contact engineering) instead of "an administrator," trims an over-length comment, and fixes deactivated-screen text hierarchy. Co-Authored-By: Claude Sonnet 4.6 --- app/login/deactivated/page.tsx | 2 +- components/features/users-table.tsx | 4 ++-- lib/auth/server.ts | 3 +-- prisma/actions/users.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/login/deactivated/page.tsx b/app/login/deactivated/page.tsx index 2b991e2f..3ac9bfe0 100644 --- a/app/login/deactivated/page.tsx +++ b/app/login/deactivated/page.tsx @@ -26,7 +26,7 @@ export default async function AccountDeactivatedPage() {

Account deactivated

-

+

Your account has been deactivated, so you can't access Aplio right now.

diff --git a/components/features/users-table.tsx b/components/features/users-table.tsx index 47b91675..f1550b22 100644 --- a/components/features/users-table.tsx +++ b/components/features/users-table.tsx @@ -377,8 +377,8 @@ export function UsersTable({ users, currentUserId }: UsersTableProps) { description={ <> {deactivateTarget?.displayName} will be signed out immediately and - blocked from signing back in. This can't be undone from this - page. + blocked from signing back in. Reactivating requires a direct + database change — contact engineering. } confirmLabel="Deactivate" diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 8a54bc74..c24fee5e 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -58,8 +58,7 @@ export const getOptionalUser = cache( ); // A live session whose row has since been soft-deleted — distinct from "no -// session" so getCurrentUser can route it to the explanatory screen instead -// of a silent sign-in loop. +// session" so getCurrentUser avoids a silent sign-in loop. export const getDeactivatedSessionUser = cache( async function getDeactivatedSessionUser(): Promise { const resolution = await resolveUser(); diff --git a/prisma/actions/users.ts b/prisma/actions/users.ts index e908bffd..6046fd4d 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -95,7 +95,7 @@ export async function createUser(input: unknown): Promise { return existing.deletedAt ? { error: - 'That email belongs to a deactivated account. Contact an administrator to reactivate it.', + 'That email belongs to a deactivated account. Reactivating requires a direct database change — contact engineering.', } : { error: 'A user with this email already exists.' };