Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/login/deactivated/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full max-w-sm flex-col items-center gap-4 text-center">
<UserRoundX className="text-muted-foreground size-10" aria-hidden />
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold">Account deactivated</h1>
<p className="text-sm">
Your account has been deactivated, so you can&apos;t access Aplio
right now.
</p>
<p className="text-muted-foreground text-sm">
If you think this is a mistake, contact an administrator to have it
restored.
</p>
</div>
<p className="text-muted-foreground text-xs">
Signed in as {deactivatedUser.email}
</p>
<div className="flex w-full flex-col gap-2">
<DeactivatedSignOut />
<Button variant="ghost" asChild className="w-full">
<Link href="/positions">Browse open positions</Link>
</Button>
</div>
</div>
);
}
24 changes: 7 additions & 17 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand All @@ -30,16 +22,19 @@ export default async function SignInPage({
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
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();

Expand All @@ -60,11 +55,6 @@ export default async function SignInPage({

return (
<div className="flex w-full max-w-sm flex-col items-center gap-4">
{isDeactivated && !user && (
<WarningCallout icon={TriangleAlert} className="w-full">
{ACCOUNT_DEACTIVATED_MESSAGE}
</WarningCallout>
)}
{user ? (
<NameField defaultName={user.name ?? ''} redirectTo={safeTo} />
) : (
Expand Down
43 changes: 43 additions & 0 deletions components/features/deactivated-sign-out.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button className="w-full" disabled={pending} onClick={handleSignOut}>
{pending && <Loader2 className="animate-spin" aria-hidden />}
Sign out
</Button>
);
}
4 changes: 2 additions & 2 deletions components/features/users-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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&apos;t be undone from this
page.
blocked from signing back in. Reactivating requires a direct
database change — contact engineering.
</>
}
confirmLabel="Deactivate"
Expand Down
27 changes: 17 additions & 10 deletions lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -26,7 +25,7 @@ const resolveUser = cache(
});
if (row)
return row.deletedAt
? { status: 'deactivated' }
? { status: 'deactivated', user: row }
: { status: 'active', user: row };
}
}
Expand All @@ -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 };
},
);
Expand All @@ -49,14 +48,24 @@ export async function getIsBypass(): Promise<boolean> {
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<User | null> {
const resolution = await resolveUser();
return resolution.status === 'active' ? resolution.user : null;
},
);

// 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<User | null> {
const resolution = await resolveUser();
return resolution.status === 'deactivated' ? resolution.user : null;
},
);

export async function currentPath(): Promise<string | null> {
return (await headers()).get('x-current-path');
}
Expand All @@ -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()));
Expand Down
7 changes: 1 addition & 6 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
18 changes: 17 additions & 1 deletion prisma/actions/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,3 +64,19 @@ export async function signOutUser(): Promise<ErrorType | void> {

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<ErrorType | void> {
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');
}
14 changes: 10 additions & 4 deletions prisma/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -50,7 +50,7 @@ export async function deactivateUser(
): Promise<ActionError | void> {
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;
Expand Down Expand Up @@ -89,9 +89,15 @@ export async function createUser(input: unknown): Promise<ActionError | void> {
// 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.
Expand Down
20 changes: 20 additions & 0 deletions tests/db/authorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = {
Expand Down
6 changes: 6 additions & 0 deletions tests/stubs/auth-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export async function getOptionalUser(): Promise<User | null> {
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<User | null> {
return null;
}

export async function getIsBypass(): Promise<boolean> {
return false;
}
Expand Down
Loading