Skip to content
Open
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
150 changes: 150 additions & 0 deletions src/app/api/admin/users/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/** @jest-environment node */

jest.mock('@/lib/auth', () => ({
getCurrentUser: jest.fn(),
}));

jest.mock('@/lib/db/prisma', () => ({
__esModule: true,
default: {
verificationToken: {
create: jest.fn(),
deleteMany: jest.fn(),
},
},
}));

jest.mock('@/lib/email/resend', () => ({
sendEmail: jest.fn(),
}));

jest.mock('@react-email/render', () => ({
renderAsync: jest.fn(),
}));

jest.mock('@/lib/email/templates/user-invite', () => ({
UserInviteEmail: jest.fn(() => '<div>invite</div>'),
}));

jest.mock('@/env.mjs', () => ({
env: {
NEXTAUTH_URL: 'http://localhost:3000',
},
}));

jest.mock('@/lib/db/users', () => ({
createUser: jest.fn(),
getUsers: jest.fn(),
updateUser: jest.fn(),
}));

jest.mock('@/lib/discord', () => ({
sendUserOnboardedAdminAlert: jest.fn(),
}));

import prisma from '@/lib/db/prisma';
import { getCurrentUser } from '@/lib/auth';
import { sendEmail } from '@/lib/email/resend';
import { renderAsync } from '@react-email/render';
import { createUser } from '@/lib/db/users';
import { sendUserOnboardedAdminAlert } from '@/lib/discord';
import { POST } from './route';

const mockGetCurrentUser = getCurrentUser as jest.MockedFunction<typeof getCurrentUser>;
const mockCreateUser = createUser as jest.MockedFunction<typeof createUser>;
const mockSendEmail = sendEmail as jest.MockedFunction<typeof sendEmail>;
const mockRenderAsync = renderAsync as jest.MockedFunction<typeof renderAsync>;
const mockVerificationTokenCreate = prisma.verificationToken.create as jest.MockedFunction<typeof prisma.verificationToken.create>;
const mockVerificationTokenDeleteMany = prisma.verificationToken.deleteMany as jest.MockedFunction<typeof prisma.verificationToken.deleteMany>;
const mockSendUserOnboardedAdminAlert = sendUserOnboardedAdminAlert as jest.MockedFunction<typeof sendUserOnboardedAdminAlert>;

describe('POST /api/admin/users', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetCurrentUser.mockResolvedValue({ isSuperAdmin: true } as unknown as Awaited<ReturnType<typeof getCurrentUser>>);
mockVerificationTokenCreate.mockResolvedValue({} as Awaited<ReturnType<typeof prisma.verificationToken.create>>);
mockVerificationTokenDeleteMany.mockResolvedValue({ count: 1 });
mockRenderAsync.mockResolvedValue('<html>invite</html>');
mockSendEmail.mockResolvedValue({ success: true, message: 'Email sent successfully' });
mockSendUserOnboardedAdminAlert.mockResolvedValue(undefined);
});

it('returns 401 when user is not super admin', async () => {
mockGetCurrentUser.mockResolvedValueOnce(null);

const request = new Request('http://localhost/api/admin/users', {
method: 'POST',
body: JSON.stringify({ email: 'test@example.com' }),
headers: { 'Content-Type': 'application/json' },
});

const response = await POST(request);
expect(response.status).toBe(401);
});

it('returns 409 and surfaces duplicate email message', async () => {
mockCreateUser.mockRejectedValueOnce(new Error('A user with this email already exists.'));

const request = new Request('http://localhost/api/admin/users', {
method: 'POST',
body: JSON.stringify({ email: 'duplicate@example.com', name: 'Duplicate User' }),
headers: { 'Content-Type': 'application/json' },
});

const response = await POST(request);
expect(response.status).toBe(409);
await expect(response.json()).resolves.toEqual({
error: 'A user with this email already exists.',
});
});

it('returns success even when invite email sending fails', async () => {
mockCreateUser.mockResolvedValueOnce({
id: 'user-1',
email: ' USER@Example.COM ',
name: ' Test User ',
administers: [],
} as unknown as Awaited<ReturnType<typeof createUser>>);
mockSendEmail.mockResolvedValueOnce({ success: false, message: 'Email failed' });

const request = new Request('http://localhost/api/admin/users', {
method: 'POST',
body: JSON.stringify({
email: ' USER@Example.COM ',
name: ' Test User ',
isSuperAdmin: false,
administers: [],
}),
headers: { 'Content-Type': 'application/json' },
});

const response = await POST(request);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual(
expect.objectContaining({
id: 'user-1',
})
);
expect(mockSendEmail).toHaveBeenCalledTimes(1);
expect(mockVerificationTokenCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
identifier: 'user@example.com',
}),
})
);
expect(mockVerificationTokenDeleteMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
identifier: 'user@example.com',
token: expect.any(String),
}),
})
);
expect(mockSendUserOnboardedAdminAlert).toHaveBeenCalledWith({
cityName: 'Admin User',
onboardingSource: 'admin_invite',
});
});
});
95 changes: 77 additions & 18 deletions src/app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,31 @@ import { env } from "@/env.mjs"
import { createUser, getUsers, updateUser } from "@/lib/db/users"
import { sendUserOnboardedAdminAlert } from "@/lib/discord"

