Skip to content

Commit 9617a25

Browse files
authored
feat: allows admins to suspend users (#228)
1 parent 7a1e123 commit 9617a25

17 files changed

Lines changed: 3967 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ Use `bun` for all package management, test running, building, etc.
33
Use `bun run typecheck` to run type checking.
44

55
Use `bunx biome check <path-to-file>` to run lint when you modify code.
6+
7+
To create database migrations, edit internal/database/src/schema.ts and run `cd internal/database && bun run generate --name <migration-name>` to generate the migration files.

internal/api/src/middleware.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,14 @@ export function createAuthMiddleware(
105105
) {
106106
throw new HTTPException(401, { message: "API key has expired" });
107107
}
108+
109+
const userForSuspensionCheck = await db.selectUserByID(apiKey.user_id);
110+
if (!userForSuspensionCheck) {
111+
throw new HTTPException(401, { message: "User not found" });
112+
}
113+
if (userForSuspensionCheck.suspended) {
114+
throw new HTTPException(403, { message: "Account suspended" });
115+
}
108116
await db.updateApiKey(apiKey.id, { last_used_at: new Date() });
109117

110118
c.set("user_id", apiKey.user_id);
@@ -167,6 +175,16 @@ export function createAuthMiddleware(
167175
throw new HTTPException(401, { message: "Unauthorized" });
168176
}
169177

178+
// Check if user is suspended
179+
const db = await c.env.database();
180+
const userForSuspensionCheck = await db.selectUserByID(token.sub);
181+
if (!userForSuspensionCheck) {
182+
throw new HTTPException(401, { message: "User not found" });
183+
}
184+
if (userForSuspensionCheck.suspended) {
185+
throw new HTTPException(403, { message: "Account suspended" });
186+
}
187+
170188
c.set("user_id", token.sub);
171189
c.set("auth_type", "session");
172190
await next();

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const schemaSiteUser = z.object({
2020
username: z.string(),
2121
organization_id: z.uuid(),
2222
site_role: schemaSiteRole,
23+
suspended: z.boolean(),
2324
});
2425

2526
export type SiteUser = z.infer<typeof schemaSiteUser>;
@@ -31,6 +32,14 @@ export const schemaListSiteUsersRequest = schemaPaginatedRequest.extend({
3132

3233
export type ListSiteUsersRequest = z.infer<typeof schemaListSiteUsersRequest>;
3334

35+
export const schemaUpdateSuspensionRequest = z.object({
36+
suspended: z.boolean(),
37+
});
38+
39+
export type UpdateSuspensionRequest = z.infer<
40+
typeof schemaUpdateSuspensionRequest
41+
>;
42+
3443
const schemaListSiteUsersResponse = schemaPaginatedResponse(schemaSiteUser);
3544

3645
export type ListSiteUsersResponse = z.infer<typeof schemaListSiteUsersResponse>;
@@ -71,4 +80,24 @@ export default class AdminUsers {
7180
await assertResponseStatus(resp, 200);
7281
return resp.json();
7382
}
83+
84+
/**
85+
* Update a user's suspension status (admin only).
86+
*
87+
* @param userId - The user ID to update.
88+
* @param suspended - Whether the user should be suspended.
89+
* @returns The updated user.
90+
*/
91+
public async updateSuspension(
92+
userId: string,
93+
suspended: boolean
94+
): Promise<SiteUser> {
95+
const resp = await this.client.request(
96+
"PATCH",
97+
`/api/admin/users/${userId}/suspension`,
98+
JSON.stringify({ suspended })
99+
);
100+
await assertResponseStatus(resp, 200);
101+
return resp.json();
102+
}
74103
}

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { UserWithPersonalOrganization } from "@blink.so/database/schema";
2+
import { HTTPException } from "hono/http-exception";
23
import { validator } from "hono/validator";
4+
import { z } from "zod";
35
import { withPagination, withSiteAdmin } from "../../middleware";
46
import type { APIServer } from "../../server";
57
import {
68
type ListSiteUsersResponse,
79
type SiteUser,
810
schemaListSiteUsersRequest,
11+
schemaUpdateSuspensionRequest,
912
} from "./users.client";
1013

1114
export default function mountAdminUsers(server: APIServer) {
@@ -35,6 +38,48 @@ export default function mountAdminUsers(server: APIServer) {
3538
return c.json(resp);
3639
}
3740
);
41+
42+
// Update user suspension status (site admin only).
43+
server.patch(
44+
"/:id/suspension",
45+
withSiteAdmin,
46+
validator("param", (value) => {
47+
return z.object({ id: z.string().uuid() }).parse(value);
48+
}),
49+
validator("json", (value) => {
50+
return schemaUpdateSuspensionRequest.parse(value);
51+
}),
52+
async (c) => {
53+
const db = await c.env.database();
54+
const { id } = c.req.valid("param");
55+
const { suspended } = c.req.valid("json");
56+
const currentUserId = c.get("user_id");
57+
58+
// Prevent self-suspension
59+
if (id === currentUserId) {
60+
throw new HTTPException(400, {
61+
message: "Cannot suspend your own account",
62+
});
63+
}
64+
65+
// Check if user exists
66+
const existingUser = await db.selectUserByID(id);
67+
if (!existingUser) {
68+
throw new HTTPException(404, { message: "User not found" });
69+
}
70+
71+
// Update user suspension status
72+
await db.updateUserByID({ id, suspended });
73+
74+
// Fetch updated user to return
75+
const updatedUser = await db.selectUserByID(id);
76+
if (!updatedUser) {
77+
throw new HTTPException(404, { message: "User not found" });
78+
}
79+
80+
return c.json(convertSiteUser(updatedUser));
81+
}
82+
);
3883
}
3984

4085
const convertSiteUser = (
@@ -49,6 +94,7 @@ const convertSiteUser = (
4994
| "username"
5095
| "organization_id"
5196
| "site_role"
97+
| "suspended"
5298
>
5399
): SiteUser => {
54100
return {
@@ -61,5 +107,6 @@ const convertSiteUser = (
61107
username: user.username,
62108
organization_id: user.organization_id,
63109
site_role: user.site_role,
110+
suspended: user.suspended,
64111
};
65112
};

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

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,149 @@ test("GET /api/admin/users filters by query", async () => {
7373
expect(results.items.some((u) => u.id === adminUser.id)).toBe(true);
7474
expect(results.items.some((u) => u.id === otherUser.id)).toBe(false);
7575
});
76+
77+
test("GET /api/admin/users includes suspended field", async () => {
78+
const { helpers } = await serve();
79+
const { client: adminClient } = await helpers.createUser({
80+
site_role: "admin",
81+
});
82+
83+
const response = await adminClient.admin.users.list();
84+
85+
expect(response.items.length).toBeGreaterThanOrEqual(1);
86+
expect(response.items[0].suspended).toBe(false);
87+
});
88+
89+
test("PATCH /api/admin/users/:id/suspension returns 403 for non-admin user", async () => {
90+
const { helpers } = await serve();
91+
const { client: memberClient } = await helpers.createUser({
92+
site_role: "member",
93+
});
94+
const { user: targetUser } = await helpers.createUser({
95+
site_role: "member",
96+
});
97+
98+
await expect(
99+
memberClient.admin.users.updateSuspension(targetUser.id, true)
100+
).rejects.toThrow("Forbidden");
101+
});
102+
103+
test("PATCH /api/admin/users/:id/suspension successfully suspends a user", async () => {
104+
const { helpers } = await serve();
105+
const { client: adminClient } = await helpers.createUser({
106+
site_role: "admin",
107+
});
108+
const { user: targetUser } = await helpers.createUser({
109+
site_role: "member",
110+
});
111+
112+
const result = await adminClient.admin.users.updateSuspension(
113+
targetUser.id,
114+
true
115+
);
116+
117+
expect(result.id).toBe(targetUser.id);
118+
expect(result.suspended).toBe(true);
119+
});
120+
121+
test("PATCH /api/admin/users/:id/suspension successfully unsuspends a user", async () => {
122+
const { helpers } = await serve();
123+
const { client: adminClient } = await helpers.createUser({
124+
site_role: "admin",
125+
});
126+
const { user: targetUser } = await helpers.createUser({
127+
site_role: "member",
128+
});
129+
130+
// First suspend the user
131+
await adminClient.admin.users.updateSuspension(targetUser.id, true);
132+
133+
// Then unsuspend
134+
const result = await adminClient.admin.users.updateSuspension(
135+
targetUser.id,
136+
false
137+
);
138+
139+
expect(result.id).toBe(targetUser.id);
140+
expect(result.suspended).toBe(false);
141+
});
142+
143+
test("PATCH /api/admin/users/:id/suspension returns 400 when trying to suspend yourself", async () => {
144+
const { helpers } = await serve();
145+
const { client: adminClient, user: adminUser } = await helpers.createUser({
146+
site_role: "admin",
147+
});
148+
149+
await expect(
150+
adminClient.admin.users.updateSuspension(adminUser.id, true)
151+
).rejects.toThrow("Cannot suspend your own account");
152+
});
153+
154+
test("PATCH /api/admin/users/:id/suspension returns 404 for non-existent user", async () => {
155+
const { helpers } = await serve();
156+
const { client: adminClient } = await helpers.createUser({
157+
site_role: "admin",
158+
});
159+
160+
await expect(
161+
adminClient.admin.users.updateSuspension(
162+
"00000000-0000-0000-0000-000000000000",
163+
true
164+
)
165+
).rejects.toThrow("User not found");
166+
});
167+
168+
test("suspended user cannot authenticate with session", async () => {
169+
const { helpers } = await serve();
170+
const { client: adminClient } = await helpers.createUser({
171+
site_role: "admin",
172+
});
173+
const { client: targetClient, user: targetUser } = await helpers.createUser({
174+
site_role: "member",
175+
});
176+
177+
// Verify user can access API before suspension
178+
const orgs = await targetClient.organizations.list();
179+
expect(orgs).toBeDefined();
180+
expect(Array.isArray(orgs)).toBe(true);
181+
182+
// Suspend the user
183+
await adminClient.admin.users.updateSuspension(targetUser.id, true);
184+
185+
// Verify suspended user cannot access API
186+
await expect(targetClient.organizations.list()).rejects.toThrow(
187+
"Account suspended"
188+
);
189+
});
190+
191+
test("suspended user cannot authenticate with API key", async () => {
192+
const { helpers, url } = await serve();
193+
const { client: adminClient } = await helpers.createUser({
194+
site_role: "admin",
195+
});
196+
const { client: targetClient, user: targetUser } = await helpers.createUser({
197+
site_role: "member",
198+
});
199+
200+
// Create an API key for the target user
201+
const apiKey = await targetClient.users.createApiKey({
202+
name: "Test API Key",
203+
});
204+
const targetApiKeyClient = new Client({
205+
baseURL: url.toString(),
206+
authToken: apiKey.key,
207+
});
208+
209+
// Verify user can access API before suspension
210+
const orgs = await targetApiKeyClient.organizations.list();
211+
expect(orgs).toBeDefined();
212+
expect(Array.isArray(orgs)).toBe(true);
213+
214+
// Suspend the user
215+
await adminClient.admin.users.updateSuspension(targetUser.id, true);
216+
217+
// Verify suspended user cannot access API with API key
218+
await expect(targetApiKeyClient.organizations.list()).rejects.toThrow(
219+
"Account suspended"
220+
);
221+
});

0 commit comments

Comments
 (0)