feat: add user lifecycle management
This commit is contained in:
parent
8ab9a9a497
commit
11b16ac3cf
8 changed files with 331 additions and 47 deletions
|
|
@ -110,3 +110,10 @@ export const updateUserRolesSchema = z.object({
|
|||
userId: entityIdSchema,
|
||||
roleIds: z.array(entityIdSchema).min(1, "Select at least one role"),
|
||||
});
|
||||
|
||||
export const userCreateSchema = z.object({
|
||||
name: z.string().trim().min(2, "Name must be at least 2 characters").max(120),
|
||||
email: z.email("Invalid email address").max(255),
|
||||
password: z.string().min(8, "Password must be at least 8 characters").max(255),
|
||||
roleIds: z.array(entityIdSchema).min(1, "Select at least one role"),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { db, role, user, userRole } from "@minmon/db";
|
||||
import { db, account, role, user, userRole } from "@minmon/db";
|
||||
import { hashCredentialPassword } from "@minmon/db/auth-utils";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
|
||||
import { permissionedProcedure, protectedProcedure, router } from "../index";
|
||||
import { updateUserRolesSchema } from "./schemas";
|
||||
import { notFound } from "./utils";
|
||||
import { byIdSchema, updateUserRolesSchema, userCreateSchema } from "./schemas";
|
||||
import { badRequest, createEntityId, notFound } from "./utils";
|
||||
|
||||
async function getUsersWithRoles() {
|
||||
const [users, assignments] = await Promise.all([
|
||||
|
|
@ -38,6 +39,19 @@ async function getUsersWithRoles() {
|
|||
}));
|
||||
}
|
||||
|
||||
async function validateRoleIds(roleIds: string[]) {
|
||||
const roles = await db.select({ id: role.id }).from(role);
|
||||
const validRoleIds = new Set(roles.map((item) => item.id));
|
||||
const uniqueRoleIds = Array.from(new Set(roleIds));
|
||||
|
||||
const invalidRoleId = uniqueRoleIds.find((roleId) => !validRoleIds.has(roleId));
|
||||
if (invalidRoleId) {
|
||||
notFound(`Role not found: ${invalidRoleId}`);
|
||||
}
|
||||
|
||||
return uniqueRoleIds;
|
||||
}
|
||||
|
||||
export const usersRouter = router({
|
||||
me: protectedProcedure.query(({ ctx }) => ({
|
||||
user: ctx.session.user,
|
||||
|
|
@ -58,6 +72,50 @@ export const usersRouter = router({
|
|||
.orderBy(asc(role.name)),
|
||||
),
|
||||
|
||||
create: permissionedProcedure("users.create")
|
||||
.input(userCreateSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const [existingUser] = await db.select({ id: user.id }).from(user).where(eq(user.email, input.email)).limit(1);
|
||||
|
||||
if (existingUser) {
|
||||
badRequest("A user with this email already exists");
|
||||
}
|
||||
|
||||
const uniqueRoleIds = await validateRoleIds(input.roleIds);
|
||||
const userId = createEntityId();
|
||||
const now = new Date();
|
||||
const passwordHash = await hashCredentialPassword(input.password);
|
||||
|
||||
await db.insert(user).values({
|
||||
id: userId,
|
||||
name: input.name,
|
||||
email: input.email,
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await db.insert(account).values({
|
||||
id: createEntityId(),
|
||||
accountId: userId,
|
||||
providerId: "credential",
|
||||
userId,
|
||||
password: passwordHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
await db.insert(userRole).values(
|
||||
uniqueRoleIds.map((roleId) => ({
|
||||
userId,
|
||||
roleId,
|
||||
})),
|
||||
);
|
||||
|
||||
const users = await getUsersWithRoles();
|
||||
return users.find((item) => item.id === userId);
|
||||
}),
|
||||
|
||||
updateRoles: permissionedProcedure("users.assignRoles")
|
||||
.input(updateUserRolesSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
|
|
@ -67,14 +125,7 @@ export const usersRouter = router({
|
|||
notFound("User not found");
|
||||
}
|
||||
|
||||
const roles = await db.select({ id: role.id }).from(role);
|
||||
const validRoleIds = new Set(roles.map((item) => item.id));
|
||||
const uniqueRoleIds = Array.from(new Set(input.roleIds));
|
||||
|
||||
const invalidRoleId = uniqueRoleIds.find((roleId) => !validRoleIds.has(roleId));
|
||||
if (invalidRoleId) {
|
||||
notFound(`Role not found: ${invalidRoleId}`);
|
||||
}
|
||||
const uniqueRoleIds = await validateRoleIds(input.roleIds);
|
||||
|
||||
await db.delete(userRole).where(eq(userRole.userId, input.userId));
|
||||
await db.insert(userRole).values(
|
||||
|
|
@ -87,4 +138,19 @@ export const usersRouter = router({
|
|||
const updated = await getUsersWithRoles();
|
||||
return updated.find((item) => item.id === input.userId);
|
||||
}),
|
||||
|
||||
delete: permissionedProcedure("users.delete").input(byIdSchema).mutation(async ({ ctx, input }) => {
|
||||
if (ctx.session.user.id === input.id) {
|
||||
badRequest("You cannot delete your own user account while signed in");
|
||||
}
|
||||
|
||||
const [existingUser] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.id)).limit(1);
|
||||
|
||||
if (!existingUser) {
|
||||
notFound("User not found");
|
||||
}
|
||||
|
||||
await db.delete(user).where(eq(user.id, input.id));
|
||||
return { id: input.id };
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
5
packages/db/src/auth-utils.ts
Normal file
5
packages/db/src/auth-utils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { hashPassword } from "better-auth/crypto";
|
||||
|
||||
export async function hashCredentialPassword(password: string) {
|
||||
return hashPassword(password);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
INSERT INTO "permission" ("id", "name", "description")
|
||||
VALUES
|
||||
('users.create', 'users.create', 'Allows users create'),
|
||||
('users.delete', 'users.delete', 'Allows users delete')
|
||||
ON CONFLICT ("id") DO UPDATE SET
|
||||
"name" = EXCLUDED."name",
|
||||
"description" = EXCLUDED."description",
|
||||
"updated_at" = now();
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "role_permission" ("role_id", "permission_id")
|
||||
SELECT 'role_admin', "permission"."id"
|
||||
FROM "permission"
|
||||
WHERE "permission"."id" IN ('users.create', 'users.delete')
|
||||
AND EXISTS (SELECT 1 FROM "role" WHERE "role"."id" = 'role_admin')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -15,6 +15,13 @@
|
|||
"when": 1782694800000,
|
||||
"tag": "0001_add_rbac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1782698400000,
|
||||
"tag": "0002_user_lifecycle_permissions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const permissions = [
|
|||
"notes.update",
|
||||
"notes.delete",
|
||||
"users.read",
|
||||
"users.create",
|
||||
"users.delete",
|
||||
"users.assignRoles",
|
||||
] as const;
|
||||
|
||||
|
|
@ -34,7 +36,9 @@ export const roleDefinitions = [
|
|||
id: "role_editor",
|
||||
name: "Editor",
|
||||
description: "Can create and update monitoring resources, run checks, and read users.",
|
||||
permissions: permissions.filter((permission) => permission !== "users.assignRoles"),
|
||||
permissions: permissions.filter(
|
||||
(permission) => !["users.create", "users.delete", "users.assignRoles"].includes(permission),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "role_viewer",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import dotenv from "dotenv";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { roleDefinitions, permissions } from "./rbac";
|
||||
import { hashCredentialPassword } from "./auth-utils";
|
||||
|
||||
dotenv.config({
|
||||
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
|
||||
|
|
@ -196,7 +196,7 @@ async function seedAuthAdmin() {
|
|||
return existingUser.id;
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(adminUser.password);
|
||||
const passwordHash = await hashCredentialPassword(adminUser.password);
|
||||
|
||||
await db.insert(user).values({
|
||||
id: adminUser.id,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue