From 11b16ac3cf3bba1d87fef50d65ec9acd939d7399 Mon Sep 17 00:00:00 2001 From: Syahdan Date: Mon, 29 Jun 2026 21:22:33 +0700 Subject: [PATCH] feat: add user lifecycle management --- apps/web/src/routes/dashboard.users.tsx | 246 +++++++++++++++--- packages/api/src/routers/schemas.ts | 7 + packages/api/src/routers/users.ts | 88 ++++++- packages/db/src/auth-utils.ts | 5 + .../0002_user_lifecycle_permissions.sql | 15 ++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/rbac.ts | 6 +- packages/db/src/seed.ts | 4 +- 8 files changed, 331 insertions(+), 47 deletions(-) create mode 100644 packages/db/src/auth-utils.ts create mode 100644 packages/db/src/migrations/0002_user_lifecycle_permissions.sql diff --git a/apps/web/src/routes/dashboard.users.tsx b/apps/web/src/routes/dashboard.users.tsx index b643353..70f789c 100644 --- a/apps/web/src/routes/dashboard.users.tsx +++ b/apps/web/src/routes/dashboard.users.tsx @@ -2,11 +2,13 @@ import { Button } from "@minmon/ui/components/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@minmon/ui/components/card"; import { Checkbox } from "@minmon/ui/components/checkbox"; import { StatusBadge } from "@minmon/ui/components/status-badge"; +import { useForm } from "@tanstack/react-form"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { toast } from "sonner"; +import z from "zod"; -import { PageSection, QueryStateCard, ResourceListCard } from "@/components/dashboard-ui"; +import { ConfirmDeleteIconButton, FormCard, PageSection, QueryStateCard, ResourceListCard, TextField } from "@/components/dashboard-ui"; import { queryClient, trpc, trpcClient } from "@/utils/trpc"; export const Route = createFileRoute("/dashboard/users")({ @@ -19,6 +21,20 @@ type RoleOption = { description: string | null; }; +type UserCreateFormValues = { + name: string; + email: string; + password: string; + roleIds: string[]; +}; + +const userCreateSchema = z.object({ + name: z.string().min(2, "Name must be at least 2 characters"), + email: z.email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + roleIds: z.array(z.string()).min(1, "Select at least one role"), +}); + function RoleBadges({ roles }: { roles: { id: string; name: string }[] }) { if (!roles.length) { return No roles; @@ -35,8 +51,9 @@ function RoleBadges({ roles }: { roles: { id: string; name: string }[] }) { ); } -function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) { +function RoleAssignmentCard({ roles, permissions }: { roles: RoleOption[]; permissions: string[] }) { const users = useQuery(trpc.users.list.queryOptions()); + const allowedPermissions = new Set(permissions); const updateRoles = useMutation({ mutationFn: async ({ userId, roleIds }: { userId: string; roleIds: string[] }) => trpcClient.users.updateRoles.mutate({ userId, roleIds }), @@ -46,6 +63,14 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) { }, onError: (error) => toast.error(error.message), }); + const removeUser = useMutation({ + mutationFn: async (id: string) => trpcClient.users.delete.mutate({ id }), + onSuccess: async () => { + toast.success("User deleted"); + await queryClient.invalidateQueries(); + }, + onError: (error) => toast.error(error.message), + }); if (users.isLoading) { return ; @@ -57,8 +82,12 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) { return ( , }, - { - key: "assign", - header: "Assign roles", - render: (item: (typeof users.data)[number]) => { - const assignedRoleIds = new Set(item.roles.map((role) => role.id)); - - return ( -
- {roles.map((role) => { - const checked = assignedRoleIds.has(role.id); - const nextRoleIds = checked - ? item.roles.map((assignedRole) => assignedRole.id).filter((roleId) => roleId !== role.id) - : [...item.roles.map((assignedRole) => assignedRole.id), role.id]; + ...(allowedPermissions.has("users.assignRoles") + ? [ + { + key: "assign", + header: "Assign roles", + render: (item: (typeof users.data)[number]) => { + const assignedRoleIds = new Set(item.roles.map((role) => role.id)); return ( - +
+ {roles.map((role) => { + const checked = assignedRoleIds.has(role.id); + const nextRoleIds = checked + ? item.roles.map((assignedRole) => assignedRole.id).filter((roleId) => roleId !== role.id) + : [...item.roles.map((assignedRole) => assignedRole.id), role.id]; + + return ( + + ); + })} +
); - })} -
- ); - }, - }, + }, + }, + ] + : []), + ...(allowedPermissions.has("users.delete") + ? [ + { + key: "actions", + header: "Actions", + className: "w-20", + render: (item: (typeof users.data)[number]) => ( + removeUser.mutate(item.id)} + /> + ), + }, + ] + : []), ]} items={users.data} emptyTitle="No users found" @@ -112,20 +161,151 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) { ); } +function CreateUserCard({ roles }: { roles: RoleOption[] }) { + const createUser = useMutation({ + mutationFn: async (values: UserCreateFormValues) => trpcClient.users.create.mutate(values), + onSuccess: async () => { + toast.success("User created"); + form.reset(); + await queryClient.invalidateQueries(); + }, + onError: (error) => toast.error(error.message), + }); + + const form = useForm({ + defaultValues: { + name: "", + email: "", + password: "", + roleIds: [roles.find((role) => role.name === "Viewer")?.id ?? roles[0]?.id ?? ""].filter(Boolean), + } satisfies UserCreateFormValues, + onSubmit: async ({ value }) => { + await createUser.mutateAsync(value); + }, + validators: { + onSubmit: userCreateSchema, + }, + }); + + return ( +
{ + event.preventDefault(); + event.stopPropagation(); + form.handleSubmit(); + }} + > + ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}> + {({ canSubmit, isSubmitting }) => ( + + )} + + } + > +
+ + {(field) => ( + field.handleChange(event.target.value)} + errors={field.state.meta.errors} + /> + )} + + + {(field) => ( + field.handleChange(event.target.value)} + errors={field.state.meta.errors} + /> + )} + + + {(field) => ( + field.handleChange(event.target.value)} + errors={field.state.meta.errors} + /> + )} + +
+ + {(field) => ( +
+

Initial roles

+
+ {roles.map((role) => { + const checked = field.state.value.includes(role.id); + const nextRoleIds = checked + ? field.state.value.filter((roleId) => roleId !== role.id) + : [...field.state.value, role.id]; + + return ( + + ); + })} +
+ {field.state.meta.errors.length ? ( +

+ {field.state.meta.errors.map((error) => (typeof error === "string" ? error : error?.message)).filter(Boolean).join(", ")} +

+ ) : null} +
+ )} +
+
+
+ ); +} + function RouteComponent() { const roles = useQuery(trpc.users.roles.queryOptions()); + const me = useQuery(trpc.users.me.queryOptions()); - if (roles.isLoading) { + if (roles.isLoading || me.isLoading) { return ; } - if (roles.isError || !roles.data) { + if (roles.isError || !roles.data || me.isError || !me.data) { return ; } + const allowedPermissions = new Set(me.data.permissions); + return (
+ {allowedPermissions.has("users.create") ? : null} Role guide @@ -142,7 +322,7 @@ function RouteComponent() { ))} - +
); diff --git a/packages/api/src/routers/schemas.ts b/packages/api/src/routers/schemas.ts index 16bcaef..b69a4ec 100644 --- a/packages/api/src/routers/schemas.ts +++ b/packages/api/src/routers/schemas.ts @@ -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"), +}); diff --git a/packages/api/src/routers/users.ts b/packages/api/src/routers/users.ts index f0885f2..668f59f 100644 --- a/packages/api/src/routers/users.ts +++ b/packages/api/src/routers/users.ts @@ -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 }; + }), }); diff --git a/packages/db/src/auth-utils.ts b/packages/db/src/auth-utils.ts new file mode 100644 index 0000000..017b916 --- /dev/null +++ b/packages/db/src/auth-utils.ts @@ -0,0 +1,5 @@ +import { hashPassword } from "better-auth/crypto"; + +export async function hashCredentialPassword(password: string) { + return hashPassword(password); +} diff --git a/packages/db/src/migrations/0002_user_lifecycle_permissions.sql b/packages/db/src/migrations/0002_user_lifecycle_permissions.sql new file mode 100644 index 0000000..22d0708 --- /dev/null +++ b/packages/db/src/migrations/0002_user_lifecycle_permissions.sql @@ -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; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 30f32c4..f7e79eb 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -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 } ] } diff --git a/packages/db/src/rbac.ts b/packages/db/src/rbac.ts index f57f19f..212e869 100644 --- a/packages/db/src/rbac.ts +++ b/packages/db/src/rbac.ts @@ -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", diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index 0cc2606..e1fea45 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -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,