feat: add user lifecycle management
This commit is contained in:
parent
8ab9a9a497
commit
11b16ac3cf
8 changed files with 331 additions and 47 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue