Skip to content

Commit 648f442

Browse files
authored
feat: allow admins to change user roles (#230)
1 parent 025a0dd commit 648f442

7 files changed

Lines changed: 394 additions & 1 deletion

File tree

internal/api/src/routes/admin/users.client.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export type UpdateSuspensionRequest = z.infer<
4040
typeof schemaUpdateSuspensionRequest
4141
>;
4242

43+
export const schemaUpdateRoleRequest = z.object({
44+
site_role: schemaSiteRole,
45+
});
46+
47+
export type UpdateRoleRequest = z.infer<typeof schemaUpdateRoleRequest>;
48+
4349
export const schemaCreateUserRequest = z.object({
4450
email: z.email(),
4551
password: z.string().min(8, "Password must be at least 8 characters"),
@@ -126,4 +132,24 @@ export default class AdminUsers {
126132
await assertResponseStatus(resp, 201);
127133
return resp.json();
128134
}
135+
136+
/**
137+
* Update a user's role (admin only).
138+
*
139+
* @param userId - The user ID to update.
140+
* @param siteRole - The new site role.
141+
* @returns The updated user.
142+
*/
143+
public async updateRole(
144+
userId: string,
145+
siteRole: SiteRole
146+
): Promise<SiteUser> {
147+
const resp = await this.client.request(
148+
"PATCH",
149+
`/api/admin/users/${userId}/role`,
150+
JSON.stringify({ site_role: siteRole })
151+
);
152+
await assertResponseStatus(resp, 200);
153+
return resp.json();
154+
}
129155
}

internal/api/src/routes/admin/users.server.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type SiteUser,
1212
schemaCreateUserRequest,
1313
schemaListSiteUsersRequest,
14+
schemaUpdateRoleRequest,
1415
schemaUpdateSuspensionRequest,
1516
} from "./users.client";
1617

@@ -84,6 +85,48 @@ export default function mountAdminUsers(server: APIServer) {
8485
}
8586
);
8687

