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. + + + + + Reason + setBanReason(event.target.value)} + placeholder='Explain why this user is being banned' + disabled={isLoading} + aria-required='true' + /> + + + Ban ends + setBanExpiresAt(event.target.value)} + disabled={isLoading} + /> + + Leave blank for a permanent ban. + + + {validationError && ( + + {validationError} + + )} + + onOpenChange(false)} + disabled={isLoading} + > + Cancel + + + {isLoading ? 'Banning...' : 'Ban User'} + + + + + + ); +} diff --git a/src/app/users/UserActionsCell.test.tsx b/src/app/users/UserActionsCell.test.tsx index e5bac41..cad62a9 100644 --- a/src/app/users/UserActionsCell.test.tsx +++ b/src/app/users/UserActionsCell.test.tsx @@ -25,7 +25,10 @@ describe('UserActionsCell', () => { onSendNotification: vi.fn(), onDelete: vi.fn(), onImpersonate: vi.fn(), + onBan: vi.fn(), + onUnban: vi.fn(), isSendingNotification: false, + isBanning: false, currentUserId: 'different-user-id', // eslint-disable-next-line @typescript-eslint/no-explicit-any api: {} as any, @@ -81,6 +84,7 @@ describe('UserActionsCell', () => { expect(screen.getByText(/Send Email/i)).toBeInTheDocument(); expect(screen.getByText(/Send Test Notification/i)).toBeInTheDocument(); expect(screen.getByText(/Delete User/i)).toBeInTheDocument(); + expect(screen.getByText(/Ban User/i)).toBeInTheDocument(); }); it('hides Impersonate option for current user', async () => { @@ -140,6 +144,26 @@ describe('UserActionsCell', () => { expect(props.onDelete).toHaveBeenCalledWith(mockUser); }); + it('calls onBan when Ban User is clicked', async () => { + const { user, props } = setup(); + + await user.click(screen.getByRole('button')); + await user.click(screen.getByText(/Ban User/i)); + + expect(props.onBan).toHaveBeenCalledWith(mockUser); + }); + + it('shows and calls Unban User for a banned user', async () => { + const bannedUser = createMockUser({ banned: true }); + const { user, props } = setup({ data: bannedUser }); + + await user.click(screen.getByRole('button')); + await user.click(screen.getByText(/Unban User/i)); + + expect(screen.queryByText(/Ban User/i)).not.toBeInTheDocument(); + expect(props.onUnban).toHaveBeenCalledWith(bannedUser); + }); + it('calls onImpersonate when Impersonate User is clicked', async () => { // Setup & Invoke const { user, props } = setup(); diff --git a/src/app/users/UserActionsCell.tsx b/src/app/users/UserActionsCell.tsx index 16c9d60..24c64b3 100644 --- a/src/app/users/UserActionsCell.tsx +++ b/src/app/users/UserActionsCell.tsx @@ -7,14 +7,25 @@ import { } from '@/components/ui/dropdown-menu'; import type { User } from '@prisma/client'; import type { ICellRendererParams } from 'ag-grid-community'; -import { Bell, Mail, MoreHorizontal, Trash2, UserCheck } from 'lucide-react'; +import { + Ban, + Bell, + Mail, + MoreHorizontal, + ShieldCheck, + Trash2, + UserCheck, +} from 'lucide-react'; interface UserActionsCellProps extends ICellRendererParams { onSendEmail: (_: User) => void; onSendNotification: (_: User) => void; onDelete: (_: User) => void; onImpersonate: (_: User) => void; + onBan: (_: User) => void; + onUnban: (_: User) => void; isSendingNotification: boolean; + isBanning: boolean; currentUserId?: string; } @@ -24,7 +35,10 @@ export function UserActionsCell({ onSendNotification, onDelete, onImpersonate, + onBan, + onUnban, isSendingNotification, + isBanning, currentUserId, }: UserActionsCellProps) { if (!data) return null; @@ -48,6 +62,25 @@ export function UserActionsCell({ Impersonate User )} + {!isCurrentUser && + (data.banned ? ( + onUnban(data)} + disabled={isBanning} + > + + Unban User + + ) : ( + onBan(data)} + disabled={isBanning} + className='text-destructive' + > + + Ban User + + ))} onSendEmail(data)}> Send Email diff --git a/src/app/users/users-grid-columns.test.tsx b/src/app/users/users-grid-columns.test.tsx index 5dac1e4..40207df 100644 --- a/src/app/users/users-grid-columns.test.tsx +++ b/src/app/users/users-grid-columns.test.tsx @@ -7,7 +7,10 @@ describe('users-grid-columns', () => { onSendNotification: vi.fn(), onDelete: vi.fn(), onImpersonate: vi.fn(), + onBan: vi.fn(), + onUnban: vi.fn(), isSendingNotification: false, + isBanning: false, currentUserId: 'user-123', }; @@ -41,6 +44,8 @@ describe('users-grid-columns', () => { expect(fields).toContain('name'); expect(fields).toContain('email'); expect(fields).toContain('role'); + expect(fields).toContain('banReason'); + expect(fields).toContain('banExpires'); expect(fields).toContain('id'); expect(fields).toContain('createdAt'); expect(fields).toContain('updatedAt'); diff --git a/src/app/users/users-grid-columns.tsx b/src/app/users/users-grid-columns.tsx index 604fff5..de7dbc3 100644 --- a/src/app/users/users-grid-columns.tsx +++ b/src/app/users/users-grid-columns.tsx @@ -27,14 +27,20 @@ export function getColumnDefs({ onSendNotification, onDelete, onImpersonate, + onBan, + onUnban, isSendingNotification, + isBanning, currentUserId, }: { onSendEmail: (_: User) => void; onSendNotification: (_: User) => void; onDelete: (_: User) => void; onImpersonate: (_: User) => void; + onBan: (_: User) => void; + onUnban: (_: User) => void; isSendingNotification: boolean; + isBanning: boolean; currentUserId?: string; }): ColDef[] { return [ @@ -48,6 +54,36 @@ export function getColumnDefs({ values: [null, 'ADMIN', 'STAFF'], }, }, + { + field: 'banReason', + headerName: 'Ban Reason', + editable: false, + }, + { + field: 'banExpires', + headerName: 'Ban Expires', + editable: false, + filter: 'agDateColumnFilter', + filterParams: { + filterOptions: ['greaterThanOrEqual', 'lessThanOrEqual', 'inRange'], + suppressAndOrCondition: true, + comparator: (filterLocalDateAtMidnight: Date, cellValue: string) => { + if (!cellValue) return -1; + const cellDate = new Date(cellValue); + const cellDateAtMidnight = new Date( + cellDate.getFullYear(), + cellDate.getMonth(), + cellDate.getDate() + ); + + if (cellDateAtMidnight < filterLocalDateAtMidnight) return -1; + if (cellDateAtMidnight > filterLocalDateAtMidnight) return 1; + return 0; + }, + }, + valueFormatter: (params) => + params.value ? new Date(params.value).toLocaleString() : '', + }, { field: 'id', editable: false }, { field: 'createdAt', @@ -114,7 +150,10 @@ export function getColumnDefs({ onSendNotification, onDelete, onImpersonate, + onBan, + onUnban, isSendingNotification, + isBanning, currentUserId, }); }, diff --git a/src/app/users/users-grid.tsx b/src/app/users/users-grid.tsx index 046debc..3d02538 100644 --- a/src/app/users/users-grid.tsx +++ b/src/app/users/users-grid.tsx @@ -18,8 +18,9 @@ import { type SortDirection, } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; -import { useSession } from '@/lib/auth-client'; +import { authClient, useSession } from '@/lib/auth-client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BanUserDialog, type BanUserDetails } from './BanUserDialog'; import { DeleteUserDialog } from './DeleteUserDialog'; import { EmailDialog } from './EmailDialog'; import { defaultColDef, getColumnDefs } from './users-grid-columns'; @@ -39,6 +40,9 @@ export function UsersGrid() { const [userToDelete, setUserToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [isSendingNotification, setIsSendingNotification] = useState(false); + const [isBanModalOpen, setIsBanModalOpen] = useState(false); + const [userToBan, setUserToBan] = useState(null); + const [isBanning, setIsBanning] = useState(false); // Pagination state const [currentPage, setCurrentPage] = useState(1); @@ -129,6 +133,72 @@ export function UsersGrid() { }); } }; + const handleBanAction = (user: User) => { + setUserToBan(user); + setIsBanModalOpen(true); + }; + const handleUnbanAction = async (user: User) => { + setIsBanning(true); + + try { + const { error } = await authClient.admin.unbanUser({ userId: user.id }); + if (error) throw new Error(error.message); + + toast({ + title: 'User Unbanned', + description: `${user.name} can sign in again.`, + }); + await fetchUsers(); + } catch (error) { + console.error('Error unbanning user:', error); + toast({ + title: 'Error', + description: 'Failed to unban user. Please try again.', + variant: 'destructive', + }); + } finally { + setIsBanning(false); + } + }; + const handleBanUser = async ({ banReason, banExpiresAt }: BanUserDetails) => { + if (!userToBan) return; + + setIsBanning(true); + + try { + const banExpiresIn = banExpiresAt + ? Math.ceil((new Date(banExpiresAt).getTime() - Date.now()) / 1000) + : undefined; + + if (banExpiresIn !== undefined && banExpiresIn <= 0) { + throw new Error('The ban end time must be in the future.'); + } + + const { error } = await authClient.admin.banUser({ + userId: userToBan.id, + banReason, + banExpiresIn, + }); + if (error) throw new Error(error.message); + + toast({ + title: 'User Banned', + description: `${userToBan.name} has been banned.`, + }); + setIsBanModalOpen(false); + setUserToBan(null); + await fetchUsers(); + } catch (error) { + console.error('Error banning user:', error); + toast({ + title: 'Error', + description: 'Failed to ban user. Please try again.', + variant: 'destructive', + }); + } finally { + setIsBanning(false); + } + }; const handlePageChange = (page: number) => { setCurrentPage(page); @@ -143,7 +213,10 @@ export function UsersGrid() { onSendNotification: handleSendNotificationAction, onDelete: handleDeleteAction, onImpersonate: handleImpersonateAction, + onBan: handleBanAction, + onUnban: handleUnbanAction, isSendingNotification, + isBanning, currentUserId: session?.user?.id, }); @@ -477,6 +550,17 @@ export function UsersGrid() { }} isLoading={isDeleting} /> + { + setIsBanModalOpen(open); + if (!open) setUserToBan(null); + }} + userToBan={userToBan ? { name: userToBan.name ?? '' } : null} + onConfirm={handleBanUser} + isLoading={isBanning} + /> ); } diff --git a/src/components/contexts/impersonation-context.tsx b/src/components/contexts/impersonation-context.tsx index b5351f3..4b9d121 100644 --- a/src/components/contexts/impersonation-context.tsx +++ b/src/components/contexts/impersonation-context.tsx @@ -2,11 +2,13 @@ import { toast } from '@/hooks/use-toast'; import { User } from '@prisma/client'; -import { useSession } from '@/lib/auth-client'; -import { createContext, useContext, useEffect, useState } from 'react'; +import { authClient, useSession } from '@/lib/auth-client'; +import { createContext, useContext } from 'react'; + +type ImpersonatedUser = Pick; interface ImpersonationContextType { - impersonatedUser: User | null; + impersonatedUser: ImpersonatedUser | null; isImpersonating: boolean; startImpersonation: (_user: User) => Promise; stopImpersonation: () => Promise; @@ -21,45 +23,21 @@ export function ImpersonationProvider({ }: { children: React.ReactNode; }) { - const [impersonatedUser, setImpersonatedUser] = useState(null); const { data: session, refetch } = useSession(); - // Check for impersonation status on mount only if user is authenticated - useEffect(() => { - if (session?.user) { - checkImpersonationStatus(); - } - }, [session?.user]); - - const checkImpersonationStatus = async () => { - try { - const response = await fetch('/api/impersonate/status'); - if (response.ok) { - const data = await response.json(); - setImpersonatedUser(data.impersonatedUser || null); - } - } catch (error) { - console.error('Error checking impersonation status:', error); - } - }; + const isImpersonating = !!session?.session?.impersonatedBy; + const impersonatedUser = isImpersonating + ? (session.user as ImpersonatedUser) + : null; const startImpersonation = async (user: User) => { try { - const response = await fetch('/api/impersonate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: user.id }), + const { error } = await authClient.admin.impersonateUser({ + userId: user.id, }); + if (error) throw new Error(error.message); - if (response.ok) { - const data = await response.json(); - setImpersonatedUser(data.impersonatedUser); - - // Force session refresh to update the UI - refetch(); - } else { - throw new Error('Failed to start impersonation'); - } + await refetch(); } catch (error) { console.error('Error starting impersonation:', error); throw error; @@ -68,23 +46,15 @@ export function ImpersonationProvider({ const stopImpersonation = async () => { try { - const response = await fetch('/api/impersonate', { - method: 'DELETE', - }); + const { error } = await authClient.admin.stopImpersonating(); + if (error) throw new Error(error.message); - if (response.ok) { - setImpersonatedUser(null); + await refetch(); - // Force session refresh to update the UI - refetch(); - - toast({ - title: 'Impersonation Stopped', - description: 'You are no longer impersonating a user.', - }); - } else { - throw new Error('Failed to stop impersonation'); - } + toast({ + title: 'Impersonation Stopped', + description: 'You are no longer impersonating a user.', + }); } catch (error) { console.error('Error stopping impersonation:', error); toast({ @@ -100,7 +70,7 @@ export function ImpersonationProvider({ @@ -19,7 +21,7 @@ export function Header() { height='32' /> - {data ? ( + {isMounted && data ? ( ) : null} - {data?.user.role === 'ADMIN' ? ( + {isMounted && data?.user.role === 'ADMIN' ? ( router.push('/signin')}>Sign In; } @@ -101,9 +103,9 @@ export function UserMenu() { Notifications {/* - Passkey endpoints resolve the real session, not the impersonation - override, so a passkey added here would attach to the admin's own - account. Hide the entry rather than mislead. + Better Auth's native impersonation session resolves passkey + operations against the impersonated account. Keep this hidden so + an admin cannot unintentionally modify the user's credentials. */} {!isImpersonating && ( () => {}; +const getServerSnapshot = () => false; +const getClientSnapshot = () => true; + +export function useIsMounted() { + return useSyncExternalStore( + emptySubscribe, + getClientSnapshot, + getServerSnapshot + ); +} diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 3da9fb4..7284873 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,10 +1,21 @@ import type { auth } from '@/lib/auth'; import { passkeyClient } from '@better-auth/passkey/client'; import { customSessionClient } from 'better-auth/client/plugins'; +import { adminClient } from 'better-auth/client/plugins'; import { createAuthClient } from 'better-auth/react'; +import { adminAc, userAc } from 'better-auth/plugins/admin/access'; export const authClient = createAuthClient({ - plugins: [customSessionClient(), passkeyClient()], + plugins: [ + adminClient({ + roles: { + ADMIN: adminAc, + STAFF: userAc, + }, + }), + customSessionClient(), + passkeyClient(), + ], }); export const { useSession, signIn, signUp, signOut } = authClient; diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 498662a..d67a47d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -4,19 +4,12 @@ import type { User } from '@prisma/client'; import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { nextCookies } from 'better-auth/next-js'; -import { customSession } from 'better-auth/plugins'; -import { cookies, headers } from 'next/headers'; +import { admin, customSession } from 'better-auth/plugins'; +import { adminAc, userAc } from 'better-auth/plugins/admin/access'; +import { headers } from 'next/headers'; type UserRole = User['role']; -interface ImpersonatedUser { - id: string; - name: string | null; - email: string; - role: UserRole; - image: string | null; -} - /** * Passkeys are scoped to this exact hostname, so each app sharing a parent * domain (template.c4g.dev vs. another-app.c4g.dev) keeps its own credentials. @@ -59,58 +52,31 @@ export const auth = betterAuth({ }, }, plugins: [ + admin({ + defaultRole: 'STAFF', + adminRoles: ['ADMIN'], + // Preserve the existing UI behavior, which allowed admins to + // impersonate users regardless of their role. + allowImpersonatingAdmins: true, + impersonationSessionDuration: 60 * 60 * 24, + roles: { + ADMIN: adminAc, + STAFF: userAc, + }, + }), passkey({ rpID: passkeyRpID, rpName: 'Template', }), customSession(async ({ user, session }) => { const dbUser = user as typeof user & { role: UserRole }; - const sessionUser = { - ...user, - role: dbUser.role ?? null, + return { + session, + user: { + ...user, + role: dbUser.role ?? null, + }, }; - - // Check for impersonation cookie - const cookieStore = await cookies(); - const impersonatedUserCookie = cookieStore.get('impersonated-user'); - - if (impersonatedUserCookie) { - try { - const impersonationData = JSON.parse(impersonatedUserCookie.value); - - // Destructure impersonation data with fallbacks for backward compatibility - const { - impersonatedUser: newFormatUser, - originalAdminId: newFormatAdminId, - } = impersonationData; - const impersonatedUser: ImpersonatedUser = - newFormatUser ?? impersonationData; - const originalAdminId = newFormatAdminId ?? dbUser.id; - - // Verify the original admin is still an admin and matches the current user - const isValidImpersonation = - originalAdminId === dbUser.id && dbUser.role === 'ADMIN'; - - if (isValidImpersonation) { - // Override session with impersonated user data - return { - session, - user: { - ...sessionUser, - id: impersonatedUser.id, - name: impersonatedUser.name ?? '', - email: impersonatedUser.email, - role: impersonatedUser.role, - image: impersonatedUser.image || null, - }, - }; - } - } catch { - // Invalid cookie, fall through to the original user data - } - } - - return { session, user: sessionUser }; }), // nextCookies must remain the last plugin nextCookies(), @@ -119,7 +85,7 @@ export const auth = betterAuth({ /** * Returns the current session (or null) for server components and route - * handlers, including the `role` field and any active impersonation override. + * handlers, including the `role` and native impersonation fields. */ export async function getSession() { return auth.api.getSession({ headers: await headers() }); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 2a29896..787b501 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -1,5 +1,5 @@ -import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../../prisma/generated/prisma/client'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; diff --git a/src/test/mocks.tsx b/src/test/mocks.tsx index 0dcf0c6..2f6dea1 100644 --- a/src/test/mocks.tsx +++ b/src/test/mocks.tsx @@ -10,6 +10,9 @@ export const createMockUser = (overrides?: Partial): User => ({ emailVerified: false, image: null, role: null, + banned: false, + banReason: null, + banExpires: null, pushSubscription: null, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'),
+ Leave blank for a permanent ban. +
+ {validationError} +