From 5e9f5e25a9c9b2c5f0451856853aebf94334a177 Mon Sep 17 00:00:00 2001 From: theNEXlevel Date: Fri, 4 Sep 2026 00:15:36 -0500 Subject: [PATCH] Use Better Auth impersonation and add user bans --- .github/workflows/ci.yaml | 3 + .../migration.sql | 8 ++ prisma/schema/session.prisma | 1 + prisma/schema/user.prisma | 3 + src/app/api/impersonate/route.ts | 127 ------------------ src/app/api/impersonate/status/route.ts | 67 --------- src/app/api/users/route.ts | 3 + src/app/users/BanUserDialog.test.tsx | 53 ++++++++ src/app/users/BanUserDialog.tsx | 118 ++++++++++++++++ src/app/users/UserActionsCell.test.tsx | 24 ++++ src/app/users/UserActionsCell.tsx | 35 ++++- src/app/users/users-grid-columns.test.tsx | 5 + src/app/users/users-grid-columns.tsx | 39 ++++++ src/app/users/users-grid.tsx | 86 +++++++++++- .../contexts/impersonation-context.tsx | 72 +++------- src/components/layout/header.tsx | 6 +- src/components/layout/user-menu.tsx | 10 +- src/hooks/use-is-mounted.ts | 13 ++ src/lib/auth-client.ts | 13 +- src/lib/auth.ts | 78 +++-------- src/lib/prisma.ts | 2 +- src/test/mocks.tsx | 3 + 22 files changed, 458 insertions(+), 311 deletions(-) create mode 100644 prisma/migrations/20260903000000_add_better_auth_impersonation/migration.sql delete mode 100644 src/app/api/impersonate/route.ts delete mode 100644 src/app/api/impersonate/status/route.ts create mode 100644 src/app/users/BanUserDialog.test.tsx create mode 100644 src/app/users/BanUserDialog.tsx create mode 100644 src/hooks/use-is-mounted.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5224ed0..a2707ff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate Prisma schema + run: pnpm exec prisma validate + - name: Run lint run: pnpm lint diff --git a/prisma/migrations/20260903000000_add_better_auth_impersonation/migration.sql b/prisma/migrations/20260903000000_add_better_auth_impersonation/migration.sql new file mode 100644 index 0000000..ee362f8 --- /dev/null +++ b/prisma/migrations/20260903000000_add_better_auth_impersonation/migration.sql @@ -0,0 +1,8 @@ +-- Add the fields required by Better Auth's admin plugin. +ALTER TABLE "User" + ADD COLUMN "banned" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "banReason" TEXT, + ADD COLUMN "banExpires" TIMESTAMP(3); + +ALTER TABLE "Session" + ADD COLUMN "impersonatedBy" TEXT; diff --git a/prisma/schema/session.prisma b/prisma/schema/session.prisma index 85b504a..3a0a817 100644 --- a/prisma/schema/session.prisma +++ b/prisma/schema/session.prisma @@ -5,6 +5,7 @@ model Session { expiresAt DateTime ipAddress String? userAgent String? + impersonatedBy String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 0a72bd2..ea51b41 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -5,6 +5,9 @@ model User { emailVerified Boolean @default(false) image String? role UserRole? + banned Boolean @default(false) + banReason String? + banExpires DateTime? pushSubscription String? accounts Account[] sessions Session[] diff --git a/src/app/api/impersonate/route.ts b/src/app/api/impersonate/route.ts deleted file mode 100644 index a1b8e63..0000000 --- a/src/app/api/impersonate/route.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { getSession } from '@/lib/auth'; -import { prisma } from '@/lib/prisma'; -import { cookies } from 'next/headers'; -import { NextRequest, NextResponse } from 'next/server'; - -export async function POST(request: NextRequest) { - try { - const session = await getSession(); - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Check if the current user is an admin (before impersonation) - const currentUser = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, - }); - - if (currentUser?.role !== 'ADMIN') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { userId } = await request.json(); - - if (!userId) { - return NextResponse.json( - { error: 'User ID is required' }, - { status: 400 } - ); - } - - // Verify the user exists - const userToImpersonate = await prisma.user.findUnique({ - where: { id: userId }, - select: { id: true, name: true, email: true, role: true, image: true }, - }); - - if (!userToImpersonate) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); - } - - // Set impersonation cookie with both impersonated user and original admin ID - const impersonationData = { - impersonatedUser: userToImpersonate, - originalAdminId: session.user.id, - }; - - const response = NextResponse.json({ - success: true, - impersonatedUser: userToImpersonate, - }); - - response.cookies.set( - 'impersonated-user', - JSON.stringify(impersonationData), - { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24, // 24 hours - } - ); - - return response; - } catch (error) { - console.error('Error starting impersonation:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} - -export async function DELETE() { - try { - const session = await getSession(); - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Get the impersonation cookie to check the original admin ID - const cookieStore = await cookies(); - const impersonatedUserCookie = cookieStore.get('impersonated-user'); - - if (impersonatedUserCookie) { - try { - const impersonationData = JSON.parse(impersonatedUserCookie.value); - - // Check if the original admin is still an admin - const originalAdmin = await prisma.user.findUnique({ - where: { id: impersonationData.originalAdminId }, - select: { role: true }, - }); - - if (originalAdmin?.role !== 'ADMIN') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - } catch { - // Invalid cookie format, but we'll still allow deletion - } - } else { - // No impersonation cookie, check if current user is admin - const currentUser = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, - }); - - if (currentUser?.role !== 'ADMIN') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - } - - // Remove impersonation cookie - const response = NextResponse.json({ success: true }); - response.cookies.delete('impersonated-user'); - - return response; - } catch (error) { - console.error('Error stopping impersonation:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} diff --git a/src/app/api/impersonate/status/route.ts b/src/app/api/impersonate/status/route.ts deleted file mode 100644 index a0bdffa..0000000 --- a/src/app/api/impersonate/status/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getSession } from '@/lib/auth'; -import { prisma } from '@/lib/prisma'; -import { cookies } from 'next/headers'; -import { NextResponse } from 'next/server'; - -export async function GET() { - try { - const session = await getSession(); - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Get the impersonation cookie to check the original admin ID - const cookieStore = await cookies(); - const impersonatedUserCookie = cookieStore.get('impersonated-user'); - - let impersonatedUser = null; - - if (impersonatedUserCookie) { - try { - const impersonationData = JSON.parse(impersonatedUserCookie.value); - - // Check if the original admin is still an admin - const originalAdmin = await prisma.user.findUnique({ - where: { id: impersonationData.originalAdminId }, - select: { role: true }, - }); - - if (originalAdmin?.role !== 'ADMIN') { - // Original admin is no longer admin, clear the cookie - const response = NextResponse.json({ impersonatedUser: null }); - response.cookies.delete('impersonated-user'); - return response; - } - - // Check if the impersonation data has the new format - if (impersonationData.impersonatedUser) { - impersonatedUser = impersonationData.impersonatedUser; - } else { - // Old format, treat the entire data as the impersonated user - impersonatedUser = impersonationData; - } - } catch { - // Invalid cookie format, ignore - } - } else { - // No impersonation cookie, check if current user is admin - const currentUser = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { role: true }, - }); - - if (currentUser?.role !== 'ADMIN') { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - } - - return NextResponse.json({ impersonatedUser }); - } catch (error) { - console.error('Error checking impersonation status:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts index b476d1b..da3f8a4 100644 --- a/src/app/api/users/route.ts +++ b/src/app/api/users/route.ts @@ -74,6 +74,9 @@ export async function GET(request: NextRequest) { name: true, email: true, role: true, + banned: true, + banReason: true, + banExpires: true, createdAt: true, updatedAt: true, }, diff --git a/src/app/users/BanUserDialog.test.tsx b/src/app/users/BanUserDialog.test.tsx new file mode 100644 index 0000000..dc06cb0 --- /dev/null +++ b/src/app/users/BanUserDialog.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@/test/test-utils'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { BanUserDialog } from './BanUserDialog'; + +describe('BanUserDialog', () => { + const defaultProps = { + isOpen: true, + onOpenChange: vi.fn(), + userToBan: { name: 'Test User' }, + onConfirm: vi.fn().mockResolvedValue(undefined), + isLoading: false, + }; + + it('renders shadcn inputs and user details', () => { + render(); + + expect( + screen.getByRole('heading', { name: 'Ban User' }) + ).toBeInTheDocument(); + expect(screen.getByLabelText('Reason')).toBeInTheDocument(); + expect(screen.getByLabelText('Ban ends')).toHaveAttribute( + 'type', + 'datetime-local' + ); + }); + + it('requires a reason', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Ban User' })); + + expect(defaultProps.onConfirm).not.toHaveBeenCalled(); + expect(screen.getByRole('alert')).toHaveTextContent( + 'A reason is required.' + ); + }); + + it('submits the reason and end time', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('Reason'), 'Abuse of service'); + await user.type(screen.getByLabelText('Ban ends'), '2030-01-01T12:00'); + await user.click(screen.getByRole('button', { name: 'Ban User' })); + + expect(defaultProps.onConfirm).toHaveBeenCalledWith({ + banReason: 'Abuse of service', + banExpiresAt: '2030-01-01T12:00', + }); + }); +}); diff --git a/src/app/users/BanUserDialog.tsx b/src/app/users/BanUserDialog.tsx new file mode 100644 index 0000000..601f5b2 --- /dev/null +++ b/src/app/users/BanUserDialog.tsx @@ -0,0 +1,118 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import type { User } from '@prisma/client'; +import { FormEvent, useState } from 'react'; + +export interface BanUserDetails { + banReason: string; + banExpiresAt?: string; +} + +interface BanUserDialogProps { + isOpen: boolean; + onOpenChange: (_: boolean) => void; + userToBan: Pick | null; + onConfirm: (_details: BanUserDetails) => Promise; + isLoading: boolean; +} + +export function BanUserDialog({ + isOpen, + onOpenChange, + userToBan, + onConfirm, + isLoading, +}: BanUserDialogProps) { + const [banReason, setBanReason] = useState(''); + const [banExpiresAt, setBanExpiresAt] = useState(''); + const [validationError, setValidationError] = useState(null); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + const reason = banReason.trim(); + if (!reason) { + setValidationError('A reason is required.'); + return; + } + + if (banExpiresAt && new Date(banExpiresAt).getTime() <= Date.now()) { + setValidationError('The ban end time must be in the future.'); + return; + } + + setValidationError(null); + await onConfirm({ + banReason: reason, + banExpiresAt: banExpiresAt || undefined, + }); + }; + + return ( + + + + Ban User + + Ban {userToBan?.name ?? 'this user'} from signing in. Leave the end + time blank for a permanent ban. + + +
+
+ +