Skip to content

Commit 025a0dd

Browse files
authored
feat: allow admins to create users (#229)
1 parent 9617a25 commit 025a0dd

14 files changed

Lines changed: 826 additions & 360 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ Use `bun run typecheck` to run type checking.
55
Use `bunx biome check <path-to-file>` to run lint when you modify code.
66

77
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.
8+
9+
When you create new frontend components or modify them, update their Storybook stories too. When you create a new component, create a new story for it.

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,16 @@ export type UpdateSuspensionRequest = z.infer<
4040
typeof schemaUpdateSuspensionRequest
4141
>;
4242

43+
export const schemaCreateUserRequest = z.object({
44+
email: z.email(),
45+
password: z.string().min(8, "Password must be at least 8 characters"),
46+
display_name: z.string().nullable().optional(),
47+
site_role: schemaSiteRole.optional(),
48+
authentication_type: z.literal("password"),
49+
});
50+
51+
export type CreateUserRequest = z.infer<typeof schemaCreateUserRequest>;
52+
4353
const schemaListSiteUsersResponse = schemaPaginatedResponse(schemaSiteUser);
4454

4555
export type ListSiteUsersResponse = z.infer<typeof schemaListSiteUsersResponse>;
@@ -100,4 +110,20 @@ export default class AdminUsers {
100110
await assertResponseStatus(resp, 200);
101111
return resp.json();
102112
}
113+
114+
/**
115+
* Create a new user (admin only).
116+
*
117+
* @param request - The create user request.
118+
* @returns The created user.
119+
*/
120+
public async create(request: CreateUserRequest): Promise<SiteUser> {
121+
const resp = await this.client.request(
122+
"POST",
123+
"/api/admin/users",
124+
JSON.stringify(request)
125+
);
126+
await assertResponseStatus(resp, 201);
127+
return resp.json();
128+
}
103129
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import { validator } from "hono/validator";
44
import { z } from "zod";
55
import { withPagination, withSiteAdmin } from "../../middleware";
66
import type { APIServer } from "../../server";
7+
import { hashPassword } from "../../util/password";
8+
import { provisionUser } from "../provision-user";
79
import {
810
type ListSiteUsersResponse,
911
type SiteUser,
12+
schemaCreateUserRequest,
1013
schemaListSiteUsersRequest,
1114
schemaUpdateSuspensionRequest,
1215
} from "./users.client";
@@ -80,6 +83,43 @@ export default function mountAdminUsers(server: APIServer) {
8083
return c.json(convertSiteUser(updatedUser));
8184
}
8285
);
86+
87+
// Create a new user (site admin only).
88+
server.post(
89+
"/",
90+
withSiteAdmin,
91+
validator("json", (value) => {
92+
return schemaCreateUserRequest.parse(value);
93+
}),
94+
async (c) => {
95+
const db = await c.env.database();
96+
const { email, password, display_name, site_role } = c.req.valid("json");
97+
98+
// Check if user with email already exists
99+
const existingUser = await db.selectUserByEmail(email);
100+
if (existingUser) {
101+
throw new HTTPException(409, {
102+
message: "User with this email already exists",
103+
});
104+
}
105+
106+
const hashedPassword = await hashPassword(password);
107+
108+
const newUser = await provisionUser({
109+
db,
110+
autoJoinOrganizations: c.env.autoJoinOrganizations,
111+
user: {
112+
email,
113+
password: hashedPassword,
114+
display_name: display_name ?? null,
115+
email_verified: new Date(), // Admin-created users are pre-verified
116+
site_role,
117+
},
118+
});
119+
120+
return c.json(convertSiteUser(newUser), 201);
121+
}
122+
);
83123
}
84124

