Skip to content

Commit ccfa416

Browse files
committed
feat(admin-users): harden user management flow (DB normalization, duplicate-email handling, invite cleanup) and add test coverage
- centralize email/name normalization in createUser/updateUser - prevent duplicate emails case-insensitively and map conflicts to clear API errors - improve invite flow robustness with verification-token cleanup on email failure - improve admin user dialog/API error surfacing and feedback - add route/db tests for admin users plus klitiki whitespace-normalization tests
1 parent 133e266 commit ccfa416

File tree

7 files changed

+570
-33
lines changed

7 files changed

+570
-33
lines changed
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/** @jest-environment node */
2+
3+
jest.mock('@/lib/auth', () => ({
4+
getCurrentUser: jest.fn(),
5+
}));
6+
7+
jest.mock('@/lib/db/prisma', () => ({
8+
__esModule: true,
9+
default: {
10+
verificationToken: {
11+
create: jest.fn(),
12+
deleteMany: jest.fn(),
13+
},
14+
},
15+
}));
16+
17+
jest.mock('@/lib/email/resend', () => ({
18+
sendEmail: jest.fn(),
19+
}));
20+
21+
jest.mock('@react-email/render', () => ({
22+
renderAsync: jest.fn(),
23+
}));
24+
25+
jest.mock('@/lib/email/templates/user-invite', () => ({
26+
UserInviteEmail: jest.fn(() => '<div>invite</div>'),
27+
}));
28+
29+
jest.mock('@/env.mjs', () => ({
30+
env: {
31+
NEXTAUTH_URL: 'http://localhost:3000',
32+
},
33+
}));
34+
35+
jest.mock('@/lib/db/users', () => ({
36+
createUser: jest.fn(),
37+
getUsers: jest.fn(),
38+
updateUser: jest.fn(),
39+
}));
40+
41+
jest.mock('@/lib/discord', () => ({
42+
sendUserOnboardedAdminAlert: jest.fn(),
43+
}));
44+
45+
import prisma from '@/lib/db/prisma';
46+
import { getCurrentUser } from '@/lib/auth';
47+
import { sendEmail } from '@/lib/email/resend';
48+
import { renderAsync } from '@react-email/render';
49+
import { createUser } from '@/lib/db/users';
50+
import { sendUserOnboardedAdminAlert } from '@/lib/discord';
51+
import { POST } from './route';
52+
53+
const mockGetCurrentUser = getCurrentUser as jest.MockedFunction<typeof getCurrentUser>;
54+
const mockCreateUser = createUser as jest.MockedFunction<typeof createUser>;
55+
const mockSendEmail = sendEmail as jest.MockedFunction<typeof sendEmail>;
56+
const mockRenderAsync = renderAsync as jest.MockedFunction<typeof renderAsync>;
57+
const mockVerificationTokenCreate = prisma.verificationToken.create as jest.MockedFunction<typeof prisma.verificationToken.create>;
58+
const mockVerificationTokenDeleteMany = prisma.verificationToken.deleteMany as jest.MockedFunction<typeof prisma.verificationToken.deleteMany>;
59+
const mockSendUserOnboardedAdminAlert = sendUserOnboardedAdminAlert as jest.MockedFunction<typeof sendUserOnboardedAdminAlert>;
60+
61+
describe('POST /api/admin/users', () => {
62+
beforeEach(() => {
63+
jest.clearAllMocks();
64+
mockGetCurrentUser.mockResolvedValue({ isSuperAdmin: true } as unknown as Awaited<ReturnType<typeof getCurrentUser>>);
65+
mockVerificationTokenCreate.mockResolvedValue({} as Awaited<ReturnType<typeof prisma.verificationToken.create>>);
66+
mockVerificationTokenDeleteMany.mockResolvedValue({ count: 1 });
67+
mockRenderAsync.mockResolvedValue('<html>invite</html>');
68+
mockSendEmail.mockResolvedValue({ success: true, message: 'Email sent successfully' });
69+
mockSendUserOnboardedAdminAlert.mockResolvedValue(undefined);
70+
});
71+
72+
it('returns 401 when user is not super admin', async () => {
73+
mockGetCurrentUser.mockResolvedValueOnce(null);
74+
75+
const request = new Request('http://localhost/api/admin/users', {
76+
method: 'POST',
77+
body: JSON.stringify({ email: 'test@example.com' }),
78+
headers: { 'Content-Type': 'application/json' },
79+
});
80+
81+
const response = await POST(request);
82+
expect(response.status).toBe(401);
83+
});
84+
85+
it('returns 409 and surfaces duplicate email message', async () => {
86+
mockCreateUser.mockRejectedValueOnce(new Error('A user with this email already exists.'));
87+
88+
const request = new Request('http://localhost/api/admin/users', {
89+
method: 'POST',
90+
body: JSON.stringify({ email: 'duplicate@example.com', name: 'Duplicate User' }),
91+
headers: { 'Content-Type': 'application/json' },
92+
});
93+
94+
const response = await POST(request);
95+
expect(response.status).toBe(409);
96+
await expect(response.json()).resolves.toEqual({
97+
error: 'A user with this email already exists.',
98+
});
99+
});
100+
101+
it('returns success even when invite email sending fails', async () => {
102+
mockCreateUser.mockResolvedValueOnce({
103+
id: 'user-1',
104+
email: ' USER@Example.COM ',
105+
name: ' Test User ',
106+
administers: [],
107+
} as unknown as Awaited<ReturnType<typeof createUser>>);
108+
mockSendEmail.mockResolvedValueOnce({ success: false, message: 'Email failed' });
109+
110+
const request = new Request('http://localhost/api/admin/users', {
111+
method: 'POST',
112+
body: JSON.stringify({
113+
email: ' USER@Example.COM ',
114+
name: ' Test User ',
115+
isSuperAdmin: false,
116+
administers: [],
117+
}),
118+
headers: { 'Content-Type': 'application/json' },
119+
});
120+
121+
const response = await POST(request);
122+
123+
expect(response.status).toBe(200);
124+
await expect(response.json()).resolves.toEqual(
125+
expect.objectContaining({
126+
id: 'user-1',
127+
})
128+
);
129+
expect(mockSendEmail).toHaveBeenCalledTimes(1);
130+
expect(mockVerificationTokenCreate).toHaveBeenCalledWith(
131+
expect.objectContaining({
132+
data: expect.objectContaining({
133+
identifier: 'user@example.com',
134+
}),
135+
})
136+
);
137+
expect(mockVerificationTokenDeleteMany).toHaveBeenCalledWith(
138+
expect.objectContaining({
139+
where: expect.objectContaining({
140+
identifier: 'user@example.com',
141+
token: expect.any(String),
142+
}),
143+
})
144+
);
145+
expect(mockSendUserOnboardedAdminAlert).toHaveBeenCalledWith({
146+
cityName: 'Admin User',
147+
onboardingSource: 'admin_invite',
148+
});
149+
});
150+
});

