diff --git a/app/login/deactivated/page.tsx b/app/login/deactivated/page.tsx new file mode 100644 index 00000000..3ac9bfe0 --- /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/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 e9af1893..c24fee5e 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,15 @@ export const getOptionalUser = cache( }, ); +// A live session whose row has since been soft-deleted — distinct from "no +// session" so getCurrentUser avoids 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 +76,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/prisma/actions/users.ts b/prisma/actions/users.ts index 43c192d3..6046fd4d 100644 --- a/prisma/actions/users.ts +++ b/prisma/actions/users.ts @@ -15,7 +15,7 @@ const toggleAdminSchema = z.object({ makeAdmin: z.boolean(), }); -const deactivateSchema = z.object({ userId: z.string().min(1) }); +const userIdSchema = z.object({ userId: z.string().min(1) }); type ActionError = { error: string }; @@ -50,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; @@ -89,9 +89,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. Reactivating requires a direct database change — contact engineering.', + } + : { 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/tests/db/authorization.test.ts b/tests/db/authorization.test.ts index 47b11263..a0d091e7 100644 --- a/tests/db/authorization.test.ts +++ b/tests/db/authorization.test.ts @@ -43,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, @@ -560,6 +561,25 @@ describe('toggleUserAdmin / deactivateUser / createUser', () => { }); }); +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(), + }); + + const adminList = (await getUsersForAdmin()).map((u) => u.id); + expect(adminList).not.toContain(target.id); + + actAs(admin); + 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, + ); + }); +}); + describe('createGlobalQuestion / updateGlobalQuestion / deleteGlobalQuestion', () => { it('rejects a manager and an applicant', async () => { const input = { 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; }