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;
}