Skip to content

Commit 3dfd82f

Browse files
authored
feat: site settings - users (#225)
1 parent af1d7b7 commit 3dfd82f

35 files changed

Lines changed: 4535 additions & 53 deletions

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Use `bun` for all package management, test running, building, etc.
2+
3+
Use `bun run typecheck` to run type checking.
4+
5+
Use `bunx biome check <path-to-file>` to run lint when you modify code.

internal/api/src/client-helper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const assertResponseStatus = async <T extends number>(
3737
};
3838

3939
export const schemaPaginatedRequest = z.object({
40-
per_page: z.number().int().positive().max(100).default(10).optional(),
41-
page: z.number().int().nonnegative().default(0).optional(),
40+
per_page: z.coerce.number().int().positive().max(100).default(10).optional(),
41+
page: z.coerce.number().int().nonnegative().default(0).optional(),
4242
});
4343

4444
/**

internal/api/src/client.browser.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AdminUsers from "./routes/admin/users.client";
12
import Agents from "./routes/agents/agents.client";
23
import Auth from "./routes/auth/auth.client";
34
import Chats from "./routes/chats/chats.client";
@@ -21,6 +22,9 @@ export default class Client {
2122
protected readonly baseURL: URL;
2223
private readonly fetch?: typeof globalThis.fetch;
2324

25+
public readonly admin = {
26+
users: new AdminUsers(this),
27+
};
2428
public readonly auth: Auth;
2529
public readonly chats = new Chats(this);
2630

@@ -98,6 +102,7 @@ export default class Client {
98102
}
99103
}
100104

105+
export * from "./routes/admin/users.client";
101106
export * from "./routes/agents/agents.client";
102107
export * from "./routes/agents/traces.client";
103108
export * from "./routes/chats/chats.client";

internal/api/src/middleware.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,29 @@ function readAuthTokenFromRequest(
205205

206206
export const withAuth = createAuthMiddleware();
207207

208+
/**
209+
* Middleware to ensure the user is a site admin.
210+
* Automatically calls withAuth first.
211+
*/
212+
export const withSiteAdmin: MiddlewareHandler<{
213+
Bindings: Bindings;
214+
Variables: {
215+
user_id: string;
216+
api_key?: ApiKey;
217+
auth_type: "session" | "api_key";
218+
};
219+
}> = async (c, next) => {
220+
await withAuth(c, async () => {
221+
const db = await c.env.database();
222+
const userId = c.get("user_id");
223+
const user = await db.selectUserByID(userId);
224+
if (!user || user.site_role !== "admin") {
225+
throw new HTTPException(403, { message: "Forbidden" });
226+
}
227+
await next();
228+
});
229+
};
230+
208231
export const withDevhookAuth: MiddlewareHandler<{
209232
Bindings: Bindings;
210233
Variables: {
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { z } from "zod";
2+
import type Client from "../../client.browser";
3+
import {
4+
assertResponseStatus,
5+
schemaPaginatedRequest,
6+
schemaPaginatedResponse,
7+
} from "../../client-helper";
8+
9+
export const schemaSiteRole = z.enum(["admin", "member"]);
10+
11+
export type SiteRole = z.infer<typeof schemaSiteRole>;
12+
13+
const schemaSiteUser = z.object({
14+
id: z.uuid(),
15+
created_at: z.date(),
16+
updated_at: z.date(),
17+
display_name: z.string().nullable(),
18+
email: z.email(),
19+
avatar_url: z.url().nullable(),
20+
username: z.string(),
21+
organization_id: z.uuid(),
22+
site_role: schemaSiteRole,
23+
});
24+
25+
export type SiteUser = z.infer<typeof schemaSiteUser>;
26+
27+
export const schemaListSiteUsersRequest = schemaPaginatedRequest.extend({
28+
query: z.string().optional(),
29+
site_role: schemaSiteRole.optional(),
30+
});
31+
32+
export type ListSiteUsersRequest = z.infer<typeof schemaListSiteUsersRequest>;
33+
34+
const schemaListSiteUsersResponse = schemaPaginatedResponse(schemaSiteUser);
35+
36+
export type ListSiteUsersResponse = z.infer<typeof schemaListSiteUsersResponse>;
37+
38+
export default class AdminUsers {
39+
private readonly client: Client;
40+
41+
public constructor(client: Client) {
42+
this.client = client;
43+
}
44+
45+
/**
46+
* List all users in the site (admin only).
47+
*
48+
* @param request - The request body.
49+
* @returns The list of users.
50+
*/
51+
public async list(
52+
request: ListSiteUsersRequest = {}
53+
): Promise<ListSiteUsersResponse> {
54+
const query = new URLSearchParams();
55+
if (request.per_page) {
56+
query.set("per_page", request.per_page.toString());
57+
}
58+
if (request.page) {
59+
query.set("page", request.page.toString());
60+
}
61+
if (request.query) {
62+
query.set("query", request.query);
63+
}
64+
if (request.site_role) {
65+
query.set("site_role", request.site_role);
66+
}
67+
const resp = await this.client.request(
68+
"GET",
69+
`/api/admin/users?${query.toString()}`
70+
);
71+
await assertResponseStatus(resp, 200);
72+
return resp.json();
73+
}
74+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { UserWithPersonalOrganization } from "@blink.so/database/schema";
2+
import { validator } from "hono/validator";
3+
import { withPagination, withSiteAdmin } from "../../middleware";
4+
import type { APIServer } from "../../server";
5+
import {
6+
type ListSiteUsersResponse,
7+
type SiteUser,
8+
schemaListSiteUsersRequest,
9+
} from "./users.client";
10+
11+
export default function mountAdminUsers(server: APIServer) {
12+
// List all users (site admin only).
13+
server.get(
14+
"/",
15+
withSiteAdmin,
16+
withPagination,
17+
validator("query", (value) => {
18+
return schemaListSiteUsersRequest.parse(value);
19+
}),
20+
async (c) => {
21+
const db = await c.env.database();
22+
const { query, site_role } = c.req.valid("query");
23+
24+
const users = await db.selectAllUsers({
25+
page: c.get("page"),
26+
per_page: c.get("per_page"),
27+
query: query || undefined,
28+
siteRole: site_role || undefined,
29+
});
30+
31+
const resp: ListSiteUsersResponse = {
32+
has_more: users.has_more,
33+
items: users.items.map((u) => convertSiteUser(u)),
34+
};
35+
return c.json(resp);
36+
}
37+
);
38+
}
39+
40+
const convertSiteUser = (
41+
user: Pick<
42+
UserWithPersonalOrganization,
43+
| "id"
44+
| "created_at"
45+
| "updated_at"
46+
| "display_name"
47+
| "email"
48+
| "avatar_url"
49+
| "username"
50+
| "organization_id"
51+
| "site_role"
52+
>
53+
): SiteUser => {
54+
return {
55+
id: user.id,
56+
created_at: user.created_at,
57+
updated_at: user.updated_at,
58+
display_name: user.display_name,
59+
email: user.email ?? "",
60+
avatar_url: user.avatar_url,
61+
username: user.username,
62+
organization_id: user.organization_id,
63+
site_role: user.site_role,
64+
};
65+
};
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect, test } from "bun:test";
2+
import Client from "../../client.node";
3+
import { serve } from "../../test";
4+
5+
test("GET /api/admin/users returns 403 for non-admin user", async () => {
6+
const { helpers } = await serve();
7+
const { client } = await helpers.createUser({ site_role: "member" });
8+
9+
await expect(client.admin.users.list()).rejects.toThrow("Forbidden");
10+
});
11+
12+
test("GET /api/admin/users returns users for site admin", async () => {
13+
const { helpers } = await serve();
14+
const { client: adminClient } = await helpers.createUser({
15+
site_role: "admin",
16+
});
17+
const { user: regularUser } = await helpers.createUser({
18+
site_role: "member",
19+
});
20+
21+
const response = await adminClient.admin.users.list();
22+
23+
expect(response.items).toBeDefined();
24+
expect(response.items.length).toBeGreaterThanOrEqual(2);
25+
26+
const regularUserInList = response.items.find((u) => u.id === regularUser.id);
27+
expect(regularUserInList).toBeDefined();
28+
expect(regularUserInList?.site_role).toBe("member");
29+
});
30+
31+
test("GET /api/admin/users returns 401 for unauthenticated request", async () => {
32+
const { url } = await serve();
33+
34+
const unauthClient = new Client({
35+
baseURL: url.toString(),
36+
});
37+
38+
await expect(unauthClient.admin.users.list()).rejects.toThrow("Unauthorized");
39+
});
40+
41+
test("GET /api/admin/users filters by site_role", async () => {
42+
const { helpers } = await serve();
43+
const { client: adminClient, user: adminUser } = await helpers.createUser({
44+
site_role: "admin",
45+
});
46+
const { user: memberUser } = await helpers.createUser({
47+
site_role: "member",
48+
});
49+
50+
const adminOnly = await adminClient.admin.users.list({ site_role: "admin" });
51+
52+
expect(adminOnly.items.every((u) => u.site_role === "admin")).toBe(true);
53+
expect(adminOnly.items.some((u) => u.id === adminUser.id)).toBe(true);
54+
expect(adminOnly.items.some((u) => u.id === memberUser.id)).toBe(false);
55+
});
56+
57+
test("GET /api/admin/users filters by query", async () => {
58+
const { helpers } = await serve();
59+
const { client: adminClient, user: adminUser } = await helpers.createUser({
60+
site_role: "admin",
61+
email: "admin-unique-email@example.com",
62+
});
63+
const { user: otherUser } = await helpers.createUser({
64+
site_role: "member",
65+
email: "other-user@example.com",
66+
});
67+
68+
const results = await adminClient.admin.users.list({
69+
query: "admin-unique-email",
70+
});
71+
72+
expect(results.items.length).toBeGreaterThanOrEqual(1);
73+
expect(results.items.some((u) => u.id === adminUser.id)).toBe(true);
74+
expect(results.items.some((u) => u.id === otherUser.id)).toBe(false);
75+
});

0 commit comments

Comments
 (0)