async function generateSignInLink(email: string) {
interface ErrorWithCode {
code?: string
}

interface VerificationTokenKey {
identifier: string
token: string
}

function getUserErrorResponse(error: unknown, fallbackMessage: string): NextResponse {
const errorWithCode = error as ErrorWithCode
const errorMessage = error instanceof Error ? error.message : fallbackMessage

if (errorWithCode.code === "P2002" || errorMessage === "A user with this email already exists.") {
return NextResponse.json({ error: "A user with this email already exists." }, { status: 409 })
}

if (errorMessage === "Email is required to create a user" || errorMessage === "Email cannot be empty") {
return NextResponse.json({ error: errorMessage }, { status: 400 })
}

return NextResponse.json({ error: errorMessage }, { status: 500 })
}

async function generateSignInLink(email: string): Promise<{ signInUrl: string, verificationTokenKey: VerificationTokenKey }> {
// Create a token that expires in 24 hours
const token = createHash('sha256')
.update(email + Date.now().toString())
Expand All @@ -26,22 +50,55 @@ async function generateSignInLink(email: string) {

// Generate the sign-in URL
const signInUrl = `${env.NEXTAUTH_URL}/sign-in?token=${token}&email=${encodeURIComponent(email)}`
return signInUrl
return {
signInUrl,
verificationTokenKey: {
identifier: email,
token,
}
}
}

async function deleteVerificationToken(verificationTokenKey: VerificationTokenKey): Promise<void> {
try {
await prisma.verificationToken.deleteMany({
where: verificationTokenKey,
})
} catch (cleanupError) {
console.error("Failed to clean up verification token after invite failure:", cleanupError)
}
}

async function sendInviteEmail(email: string, name: string) {
const signInUrl = await generateSignInLink(email)
const emailHtml = await renderAsync(UserInviteEmail({
name: name || email,
inviteUrl: signInUrl
}))

await sendEmail({
from: "OpenCouncil <auth@opencouncil.gr>",
to: email,
subject: "You've been invited to OpenCouncil",
html: emailHtml,
})
let verificationTokenKey: VerificationTokenKey | null = null

try {
const signInLinkData = await generateSignInLink(email)
verificationTokenKey = signInLinkData.verificationTokenKey
const emailHtml = await renderAsync(UserInviteEmail({
name: name || email,
inviteUrl: signInLinkData.signInUrl
}))

const sendResult = await sendEmail({
from: "OpenCouncil <auth@opencouncil.gr>",
to: email,
subject: "You've been invited to OpenCouncil",
html: emailHtml,
})

if (!sendResult.success && verificationTokenKey) {
await deleteVerificationToken(verificationTokenKey)
}

return sendResult.success
} catch (error) {
console.error("Failed to send invite email:", error)
if (verificationTokenKey) {
await deleteVerificationToken(verificationTokenKey)
}
return false
}
}

export async function GET() {
Expand Down Expand Up @@ -72,7 +129,10 @@ export async function POST(request: Request) {
const newUser = await createUser({ email, name, isSuperAdmin, administers })

// Send invitation email
await sendInviteEmail(email, name)
const inviteEmailSent = await sendInviteEmail(newUser.email, newUser.name ?? newUser.email)
if (!inviteEmailSent) {
console.error(`User ${newUser.id} created, but invite email failed to send`)
}

// Send Discord admin alert for admin invite
sendUserOnboardedAdminAlert({
Expand All @@ -83,7 +143,7 @@ export async function POST(request: Request) {
return NextResponse.json(newUser)
} catch (error) {
console.error("Failed to create user:", error)
return new NextResponse("Failed to create user", { status: 500 })
return getUserErrorResponse(error, "Failed to create user")
}
}

Expand All @@ -101,7 +161,6 @@ export async function PUT(request: Request) {
return NextResponse.json(updatedUser)
} catch (error) {
console.error("Failed to update user:", error)
return new NextResponse("Failed to update user", { status: 500 })
return getUserErrorResponse(error, "Failed to update user")
}
}

40 changes: 36 additions & 4 deletions src/components/admin/users/user-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"
import { X } from "lucide-react"
import { UserWithRelations } from "@/lib/db/users"
import Combobox from "@/components/Combobox"
import { toast } from "@/hooks/use-toast"

interface UserDialogProps {
open: boolean
Expand Down Expand Up @@ -112,9 +113,11 @@ export function UserDialog({ open, onOpenChange, user, onDelete }: UserDialogPro
setLoading(true)

const formData = new FormData(e.currentTarget)
const email = String(formData.get("email") ?? "")
const name = String(formData.get("name") ?? "")
const data = {
email: formData.get("email") as string,
name: formData.get("name") as string,
email,
name: name || null,
isSuperAdmin: formData.get("isSuperAdmin") === "on",
administers: selectedEntities.map(entity => ({
[entity.type]: { connect: { id: entity.id } }
Expand All @@ -134,12 +137,41 @@ export function UserDialog({ open, onOpenChange, user, onDelete }: UserDialogPro
})

if (!response.ok) {
throw new Error("Failed to save user")
const responseText = await response.text()
let errorMessage = "Failed to save user"

if (responseText) {
try {
const parsedError = JSON.parse(responseText) as { error?: unknown }
const parsedErrorMessage = parsedError.error

if (typeof parsedErrorMessage === "string" && parsedErrorMessage.trim()) {
errorMessage = parsedErrorMessage
} else if (parsedErrorMessage !== undefined) {
errorMessage = JSON.stringify(parsedErrorMessage)
} else {
errorMessage = responseText
}
} catch {
errorMessage = responseText
}
}

throw new Error(errorMessage)
}

toast({
title: "Success",
description: isEditing ? "User updated successfully." : "User created successfully.",
})
onOpenChange(false)
} catch (error) {
console.error("Failed to save user:", error)
toast({
title: "Error",
description: error instanceof Error ? error.message : "Failed to save user",
variant: "destructive",
})
} finally {
setLoading(false)
}
Expand Down Expand Up @@ -268,4 +300,4 @@ export function UserDialog({ open, onOpenChange, user, onDelete }: UserDialogPro
</DialogContent>
</Dialog>
)
}
}
Loading