88+
// Update user role (site admin only).
89+
server.patch(
90+
"/:id/role",
91+
withSiteAdmin,
92+
validator("param", (value) => {
93+
return z.object({ id: z.string().uuid() }).parse(value);
94+
}),
95+
validator("json", (value) => {
96+
return schemaUpdateRoleRequest.parse(value);
97+
}),
98+
async (c) => {
99+
const db = await c.env.database();
100+
const { id } = c.req.valid("param");
101+
const { site_role } = c.req.valid("json");
102+
const currentUserId = c.get("user_id");
103+
104+
// Prevent changing own role
105+
if (id === currentUserId) {
106+
throw new HTTPException(400, {
107+
message: "Cannot change your own role",
108+
});
109+
}
110+
111+
// Check if user exists
112+
const existingUser = await db.selectUserByID(id);
113+
if (!existingUser) {
114+
throw new HTTPException(404, { message: "User not found" });
115+
}
116+
117+
// Update user role
118+
await db.updateUserByID({ id, site_role });
119+
120+
// Fetch updated user to return
121+
const updatedUser = await db.selectUserByID(id);
122+
if (!updatedUser) {
123+
throw new HTTPException(404, { message: "User not found" });
124+
}
125+
126+
return c.json(convertSiteUser(updatedUser));
127+
}
128+
);
129+
87130
// Create a new user (site admin only).
88131
server.post(
89132
"/",

internal/api/src/routes/admin/users.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,3 +333,101 @@ test("suspended user cannot authenticate with API key", async () => {
333333
"Account suspended"
334334
);
335335
});
336+
337+
test("PATCH /api/admin/users/:id/role returns 403 for non-admin user", async () => {
338+
const { helpers } = await serve();
339+
const { client: memberClient } = await helpers.createUser({
340+
site_role: "member",
341+
});
342+
const { user: targetUser } = await helpers.createUser({
343+
site_role: "member",
344+
});
345+
346+
await expect(
347+
memberClient.admin.users.updateRole(targetUser.id, "admin")
348+
).rejects.toThrow("Forbidden");
349+
});
350+
351+
test("PATCH /api/admin/users/:id/role returns 401 for unauthenticated request", async () => {
352+
const { helpers, url } = await serve();
353+
const { user: targetUser } = await helpers.createUser({
354+
site_role: "member",
355+
});
356+
357+
const unauthClient = new Client({ baseURL: url.toString() });
358+
359+
await expect(
360+
unauthClient.admin.users.updateRole(targetUser.id, "admin")
361+
).rejects.toThrow("Unauthorized");
362+
});
363+
364+
test("PATCH /api/admin/users/:id/role successfully promotes user to admin", async () => {
365+
const { helpers, bindings } = await serve();
366+
const { client: adminClient } = await helpers.createUser({
367+
site_role: "admin",
368+
});
369+
const { user: targetUser } = await helpers.createUser({
370+
site_role: "member",
371+
});
372+
373+
const result = await adminClient.admin.users.updateRole(
374+
targetUser.id,
375+
"admin"
376+
);
377+
378+
expect(result.id).toBe(targetUser.id);
379+
expect(result.site_role).toBe("admin");
380+
381+
// Verify the change persisted in the database
382+
const db = await bindings.database();
383+
const updatedUser = await db.selectUserByID(targetUser.id);
384+
expect(updatedUser?.site_role).toBe("admin");
385+
});
386+
387+
test("PATCH /api/admin/users/:id/role successfully demotes admin to member", async () => {
388+
const { helpers, bindings } = await serve();
389+
const { client: adminClient } = await helpers.createUser({
390+
site_role: "admin",
391+
});
392+
const { user: targetUser } = await helpers.createUser({
393+
site_role: "admin",
394+
});
395+
396+
const result = await adminClient.admin.users.updateRole(
397+
targetUser.id,
398+
"member"
399+
);
400+
401+
expect(result.id).toBe(targetUser.id);
402+
expect(result.site_role).toBe("member");
403+
404+
// Verify the change persisted in the database
405+
const db = await bindings.database();
406+
const updatedUser = await db.selectUserByID(targetUser.id);
407+
expect(updatedUser?.site_role).toBe("member");
408+
});
409+
410+
test("PATCH /api/admin/users/:id/role returns 400 when trying to change your own role", async () => {
411+
const { helpers } = await serve();
412+
const { client: adminClient, user: adminUser } = await helpers.createUser({
413+
site_role: "admin",
414+
});
415+
416+
await expect(
417+
adminClient.admin.users.updateRole(adminUser.id, "member")
418+
).rejects.toThrow("Cannot change your own role");
419+
});
420+
421+
test("PATCH /api/admin/users/:id/role returns 404 for non-existent user", async () => {
422+
const { helpers } = await serve();
423+
const { client: adminClient } = await helpers.createUser({
424+
site_role: "admin",
425+
});
426+
427+
await expect(
428+
adminClient.admin.users.updateRole(
429+
"00000000-0000-0000-0000-000000000000",
430+
"admin"
431+
)
432+
).rejects.toThrow("User not found");
433+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type { SiteUser } from "@blink.so/api";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { useState } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { ChangeRoleModal } from "./change-role-modal";
6+
7+
const mockMemberUser: SiteUser = {
8+
id: "user-123",
9+
created_at: new Date(),
10+
updated_at: new Date(),
11+
display_name: "John Doe",
12+
email: "john@example.com",
13+
avatar_url: null,
14+
username: "johndoe",
15+
organization_id: "org-1",
16+
site_role: "member",
17+
suspended: false,
18+
};
19+
20+
const mockAdminUser: SiteUser = {
21+
id: "user-456",
22+
created_at: new Date(),
23+
updated_at: new Date(),
24+
display_name: "Jane Admin",
25+
email: "jane@example.com",
26+
avatar_url: null,
27+
username: "janeadmin",
28+
organization_id: "org-1",
29+
site_role: "admin",
30+
suspended: false,
31+
};
32+
33+
function ChangeRoleModalDemo({
34+
user,
35+
initialError,
36+
}: {
37+
user: SiteUser;
38+
initialError?: string;
39+
}) {
40+
const [open, setOpen] = useState(true);
41+
42+
const handleRoleChanged = async () => {
43+
setOpen(false);
44+
};
45+
46+
return (
47+
<>
48+
<Button onClick={() => setOpen(true)}>Open Modal</Button>
49+
<ChangeRoleModal
50+
open={open}
51+
user={user}
52+
onClose={() => setOpen(false)}
53+
onRoleChanged={handleRoleChanged}
54+
initialError={initialError}
55+
/>
56+
</>
57+
);
58+
}
59+
60+
const meta: Meta<typeof ChangeRoleModal> = {
61+
title: "Page/SiteAdmin/ChangeRoleModal",
62+
component: ChangeRoleModal,
63+
parameters: {
64+
layout: "centered",
65+
},
66+
};
67+
68+
export default meta;
69+
70+
type Story = StoryObj<typeof meta>;
71+
72+
export const MemberToAdmin: Story = {
73+
render: () => <ChangeRoleModalDemo user={mockMemberUser} />,
74+
};
75+
76+
export const AdminToMember: Story = {
77+
render: () => <ChangeRoleModalDemo user={mockAdminUser} />,
78+
};
79+
80+
export const WithError: Story = {
81+
render: () => (
82+
<ChangeRoleModalDemo
83+
user={mockMemberUser}
84+
initialError="Cannot change your own role"
85+
/>
86+
),
87+
};

0 commit comments

Comments
 (0)