mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-11 05:59:23 -06:00
feat: add roleEdit and userEdit Zod models for validation
This commit is contained in:
50
prisma/migrations/20250226165121_migration/migration.sql
Normal file
50
prisma/migrations/20250226165121_migration/migration.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Role" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RoleAppPermission" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roleId" TEXT NOT NULL,
|
||||
"appId" TEXT NOT NULL,
|
||||
"permission" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "RoleAppPermission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RoleAppPermission_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT,
|
||||
"email" TEXT NOT NULL,
|
||||
"emailVerified" DATETIME,
|
||||
"password" TEXT NOT NULL,
|
||||
"twoFaSecret" TEXT,
|
||||
"twoFaEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"image" TEXT,
|
||||
"roleId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "email", "emailVerified", "id", "image", "name", "password", "twoFaEnabled", "twoFaSecret", "updatedAt") SELECT "createdAt", "email", "emailVerified", "id", "image", "name", "password", "twoFaEnabled", "twoFaSecret", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RoleAppPermission_roleId_appId_key" ON "RoleAppPermission"("roleId", "appId");
|
||||
@@ -42,7 +42,6 @@ export default function S3TargetsTable({ targets }: {
|
||||
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
|
||||
]}
|
||||
data={targets}
|
||||
onItemClickLink={(item) => `/project/${item.id}`}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
|
||||
54
src/app/settings/users/actions.ts
Normal file
54
src/app/settings/users/actions.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
'use server'
|
||||
|
||||
import { SuccessActionResult } from "@/shared/model/server-action-error-return.model";
|
||||
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import userService from "@/server/services/user.service";
|
||||
import { UserEditModel, userEditZodModel } from "@/shared/model/user-edit.model";
|
||||
import roleService from "@/server/services/role.service";
|
||||
import { RoleEditModel, roleEditZodModel } from "@/shared/model/role-edit.model";
|
||||
|
||||
export const saveUser = async (prevState: any, inputData: UserEditModel) =>
|
||||
saveFormAction(inputData, userEditZodModel, async (validatedData) => {
|
||||
const { email } = await getAuthUserSession(); // check admin permission
|
||||
if (validatedData.email === email) {
|
||||
throw new ServiceException('Please edit your profile in the profile settings');
|
||||
}
|
||||
if (validatedData.id) {
|
||||
if (!!validatedData.newPassword) {
|
||||
await userService.changePasswordImediately(validatedData.email, validatedData.newPassword);
|
||||
}
|
||||
await userService.updateUser({
|
||||
roleId: validatedData.roleId,
|
||||
email: validatedData.email
|
||||
});
|
||||
} else {
|
||||
if (!validatedData.newPassword || validatedData.newPassword.split(' ').join('').length === 0) {
|
||||
throw new ServiceException('The password is required');
|
||||
}
|
||||
await userService.registerUser(validatedData.email, validatedData.newPassword, validatedData.roleId);
|
||||
}
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const saveRole = async (prevState: any, inputData: RoleEditModel) =>
|
||||
saveFormAction(inputData, roleEditZodModel, async (validatedData) => {
|
||||
const { email } = await getAuthUserSession(); // check admin permission
|
||||
|
||||
await roleService.save(validatedData);
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const deleteUser = async (userId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession(); // todo check admin permission
|
||||
await userService.deleteUserById(userId);
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
|
||||
export const deleteRole = async (roleId: string) =>
|
||||
simpleAction(async () => {
|
||||
await getAuthUserSession(); // todo check admin permission
|
||||
await roleService.deleteById(roleId);
|
||||
return new SuccessActionResult();
|
||||
});
|
||||
50
src/app/settings/users/page.tsx
Normal file
50
src/app/settings/users/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use server'
|
||||
|
||||
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
|
||||
import PageTitle from "@/components/custom/page-title";
|
||||
import S3TargetEditOverlay from "./user-edit-overlay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import BreadcrumbSetter from "@/components/breadcrumbs-setter";
|
||||
import UsersTable from "./users-table";
|
||||
import userService from "@/server/services/user.service";
|
||||
import roleService from "@/server/services/role.service";
|
||||
import UserEditOverlay from "./user-edit-overlay";
|
||||
import { CircleUser, Plus, User, UserRoundCog } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
import RolesTable from "./roles-table";
|
||||
|
||||
export default async function S3TargetsPage() {
|
||||
|
||||
await getAuthUserSession(); // todo only admins
|
||||
const users = await userService.getAllUsers();
|
||||
const roles = await roleService.getAll();
|
||||
return (
|
||||
<div className="flex-1 space-y-4 pt-6">
|
||||
<PageTitle
|
||||
title={'Users & Roles'} >
|
||||
</PageTitle>
|
||||
<BreadcrumbSetter items={[
|
||||
{ name: "Settings", url: "/settings/profile" },
|
||||
{ name: "Users & Roles" },
|
||||
]} />
|
||||
<Tabs defaultValue="users" >
|
||||
<TabsList className="">
|
||||
<TabsTrigger className="px-8 gap-1.5" value="users"><CircleUser className="w-3.5 h-3.5" /> Users</TabsTrigger>
|
||||
<TabsTrigger className="px-8 gap-1.5" value="roles"><UserRoundCog className="w-3.5 h-3.5"/> Roles</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="users">
|
||||
<UsersTable users={users} roles={roles} />
|
||||
</TabsContent>
|
||||
<TabsContent value="roles">
|
||||
<RolesTable roles={roles} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/app/settings/users/role-edit-overlay.tsx
Normal file
107
src/app/settings/users/role-edit-overlay.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useFormState } from 'react-dom'
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormUtils } from "@/frontend/utils/form.utilts";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { saveRole } from "./actions"
|
||||
import { RoleExtended } from "@/shared/model/role-extended.model.ts"
|
||||
import { RoleEditModel, roleEditZodModel } from "@/shared/model/role-edit.model"
|
||||
|
||||
|
||||
export default function RoleEditOverlay({ children, role }: {
|
||||
children: React.ReactNode;
|
||||
role?: RoleExtended;
|
||||
}) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
const form = useForm<RoleEditModel>({
|
||||
resolver: zodResolver(roleEditZodModel),
|
||||
defaultValues: role
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
|
||||
payload: RoleEditModel) =>
|
||||
saveRole(state, {
|
||||
...payload,
|
||||
id: role?.id
|
||||
}), FormUtils.getInitialFormState<typeof roleEditZodModel>());
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
form.reset();
|
||||
toast.success('Role saved successfully');
|
||||
setIsOpen(false);
|
||||
}
|
||||
FormUtils.mapValidationErrorsToForm<typeof roleEditZodModel>(state, form);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (role) {
|
||||
form.reset(role);
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)}>
|
||||
{children}
|
||||
</div>
|
||||
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{role?.id ? 'Edit' : 'Create'} Role</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-2">
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
})()}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
<p className="text-red-500">{state.message}</p>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form >
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
58
src/app/settings/users/roles-table.tsx
Normal file
58
src/app/settings/users/roles-table.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditIcon, Plus, TrashIcon } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import React from "react";
|
||||
import { SimpleDataTable } from "@/components/custom/simple-data-table";
|
||||
import { formatDateTime } from "@/frontend/utils/format.utils";
|
||||
import { deleteRole } from "./actions";
|
||||
import { RoleExtended } from "@/shared/model/role-extended.model.ts";
|
||||
import RoleEditOverlay from "./role-edit-overlay";
|
||||
|
||||
export default function RolesTable({ roles }: {
|
||||
roles: RoleExtended[];
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
|
||||
const asyncDeleteItem = async (id: string) => {
|
||||
const confirm = await openDialog({
|
||||
title: "Delete Role",
|
||||
description: "Do you really want to delete this role? Users with this role will be assigned to no role afterwards. They will not be able to use QuickStack until you reassign a new role to them.",
|
||||
okButton: "Delete",
|
||||
});
|
||||
if (confirm) {
|
||||
await Toast.fromAction(() => deleteRole(id), 'Deleting Role...', 'Role deleted successfully');
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<SimpleDataTable columns={[
|
||||
['id', 'ID', false],
|
||||
['name', 'Name', true],
|
||||
['roleReadPermissions', 'Read Permissions', true],
|
||||
['roleWritePermissions', 'Write Permissions', true],
|
||||
["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)],
|
||||
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
|
||||
]}
|
||||
data={roles}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="flex-1"></div>
|
||||
<RoleEditOverlay role={item} >
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</RoleEditOverlay>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteItem(item.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
/>
|
||||
<RoleEditOverlay >
|
||||
<Button variant="secondary"><Plus /> Create Role</Button>
|
||||
</RoleEditOverlay>
|
||||
</>;
|
||||
}
|
||||
140
src/app/settings/users/user-edit-overlay.tsx
Normal file
140
src/app/settings/users/user-edit-overlay.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useFormState } from 'react-dom'
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormUtils } from "@/frontend/utils/form.utilts";
|
||||
import { SubmitButton } from "@/components/custom/submit-button";
|
||||
import { S3Target, User } from "@prisma/client"
|
||||
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
|
||||
import { toast } from "sonner"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { UserEditModel, userEditZodModel } from "@/shared/model/user-edit.model"
|
||||
import { UserExtended } from "@/shared/model/user-extended.model"
|
||||
import { saveUser } from "./actions"
|
||||
import SelectFormField from "@/components/custom/select-form-field"
|
||||
import { RoleExtended } from "@/shared/model/role-extended.model.ts"
|
||||
|
||||
|
||||
export default function UserEditOverlay({ children, user, roles }: {
|
||||
children: React.ReactNode;
|
||||
roles: RoleExtended[];
|
||||
user?: UserExtended;
|
||||
}) {
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
|
||||
const form = useForm<UserEditModel>({
|
||||
resolver: zodResolver(userEditZodModel),
|
||||
defaultValues: user
|
||||
});
|
||||
|
||||
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
|
||||
payload: UserEditModel) =>
|
||||
saveUser(state, {
|
||||
...payload,
|
||||
id: user?.id
|
||||
}), FormUtils.getInitialFormState<typeof userEditZodModel>());
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'success') {
|
||||
form.reset();
|
||||
toast.success('User saved successfully');
|
||||
setIsOpen(false);
|
||||
}
|
||||
FormUtils.mapValidationErrorsToForm<typeof userEditZodModel>(state, form);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.reset(user);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onClick={() => setIsOpen(true)}>
|
||||
{children}
|
||||
</div>
|
||||
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{user?.id ? 'Edit' : 'Create'} User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="px-2">
|
||||
<Form {...form}>
|
||||
<form action={(e) => form.handleSubmit((data) => {
|
||||
return formAction(data);
|
||||
})()}>
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>E-Mail</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<SelectFormField
|
||||
form={form}
|
||||
name="roleId"
|
||||
label="Role"
|
||||
formDescription={<>
|
||||
Choose a preconfigured role or create your own in the settings.
|
||||
</>}
|
||||
values={roles.map((role) =>
|
||||
[role.id, `${role.name}`])}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Password {user?.id && <>(optional)</>}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{user?.id && <>Leave empty to keep the old password.</>}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-red-500">{state.message}</p>
|
||||
<SubmitButton>Save</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
</Form >
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
60
src/app/settings/users/users-table.tsx
Normal file
60
src/app/settings/users/users-table.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EditIcon, Plus, TrashIcon } from "lucide-react";
|
||||
import { Toast } from "@/frontend/utils/toast.utils";
|
||||
import { useConfirmDialog } from "@/frontend/states/zustand.states";
|
||||
import { User } from "@prisma/client";
|
||||
import React from "react";
|
||||
import { SimpleDataTable } from "@/components/custom/simple-data-table";
|
||||
import { formatDateTime } from "@/frontend/utils/format.utils";
|
||||
import { UserExtended } from "@/shared/model/user-extended.model";
|
||||
import UserEditOverlay from "./user-edit-overlay";
|
||||
import { deleteUser } from "./actions";
|
||||
import { RoleExtended } from "@/shared/model/role-extended.model.ts";
|
||||
|
||||
export default function UsersTable({ users, roles }: {
|
||||
users: UserExtended[];
|
||||
roles: RoleExtended[];
|
||||
}) {
|
||||
|
||||
const { openConfirmDialog: openDialog } = useConfirmDialog();
|
||||
|
||||
const asyncDeleteItem = async (id: string) => {
|
||||
const confirm = await openDialog({
|
||||
title: "Delete User",
|
||||
description: "Do you really want to delete this user?",
|
||||
okButton: "Delete",
|
||||
});
|
||||
if (confirm) {
|
||||
await Toast.fromAction(() => deleteUser(id), 'Deleting User...', 'User deleted successfully');
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<SimpleDataTable columns={[
|
||||
['id', 'ID', false],
|
||||
['email', 'Mail', true],
|
||||
['role.name', 'Role', true],
|
||||
["createdAt", "Created At", true, (item) => formatDateTime(item.createdAt)],
|
||||
["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)],
|
||||
]}
|
||||
data={users}
|
||||
actionCol={(item) =>
|
||||
<>
|
||||
<div className="flex">
|
||||
<div className="flex-1"></div>
|
||||
<UserEditOverlay user={item} roles={roles}>
|
||||
<Button variant="ghost"><EditIcon /></Button>
|
||||
</UserEditOverlay>
|
||||
<Button variant="ghost" onClick={() => asyncDeleteItem(item.id)}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</>}
|
||||
/>
|
||||
<UserEditOverlay roles={roles}>
|
||||
<Button variant="secondary"><Plus /> Create User</Button>
|
||||
</UserEditOverlay>
|
||||
</>;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
SidebarMenuAction,
|
||||
useSidebar
|
||||
} from "@/components/ui/sidebar"
|
||||
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, History, Info, Plus, Server, Settings, Settings2, User } from "lucide-react"
|
||||
import { BookOpen, Boxes, ChartNoAxesCombined, ChevronDown, ChevronRight, ChevronUp, Dot, FolderClosed, History, Info, Plus, Server, Settings, Settings2, User, User2 } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { EditProjectDialog } from "./projects/edit-project-dialog"
|
||||
import { SidebarLogoutButton } from "./sidebar-logout-button"
|
||||
@@ -39,6 +39,11 @@ const settingsMenu = [
|
||||
url: "/settings/profile",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
title: "Users & Roles",
|
||||
url: "/settings/users",
|
||||
icon: User2,
|
||||
},
|
||||
{
|
||||
title: "S3 Targets",
|
||||
url: "/settings/s3-targets",
|
||||
|
||||
@@ -23,6 +23,7 @@ export class RoleService {
|
||||
}
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ export class RoleService {
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +87,19 @@ export class RoleService {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async deleteById(id: string) {
|
||||
try {
|
||||
await dataAccess.client.role.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.roles());
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roleService = new RoleService();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma, User } from "@prisma/client";
|
||||
import dataAccess from "../adapter/db.client";
|
||||
import { revalidateTag, unstable_cache } from "next/cache";
|
||||
import { Tags } from "../utils/cache-tag-generator.utils";
|
||||
@@ -6,6 +6,7 @@ import bcrypt from "bcrypt";
|
||||
import { ServiceException } from "@/shared/model/service.exception.model";
|
||||
import QRCode from "qrcode";
|
||||
import * as OTPAuth from "otpauth";
|
||||
import { UserExtended } from "@/shared/model/user-extended.model";
|
||||
|
||||
const saltRounds = 10;
|
||||
|
||||
@@ -39,6 +40,36 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async changePasswordImediately(userMail: string, newPassword: string) {
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(newPassword, saltRounds);
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: userMail
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(user: Prisma.UserUncheckedUpdateInput) {
|
||||
try {
|
||||
delete (user as any).password;
|
||||
await dataAccess.client.user.update({
|
||||
where: {
|
||||
email: user.email as string
|
||||
},
|
||||
data: user
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async maptoDtoUser(user: User) {
|
||||
return {
|
||||
email: user.email,
|
||||
@@ -69,13 +100,14 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async registerUser(email: string, password: string) {
|
||||
async registerUser(email: string, password: string, roleId: string | null) {
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(password, saltRounds);
|
||||
const user = await dataAccess.client.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword
|
||||
password: hashedPassword,
|
||||
roleId
|
||||
}
|
||||
});
|
||||
return user;
|
||||
@@ -84,8 +116,17 @@ export class UserService {
|
||||
}
|
||||
}
|
||||
|
||||
async getAllUsers() {
|
||||
return await unstable_cache(async () => await dataAccess.client.user.findMany(),
|
||||
async getAllUsers(): Promise<UserExtended[]> {
|
||||
return await unstable_cache(async () => await dataAccess.client.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
roleId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
role: true
|
||||
}
|
||||
}),
|
||||
[Tags.users()], {
|
||||
tags: [Tags.users()]
|
||||
})();
|
||||
@@ -192,6 +233,22 @@ export class UserService {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUserById(id: string) {
|
||||
try {
|
||||
const allUsers = await this.getAllUsers();
|
||||
if (allUsers.length <= 1) {
|
||||
throw new ServiceException("You cannot delete the last user");
|
||||
}
|
||||
await dataAccess.client.user.delete({
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
revalidateTag(Tags.users());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteUser, RelatedUserModel } from "./index"
|
||||
|
||||
export const AccountModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel } from "./index"
|
||||
|
||||
export const AppModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteApp, RelatedAppModel } from "./index"
|
||||
|
||||
export const AppBasicAuthModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteApp, RelatedAppModel } from "./index"
|
||||
|
||||
export const AppDomainModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteApp, RelatedAppModel } from "./index"
|
||||
|
||||
export const AppFileMountModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteApp, RelatedAppModel } from "./index"
|
||||
|
||||
export const AppPortModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteApp, RelatedAppModel, CompleteVolumeBackup, RelatedVolumeBackupModel } from "./index"
|
||||
|
||||
export const AppVolumeModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteUser, RelatedUserModel } from "./index"
|
||||
|
||||
export const AuthenticatorModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
|
||||
export const ParameterModel = z.object({
|
||||
name: z.string(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteApp, RelatedAppModel } from "./index"
|
||||
|
||||
export const ProjectModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteUser, RelatedUserModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel } from "./index"
|
||||
|
||||
export const RoleModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteRole, RelatedRoleModel, CompleteApp, RelatedAppModel } from "./index"
|
||||
|
||||
export const RoleAppPermissionModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteVolumeBackup, RelatedVolumeBackupModel } from "./index"
|
||||
|
||||
export const S3TargetModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteUser, RelatedUserModel } from "./index"
|
||||
|
||||
export const SessionModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteRole, RelatedRoleModel, CompleteAccount, RelatedAccountModel, CompleteSession, RelatedSessionModel, CompleteAuthenticator, RelatedAuthenticatorModel } from "./index"
|
||||
|
||||
export const UserModel = z.object({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
|
||||
export const VerificationTokenModel = z.object({
|
||||
identifier: z.string(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as z from "zod"
|
||||
|
||||
import * as imports from "../../../../prisma/null"
|
||||
import { CompleteAppVolume, RelatedAppVolumeModel, CompleteS3Target, RelatedS3TargetModel } from "./index"
|
||||
|
||||
export const VolumeBackupModel = z.object({
|
||||
|
||||
9
src/shared/model/role-edit.model.ts
Normal file
9
src/shared/model/role-edit.model.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { stringToNumber } from "@/shared/utils/zod.utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const roleEditZodModel = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
name: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type RoleEditModel = z.infer<typeof roleEditZodModel>;
|
||||
5
src/shared/model/role-extended.model.ts.ts
Normal file
5
src/shared/model/role-extended.model.ts.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Role, RoleAppPermission, User } from "@prisma/client";
|
||||
|
||||
export type RoleExtended = Role & {
|
||||
roleAppPermissions: RoleAppPermission[];
|
||||
}
|
||||
11
src/shared/model/user-edit.model.ts
Normal file
11
src/shared/model/user-edit.model.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { stringToNumber } from "@/shared/utils/zod.utils";
|
||||
import { z } from "zod";
|
||||
|
||||
export const userEditZodModel = z.object({
|
||||
id: z.string().trim().optional(),
|
||||
email: z.string().trim().min(1),
|
||||
newPassword: z.string().optional(),
|
||||
roleId: z.string().trim().nullable(),
|
||||
})
|
||||
|
||||
export type UserEditModel = z.infer<typeof userEditZodModel>;
|
||||
10
src/shared/model/user-extended.model.ts
Normal file
10
src/shared/model/user-extended.model.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Role, User } from "@prisma/client";
|
||||
|
||||
export type UserExtended = {
|
||||
id: string;
|
||||
role: Role | null;
|
||||
roleId: string | null;
|
||||
email: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
Reference in New Issue
Block a user