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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@minmon/ui/components/card";
|
||||||
import { Checkbox } from "@minmon/ui/components/checkbox";
|
import { Checkbox } from "@minmon/ui/components/checkbox";
|
||||||
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { toast } from "sonner";
|
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";
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/users")({
|
export const Route = createFileRoute("/dashboard/users")({
|
||||||
|
|
@ -19,6 +21,20 @@ type RoleOption = {
|
||||||
description: string | null;
|
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 }[] }) {
|
function RoleBadges({ roles }: { roles: { id: string; name: string }[] }) {
|
||||||
if (!roles.length) {
|
if (!roles.length) {
|
||||||
return <span className="text-muted-foreground">No roles</span>;
|
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 users = useQuery(trpc.users.list.queryOptions());
|
||||||
|
const allowedPermissions = new Set(permissions);
|
||||||
const updateRoles = useMutation({
|
const updateRoles = useMutation({
|
||||||
mutationFn: async ({ userId, roleIds }: { userId: string; roleIds: string[] }) =>
|
mutationFn: async ({ userId, roleIds }: { userId: string; roleIds: string[] }) =>
|
||||||
trpcClient.users.updateRoles.mutate({ userId, roleIds }),
|
trpcClient.users.updateRoles.mutate({ userId, roleIds }),
|
||||||
|
|
@ -46,6 +63,14 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(error.message),
|
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) {
|
if (users.isLoading) {
|
||||||
return <QueryStateCard title="Loading users" description="Fetching users and their current role assignments." />;
|
return <QueryStateCard title="Loading users" description="Fetching users and their current role assignments." />;
|
||||||
|
|
@ -57,8 +82,12 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResourceListCard
|
<ResourceListCard
|
||||||
title="User role assignments"
|
title="Users"
|
||||||
description="Grant one or more roles to each user. Changes are saved immediately."
|
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={[
|
columns={[
|
||||||
{
|
{
|
||||||
key: "user",
|
key: "user",
|
||||||
|
|
@ -75,6 +104,8 @@ function RoleAssignmentCard({ roles }: { roles: RoleOption[] }) {
|
||||||
header: "Current roles",
|
header: "Current roles",
|
||||||
render: (item: (typeof users.data)[number]) => <RoleBadges roles={item.roles} />,
|
render: (item: (typeof users.data)[number]) => <RoleBadges roles={item.roles} />,
|
||||||
},
|
},
|
||||||
|
...(allowedPermissions.has("users.assignRoles")
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
key: "assign",
|
key: "assign",
|
||||||
header: "Assign roles",
|
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}
|
items={users.data}
|
||||||
emptyTitle="No users found"
|
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() {
|
function RouteComponent() {
|
||||||
const roles = useQuery(trpc.users.roles.queryOptions());
|
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." />;
|
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." />;
|
return <QueryStateCard title="Roles unavailable" description="Role options could not be loaded right now." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowedPermissions = new Set(me.data.permissions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageSection title="Users and roles" description="Assign Admin, Editor, or Viewer access to application users.">
|
<PageSection title="Users and roles" description="Assign Admin, Editor, or Viewer access to application users.">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
{allowedPermissions.has("users.create") ? <CreateUserCard roles={roles.data} /> : null}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Role guide</CardTitle>
|
<CardTitle>Role guide</CardTitle>
|
||||||
|
|
@ -142,7 +322,7 @@ function RouteComponent() {
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<RoleAssignmentCard roles={roles.data} />
|
<RoleAssignmentCard roles={roles.data} permissions={me.data.permissions} />
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -110,3 +110,10 @@ export const updateUserRolesSchema = z.object({
|
||||||
userId: entityIdSchema,
|
userId: entityIdSchema,
|
||||||
roleIds: z.array(entityIdSchema).min(1, "Select at least one role"),
|
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 { asc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { permissionedProcedure, protectedProcedure, router } from "../index";
|
import { permissionedProcedure, protectedProcedure, router } from "../index";
|
||||||
import { updateUserRolesSchema } from "./schemas";
|
import { byIdSchema, updateUserRolesSchema, userCreateSchema } from "./schemas";
|
||||||
import { notFound } from "./utils";
|
import { badRequest, createEntityId, notFound } from "./utils";
|
||||||
|
|
||||||
async function getUsersWithRoles() {
|
async function getUsersWithRoles() {
|
||||||
const [users, assignments] = await Promise.all([
|
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({
|
export const usersRouter = router({
|
||||||
me: protectedProcedure.query(({ ctx }) => ({
|
me: protectedProcedure.query(({ ctx }) => ({
|
||||||
user: ctx.session.user,
|
user: ctx.session.user,
|
||||||
|
|
@ -58,6 +72,50 @@ export const usersRouter = router({
|
||||||
.orderBy(asc(role.name)),
|
.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")
|
updateRoles: permissionedProcedure("users.assignRoles")
|
||||||
.input(updateUserRolesSchema)
|
.input(updateUserRolesSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
|
|
@ -67,14 +125,7 @@ export const usersRouter = router({
|
||||||
notFound("User not found");
|
notFound("User not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const roles = await db.select({ id: role.id }).from(role);
|
const uniqueRoleIds = await validateRoleIds(input.roleIds);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(userRole).where(eq(userRole.userId, input.userId));
|
await db.delete(userRole).where(eq(userRole.userId, input.userId));
|
||||||
await db.insert(userRole).values(
|
await db.insert(userRole).values(
|
||||||
|
|
@ -87,4 +138,19 @@ export const usersRouter = router({
|
||||||
const updated = await getUsersWithRoles();
|
const updated = await getUsersWithRoles();
|
||||||
return updated.find((item) => item.id === input.userId);
|
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,
|
"when": 1782694800000,
|
||||||
"tag": "0001_add_rbac",
|
"tag": "0001_add_rbac",
|
||||||
"breakpoints": true
|
"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.update",
|
||||||
"notes.delete",
|
"notes.delete",
|
||||||
"users.read",
|
"users.read",
|
||||||
|
"users.create",
|
||||||
|
"users.delete",
|
||||||
"users.assignRoles",
|
"users.assignRoles",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -34,7 +36,9 @@ export const roleDefinitions = [
|
||||||
id: "role_editor",
|
id: "role_editor",
|
||||||
name: "Editor",
|
name: "Editor",
|
||||||
description: "Can create and update monitoring resources, run checks, and read users.",
|
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",
|
id: "role_viewer",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { hashPassword } from "better-auth/crypto";
|
|
||||||
import { roleDefinitions, permissions } from "./rbac";
|
import { roleDefinitions, permissions } from "./rbac";
|
||||||
|
import { hashCredentialPassword } from "./auth-utils";
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
|
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
|
||||||
|
|
@ -196,7 +196,7 @@ async function seedAuthAdmin() {
|
||||||
return existingUser.id;
|
return existingUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(adminUser.password);
|
const passwordHash = await hashCredentialPassword(adminUser.password);
|
||||||
|
|
||||||
await db.insert(user).values({
|
await db.insert(user).values({
|
||||||
id: adminUser.id,
|
id: adminUser.id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue