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,35 +104,55 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
header: "Current roles",
render: (item: (typeof users.data)[number]) => <RoleBadges roles={item.roles} />,
},
{
key: "assign",
header: "Assign roles",
render: (item: (typeof users.data)[number]) => {
const assignedRoleIds = new Set(item.roles.map((role) => role.id));
return (
<div className="grid gap-2 sm:grid-cols-3">
{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 (
<label key={role.id} className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
disabled={updateRoles.isPending || nextRoleIds.length === 0}
onCheckedChange={() => updateRoles.mutate({ userId: item.id, roleIds: nextRoleIds })}
/>
<span>{role.name}</span>
</label>
<div className="grid gap-2 sm:grid-cols-3">
{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 (
<label key={role.id} className="flex items-center gap-2 text-sm">
<Checkbox
checked={checked}
disabled={updateRoles.isPending || nextRoleIds.length === 0}
onCheckedChange={() => updateRoles.mutate({ userId: item.id, roleIds: nextRoleIds })}
/>
<span>{role.name}</span>
</label>
);
})}
</div>
);
})}
</div>
);
},
},
},
},
]
: []),
...(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>
);