src/app/api/admin/users/route.ts

Lines changed: 77 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,31 @@ import { env } from "@/env.mjs"
99
import { createUser, getUsers, updateUser } from "@/lib/db/users"
1010
import { sendUserOnboardedAdminAlert } from "@/lib/discord"
1111

12-
async function generateSignInLink(email: string) {
12+
interface ErrorWithCode {
13+
code?: string
14+
}
15+
16+
interface VerificationTokenKey {
17+
identifier: string
18+
token: string
19+
}
20+
21+
function getUserErrorResponse(error: unknown, fallbackMessage: string): NextResponse {
22+
const errorWithCode = error as ErrorWithCode
23+
const errorMessage = error instanceof Error ? error.message : fallbackMessage
24+
25+
if (errorWithCode.code === "P2002" || errorMessage === "A user with this email already exists.") {
26+
return NextResponse.json({ error: "A user with this email already exists." }, { status: 409 })
27+
}
28+
29+
if (errorMessage === "Email is required to create a user" || errorMessage === "Email cannot be empty") {
30+
return NextResponse.json({ error: errorMessage }, { status: 400 })
31+
}
32+
33+
return NextResponse.json({ error: errorMessage }, { status: 500 })
34+
}
35+
36+
async function generateSignInLink(email: string): Promise<{ signInUrl: string, verificationTokenKey: VerificationTokenKey }> {
1337
// Create a token that expires in 24 hours
1438
const token = createHash('sha256')
1539
.update(email + Date.now().toString())
@@ -26,22 +50,55 @@ async function generateSignInLink(email: string) {
2650

2751
// Generate the sign-in URL
2852
const signInUrl = `${env.NEXTAUTH_URL}/sign-in?token=${token}&email=${encodeURIComponent(email)}`
29-
return signInUrl
53+
return {
54+
signInUrl,
55+
verificationTokenKey: {
56+
identifier: email,
57+
token,
58+
}
59+
}
60+
}
61+
62+
async function deleteVerificationToken(verificationTokenKey: VerificationTokenKey): Promise<void> {
63+
try {
64+
await prisma.verificationToken.deleteMany({
65+
where: verificationTokenKey,
66+
})
67+
} catch (cleanupError) {
68+
console.error("Failed to clean up verification token after invite failure:", cleanupError)
69+
}
3070
}
3171

3272
async function sendInviteEmail(email: string, name: string) {
33-
const signInUrl = await generateSignInLink(email)
34-
const emailHtml = await renderAsync(UserInviteEmail({
35-
name: name || email,
36-
inviteUrl: signInUrl
37-
}))
38-
39-
await sendEmail({
40-
from: "OpenCouncil <auth@opencouncil.gr>",
41-
to: email,
42-
subject: "You've been invited to OpenCouncil",
43-
html: emailHtml,
44-
})
73+
let verificationTokenKey: VerificationTokenKey | null = null
74+
75+
try {
76+
const signInLinkData = await generateSignInLink(email)
77+
verificationTokenKey = signInLinkData.verificationTokenKey
78+
const emailHtml = await renderAsync(UserInviteEmail({
79+
name: name || email,
80+
inviteUrl: signInLinkData.signInUrl
81+
}))
82+
83+
const sendResult = await sendEmail({
84+
from: "OpenCouncil <auth@opencouncil.gr>",
85+
to: email,
86+
subject: "You've been invited to OpenCouncil",
87+
html: emailHtml,
88+
})
89+
90+
if (!sendResult.success && verificationTokenKey) {
91+
await deleteVerificationToken(verificationTokenKey)
92+
}
93+
94+
return sendResult.success
95+
} catch (error) {
96+
console.error("Failed to send invite email:", error)
97+
if (verificationTokenKey) {
98+
await deleteVerificationToken(verificationTokenKey)
99+
}
100+
return false
101+
}
45102
}
46103

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

74131
// Send invitation email
75-
await sendInviteEmail(email, name)
132+
const inviteEmailSent = await sendInviteEmail(newUser.email, newUser.name ?? newUser.email)
133+
if (!inviteEmailSent) {
134+
console.error(`User ${newUser.id} created, but invite email failed to send`)
135+
}
76136

77137
// Send Discord admin alert for admin invite
78138
sendUserOnboardedAdminAlert({
@@ -83,7 +143,7 @@ export async function POST(request: Request) {
83143
return NextResponse.json(newUser)
84144
} catch (error) {
85145
console.error("Failed to create user:", error)
86-
return new NextResponse("Failed to create user", { status: 500 })
146+
return getUserErrorResponse(error, "Failed to create user")
87147
}
88148
}
89149

@@ -101,7 +161,6 @@ export async function PUT(request: Request) {
101161
return NextResponse.json(updatedUser)
102162
} catch (error) {
103163
console.error("Failed to update user:", error)
104-
return new NextResponse("Failed to update user", { status: 500 })
164+
return getUserErrorResponse(error, "Failed to update user")
105165
}
106166
}
107-

src/components/admin/users/user-dialog.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"
1515
import { X } from "lucide-react"
1616
import { UserWithRelations } from "@/lib/db/users"
1717
import Combobox from "@/components/Combobox"
18+
import { toast } from "@/hooks/use-toast"
1819

1920
interface UserDialogProps {
2021
open: boolean
@@ -112,9 +113,11 @@ export function UserDialog({ open, onOpenChange, user, onDelete }: UserDialogPro
112113
setLoading(true)
113114

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

136139
if (!response.ok) {
137-
throw new Error("Failed to save user")
140+
const responseText = await response.text()
141+
let errorMessage = "Failed to save user"
142+
143+
if (responseText) {
144+
try {
145+
const parsedError = JSON.parse(responseText) as { error?: unknown }
146+
const parsedErrorMessage = parsedError.error
147+
148+
if (typeof parsedErrorMessage === "string" && parsedErrorMessage.trim()) {
149+
errorMessage = parsedErrorMessage
150+
} else if (parsedErrorMessage !== undefined) {
151+
errorMessage = JSON.stringify(parsedErrorMessage)
152+
} else {
153+
errorMessage = responseText
154+
}
155+
} catch {
156+
errorMessage = responseText
157+
}
158+
}
159+
160+
throw new Error(errorMessage)
138161
}
139162

163+
toast({
164+
title: "Success",
165+
description: isEditing ? "User updated successfully." : "User created successfully.",
166+
})
140167
onOpenChange(false)
141168
} catch (error) {
142169
console.error("Failed to save user:", error)
170+
toast({
171+
title: "Error",
172+
description: error instanceof Error ? error.message : "Failed to save user",
173+
variant: "destructive",
174+
})
143175
} finally {
144176
setLoading(false)
145177
}
@@ -268,4 +300,4 @@ export function UserDialog({ open, onOpenChange, user, onDelete }: UserDialogPro
268300
</DialogContent>
269301
</Dialog>
270302
)
271-
}
303+
}

0 commit comments

Comments
 (0)