85125
const convertSiteUser = (

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

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,120 @@ import { expect, test } from "bun:test";
22
import Client from "../../client.node";
33
import { serve } from "../../test";
44

5+
test("POST /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(
10+
client.admin.users.create({
11+
email: "newuser@example.com",
12+
password: "password123",
13+
authentication_type: "password",
14+
})
15+
).rejects.toThrow("Forbidden");
16+
});
17+
18+
test("POST /api/admin/users returns 401 for unauthenticated request", async () => {
19+
const { url } = await serve();
20+
const unauthClient = new Client({ baseURL: url.toString() });
21+
22+
await expect(
23+
unauthClient.admin.users.create({
24+
email: "newuser@example.com",
25+
password: "password123",
26+
authentication_type: "password",
27+
})
28+
).rejects.toThrow("Unauthorized");
29+
});
30+
31+
test("POST /api/admin/users successfully creates a user", async () => {
32+
const { helpers, bindings } = await serve();
33+
const { client: adminClient } = await helpers.createUser({
34+
site_role: "admin",
35+
});
36+
37+
const result = await adminClient.admin.users.create({
38+
email: "newuser@example.com",
39+
password: "password123",
40+
display_name: "New User",
41+
site_role: "member",
42+
authentication_type: "password",
43+
});
44+
45+
expect(result.email).toBe("newuser@example.com");
46+
expect(result.display_name).toBe("New User");
47+
expect(result.site_role).toBe("member");
48+
expect(result.suspended).toBe(false);
49+
50+
// Verify user was created in database
51+
const db = await bindings.database();
52+
const user = await db.selectUserByEmail(result.email);
53+
expect(user).toBeDefined();
54+
expect(user?.email).toBe(result.email);
55+
expect(user?.display_name).toBe(result.display_name);
56+
expect(user?.site_role).toBe(result.site_role);
57+
expect(user?.email_verified).not.toBeNull();
58+
expect(user?.password).toBeDefined();
59+
});
60+
61+
test("POST /api/admin/users returns 409 for duplicate email", async () => {
62+
const { helpers } = await serve();
63+
const { client: adminClient } = await helpers.createUser({
64+
site_role: "admin",
65+
});
66+
await helpers.createUser({
67+
email: "existing@example.com",
68+
});
69+
70+
await expect(
71+
adminClient.admin.users.create({
72+
email: "existing@example.com",
73+
password: "password123",
74+
authentication_type: "password",
75+
})
76+
).rejects.toThrow("User with this email already exists");
77+
});
78+
79+
test("POST /api/admin/users validates password minimum length", async () => {
80+
const { helpers } = await serve();
81+
const { client: adminClient } = await helpers.createUser({
82+
site_role: "admin",
83+
});
84+
85+
await expect(
86+
adminClient.admin.users.create({
87+
email: "newuser@example.com",
88+
password: "short",
89+
authentication_type: "password",
90+
})
91+
).rejects.toThrow();
92+
});
93+
94+
test("POST /api/admin/users created user can login", async () => {
95+
const { helpers, url } = await serve();
96+
const { client: adminClient } = await helpers.createUser({
97+
site_role: "admin",
98+
});
99+
100+
const email = "logintest@example.com";
101+
const password = "password123";
102+
103+
await adminClient.admin.users.create({
104+
email,
105+
password,
106+
authentication_type: "password",
107+
});
108+
109+
// Test that the created user can sign in
110+
const newClient = new Client({ baseURL: url.toString() });
111+
const loginResult = await newClient.auth.signInWithCredentials({
112+
email,
113+
password,
114+
});
115+
expect(loginResult.ok).toBe(true);
116+
expect(loginResult.url).toBeDefined();
117+
});
118+
5119
test("GET /api/admin/users returns 403 for non-admin user", async () => {
6120
const { helpers } = await serve();
7121
const { client } = await helpers.createUser({ site_role: "member" });
@@ -83,7 +197,7 @@ test("GET /api/admin/users includes suspended field", async () => {
83197
const response = await adminClient.admin.users.list();
84198

85199
expect(response.items.length).toBeGreaterThanOrEqual(1);
86-
expect(response.items[0].suspended).toBe(false);
200+
expect(response.items[0]?.suspended).toBe(false);
87201
});
88202

89203
test("PATCH /api/admin/users/:id/suspension returns 403 for non-admin user", async () => {

internal/api/src/routes/auth/auth.server.ts

Lines changed: 23 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import type { User, UserAccount } from "@blink.so/database/schema";
2-
import { compare, hash } from "bcrypt-ts";
2+
import { compare } from "bcrypt-ts";
33
import type { Context } from "hono";
44
import { getCookie, setCookie } from "hono/cookie";
55
import { validator } from "hono/validator";
66
import { decode, encode } from "next-auth/jwt";
77
import { z } from "zod";
88
import { withAuth } from "../../middleware";
99
import type { APIServer, Bindings } from "../../server";
10+
import { hashPassword } from "../../util/password";
11+
import { provisionUser } from "../provision-user";
1012
import {
1113
schemaRequestEmailChangeRequest,
1214
schemaRequestPasswordResetRequest,
@@ -295,45 +297,17 @@ async function handleOAuthCallback(
295297
}
296298
}
297299

298-
// Check if this is the first user (for site admin role)
299-
let isFirstUser = false;
300-
let teamOrgs: Awaited<
301-
ReturnType<typeof db.selectTeamOrganizations>
302-
> | null = null;
303-
if (c.env.autoJoinOrganizations) {
304-
teamOrgs = await db.selectTeamOrganizations();
305-
isFirstUser = teamOrgs.length === 0;
306-
}
307-
308-
user = await db.insertUser({
309-
email: userProfile.email,
310-
email_verified: new Date(),
311-
display_name: userProfile.name,
312-
password: null,
313-
site_role: isFirstUser ? "admin" : "member",
300+
user = await provisionUser({
301+
db,
302+
autoJoinOrganizations: c.env.autoJoinOrganizations,
303+
user: {
304+
email: userProfile.email ?? null,
305+
email_verified: new Date(),
306+
display_name: userProfile.name ?? null,
307+
password: null,
308+
},
314309
});
315310

316-
// Auto-join team organizations (self-hosted mode)
317-
if (c.env.autoJoinOrganizations && teamOrgs) {
318-
if (isFirstUser) {
319-
// First user: create default org, make them owner
320-
await db.insertOrganizationWithMembership({
321-
name: "default",
322-
kind: "organization",
323-
created_by: user.id,
324-
});
325-
} else {
326-
// Subsequent users: add as member to all existing team orgs
327-
for (const org of teamOrgs) {
328-
await db.insertOrganizationMembership({
329-
organization_id: org.id,
330-
user_id: user.id,
331-
role: "member",
332-
});
333-
}
334-
}
335-
}
336-
337311
// consume single-use invite (mark accepted)
338312
if (usedInviteId) {
339313
try {
@@ -760,7 +734,7 @@ export default function mountAuth(server: APIServer) {
760734
}
761735

762736
// Hash and update password
763-
const hashedPassword = await hash(password, 12);
737+
const hashedPassword = await hashPassword(password);
764738
await db.updateUserByID({ id: user.id, password: hashedPassword });
765739

766740
// Clear cookies
@@ -958,50 +932,22 @@ export default function mountAuth(server: APIServer) {
958932
}
959933

960934
// Hash password and create user
961-
const hashedPassword = await hash(password, 12);
935+
const hashedPassword = await hashPassword(password);
962936

963937
// If emails are not configured, verify immediately
964938
const emailVerified = c.env.sendEmail ? null : new Date();
965939

966-
// Check if this is the first user (for site admin role)
967-
let isFirstUser = false;
968-
let teamOrgs: Awaited<
969-
ReturnType<typeof db.selectTeamOrganizations>
970-
> | null = null;
971-
if (c.env.autoJoinOrganizations) {
972-
teamOrgs = await db.selectTeamOrganizations();
973-
isFirstUser = teamOrgs.length === 0;
974-
}
975-
976-
const user = await db.insertUser({
977-
display_name: null,
978-
email,
979-
password: hashedPassword,
980-
email_verified: emailVerified,
981-
site_role: isFirstUser ? "admin" : "member",
940+
const user = await provisionUser({
941+
db,
942+
autoJoinOrganizations: c.env.autoJoinOrganizations,
943+
user: {
944+
display_name: null,
945+
email,
946+
password: hashedPassword,
947+
email_verified: emailVerified,
948+
},
982949
});
983950

984-
// Auto-join team organizations (self-hosted mode)
985-
if (c.env.autoJoinOrganizations && teamOrgs) {
986-
if (isFirstUser) {
987-
// First user: create default org, make them owner
988-
await db.insertOrganizationWithMembership({
989-
name: "default",
990-
kind: "organization",
991-
created_by: user.id,
992-
});
993-
} else {
994-
// Subsequent users: add as member to all existing team orgs
995-
for (const org of teamOrgs) {
996-
await db.insertOrganizationMembership({
997-
organization_id: org.id,
998-
user_id: user.id,
999-
role: "member",
1000-
});
1001-
}
1002-
}
1003-
}
1004-
1005951
// Sync user to telemetry system (async, don't block)
1006952
if (c.env.sendTelemetryEvent) {
1007953
c.env

0 commit comments

Comments
 (0)