feat: add user lifecycle management

This commit is contained in:
Syahdan 2026-06-29 21:22:33 +07:00
parent 8ab9a9a497
commit 11b16ac3cf
8 changed files with 331 additions and 47 deletions

View file

@ -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 <span className="text-muted-foreground">No roles</span>;
@ -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 <QueryStateCard title="Loading users" description="Fetching users and their current role assignments." />;
@ -57,8 +82,12 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
return (
<ResourceListCard
title="User role assignments"
description="Grant one or more roles to each user. Changes are saved immediately."
title="Users"
description={
allowedPermissions.has("users.assignRoles")
? "Grant one or more roles to each user. Changes are saved immediately."
: "Review application users and their current roles."
}
columns={[
{
key: "user",
@ -75,6 +104,8 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
header: "Current roles",
render: (item: (typeof users.data)[number]) => <RoleBadges roles={item.roles} />,
},
...(allowedPermissions.has("users.assignRoles")
? [
{
key: "assign",
header: "Assign roles",
@ -104,6 +135,24 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
);
},
},
]
: []),
...(allowedPermissions.has("users.delete")
? [
{
key: "actions",
header: "Actions",
className: "w-20",
render: (item: (typeof users.data)[number]) => (
<ConfirmDeleteIconButton
itemLabel={`user ${item.email}`}
disabled={removeUser.isPending}
onConfirm={() => 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 (
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit();
}}
>
<FormCard
title="Create user"
description="Add a password user and assign an initial role. The account can sign in immediately."
footer={
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
{({ canSubmit, isSubmitting }) => (
<Button type="submit" disabled={!canSubmit} loading={isSubmitting}>
Create user
</Button>
)}
</form.Subscribe>
}
>
<div className="grid gap-4 md:grid-cols-3">
<form.Field name="name">
{(field) => (
<TextField
label="Name"
name={field.name}
autoComplete="name"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<form.Field name="email">
{(field) => (
<TextField
label="Email"
name={field.name}
type="email"
autoComplete="email"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<TextField
label="Password"
name={field.name}
type="password"
autoComplete="new-password"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
errors={field.state.meta.errors}
/>
)}
</form.Field>
</div>
<form.Field name="roleIds">
{(field) => (
<div className="grid gap-2">
<p className="text-sm font-medium">Initial roles</p>
<div className="grid gap-2 sm:grid-cols-3">
{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 (
<label key={role.id} className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
disabled={checked && field.state.value.length === 1}
onCheckedChange={() => field.handleChange(nextRoleIds)}
/>
<span>{role.name}</span>
</label>
);
})}
</div>
{field.state.meta.errors.length ? (
<p className="text-xs text-destructive" role="alert">
{field.state.meta.errors.map((error) => (typeof error === "string" ? error : error?.message)).filter(Boolean).join(", ")}
</p>
) : null}
</div>
)}
</form.Field>
</FormCard>
</form>
);
}
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 <QueryStateCard title="Loading roles" description="Fetching available Admin, Editor, and Viewer roles." />;
}
if (roles.isError || !roles.data) {
if (roles.isError || !roles.data || me.isError || !me.data) {
return <QueryStateCard title="Roles unavailable" description="Role options could not be loaded right now." />;
}
const allowedPermissions = new Set(me.data.permissions);
return (
<PageSection title="Users and roles" description="Assign Admin, Editor, or Viewer access to application users.">
<div className="grid gap-4">
{allowedPermissions.has("users.create") ? <CreateUserCard roles={roles.data} /> : null}
<Card>
<CardHeader>
<CardTitle>Role guide</CardTitle>
@ -142,7 +322,7 @@ function RouteComponent() {
))}
</CardContent>
</Card>
<RoleAssignmentCard roles={roles.data} />
<RoleAssignmentCard roles={roles.data} permissions={me.data.permissions} />
</div>
</PageSection>
);

View file

@ -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"),
});

View file

@ -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 };
}),
});

View file

@ -0,0 +1,5 @@
import { hashPassword } from "better-auth/crypto";
export async function hashCredentialPassword(password: string) {
return hashPassword(password);
}

View file

@ -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;

View file

@ -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
}
]
}

View file

@ -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",

View file

@ -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,