Merge remote-tracking branch 'origin/main'

# Conflicts:
#	src/components/wrappers/dashboard/Organization/organization-combobox.tsx
This commit is contained in:
charles-gauthereau
2024-12-16 12:03:16 +01:00
93 changed files with 207 additions and 265 deletions
@@ -0,0 +1,30 @@
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
import {currentUser} from "@/auth/current-user";
import {LoggedInDropdown} from "@/components/wrappers/dashboard/LoggedInDropdown/LoggedInDropdown";
import {SidebarMenuButton} from "@/components/ui/sidebar";
import {ChevronUp} from "lucide-react";
export const LoggedInButton = async () => {
const user = await currentUser()
// if (!user) {
// return <SignInButton/>
// }
return (
<LoggedInDropdown>
<SidebarMenuButton>
<Avatar className="size-6">
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
{user.image ? (
<AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`}/>
) : null}
</Avatar>
<span>{user.name}</span>
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</LoggedInDropdown>
)
}
@@ -0,0 +1,50 @@
"use client"
import {PropsWithChildren} from "react";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {signOutAction} from "@/features/auth/auth.action";
import {redirect} from "next/navigation";
import {CircleUser, LogOut, ShieldHalf} from "lucide-react";
export type LoggedInDropdownProps = PropsWithChildren<{}>
export const LoggedInDropdown = (props: LoggedInDropdownProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{props.children}
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
className="w-[--radix-popper-anchor-width]"
>
<DropdownMenuItem onClick={() => {
redirect("/dashboard/profile")
}}>
<div className="flex justify-start items-center gap-2">
<CircleUser size={16}/>
<span>Account</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
redirect("/dashboard/admin")
}}>
<div className="flex justify-start items-center gap-2">
<ShieldHalf size={16}/>
<span>Administration Panel</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => {
signOutAction()
}}>
<div className="flex justify-start items-center gap-2">
<LogOut size={16}/>
<span>Log out</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,77 @@
"use client"
import {useMutation} from "@tanstack/react-query";
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {OrganizationSchema} from "@/components/wrappers/dashboard/Organization/organization.schema";
export type createOrganizationModalProps = {
children: any
}
export function CreateOrganizationModal(props: createOrganizationModalProps) {
const {children} = props;
const form = useZodForm({
schema: OrganizationSchema,
});
const mutation = useMutation({
mutationFn: async (values: OrganizationSchema) => {
}
})
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-full">
<DialogHeader>
<DialogTitle>Create a new organization</DialogTitle>
</DialogHeader>
<div className="sm:max-w-[375px] w-full">
<Form form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</Form>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<Button type="submit">Create</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,72 @@
"use client"
import {useEffect, useState} from "react";
import {ComboBox} from "@/components/wrappers/common/combobox";
import {Organization} from "@prisma/client";
import {useStore} from "@/state-management/store";
import {getCurrentOrganizationSlug, setCurrentOrganizationSlug} from "@/features/dashboard/organization-cookie";
import {useSidebar} from "@/components/ui/sidebar";
export type organizationComboBoxProps = {
organizations: Organization[]
defaultOrganization: Organization
}
export function OrganizationCombobox(props: organizationComboBoxProps) {
// const {organizationId, moveToAnotherOrganization} = useStore((state) => state);
const [organizationSlug, setOrganizationSlug] = useState<string>()
const {organizations, defaultOrganization} = props
useEffect(() => {
getCurrentOrganizationSlug().then(slug => {
if (slug == "") {
setOrganizationSlug(defaultOrganization.slug)
setCurrentOrganizationSlug(defaultOrganization.slug)
} else {
setOrganizationSlug(slug)
const organization = organizations.find(organization => organization.slug === slug)
if (!organization) {
setOrganizationSlug(defaultOrganization.slug)
setCurrentOrganizationSlug(defaultOrganization.slug)
}
}
})
}, [organizationSlug])
const values = organizations.map(organization => {
return ({
value: organization.slug,
label: organization.name,
})
})
const onValueChange = (slug: string) => {
if (organizationSlug !== slug) {
setOrganizationSlug(slug)
setCurrentOrganizationSlug(slug)
}
}
const { state, isMobile } = useSidebar();
return (
<>
{state === 'expanded' && (
<ComboBox
sideBar
values={values}
defaultValue={organizationSlug}
onValueChange={onValueChange}/>
)}
</>
)
}
@@ -0,0 +1,8 @@
import {z} from "zod";
export const OrganizationSchema = z.object({
name: z.string(),
});
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
@@ -0,0 +1,97 @@
"use client"
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {User} from "@prisma/client";
import {UploadIcon} from "lucide-react";
import {toast} from "sonner";
import {uploadImageAction} from "@/features/upload/public/upload.action";
import {useMutation} from "@tanstack/react-query";
import {prisma} from "@/prisma";
import {updateImageUserAction} from "@/components/wrappers/dashboard/Profile/Avatar/avatar.action";
import {useRouter} from "next/navigation";
import {useSession} from "next-auth/react";
export type AvatarWithUploadProps = {
user: User
}
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const user = props.user
const router = useRouter();
const { data: session, update } = useSession();
const submitImage = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.set("file", file);
const uploadImage = await uploadImageAction(formData)
const data = uploadImage?.data?.data
if (uploadImage?.serverError || !data) {
console.log(uploadImage?.serverError);
toast.error(uploadImage?.serverError);
return;
}
const updateUser = await updateImageUserAction(data.url)
const dataUser = updateUser?.data?.data
if (updateUser?.serverError || !dataUser) {
console.log(updateUser?.serverError);
toast.error(updateUser?.serverError);
return;
}
const newSession = {
...session,
user: {
...session?.user,
image: data.url
},
};
await update(newSession);
toast.success("Successfully uploaded user image!");
router.refresh()
}
})
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.includes("image")) {
toast.error("File not an image")
return;
}
submitImage.mutate(file)
};
return (
<div className="relative " >
<Avatar className="size-14 mr-3 ">
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
{user.image ? (
<AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`}/>
) : null}
</Avatar>
<div
onClick={() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
// @ts-ignore
fileInput.onchange = handleImageUpload;
fileInput.click();
}}
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full size-14"
>
<UploadIcon className="w-8 h-8 text-primary"/>
</div>
</div>
)
}
@@ -0,0 +1,24 @@
"use server"
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {prisma} from "@/prisma";
export const updateImageUserAction = userAction
.schema(z.string())
.action(async ({parsedInput, ctx}) => {
const user = await prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
image: parsedInput,
}
})
return {
data: user,
}
});
@@ -0,0 +1,33 @@
"use client"
import {useMutation} from "@tanstack/react-query";
import {signOutAction} from "@/features/auth/auth.action";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/ButtonWithConfirm/ButtonWithConfirm";
import {deleteUserAction} from "@/components/wrappers/dashboard/Profile/ButtonDeleteAccount/delete-account.action";
import {Trash2} from "lucide-react";
export type ButtonDeleteAccountProps = {
text? : string
}
export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => {
const mutation = useMutation({
mutationFn: () => deleteUserAction(""),
onSuccess: async () => {
await signOutAction();
},
})
return (
<ButtonWithConfirm
text={props.text ? props.text : ""}
onClick={() => {
mutation.mutate()
}}
variant={"destructive"}
isPending={mutation.isPending}
className="gap-2"
icon={<Trash2/>}
/>
)
}
@@ -0,0 +1,44 @@
"use server"
import {userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {EmailFormSchema} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
export const deleteUserAction = userAction
.schema(z.string())
.action(async ({parsedInput, ctx}) => {
const userId = parsedInput.length > 0 ? parsedInput : ctx.user.id;
const uuid = uuidv4()
const user = await prisma.user.update({
where: {
id: userId,
},
data: {
email: `${uuid}@portabase.com`,
name: `${uuid}`,
deleted: true
}
})
const account = await prisma.account.findFirst({
where: {
userId: userId,
}
})
if (account) {
await prisma.account.delete({
where: {
id: account.id
}
})
}
return {
data: user,
}
});
@@ -0,0 +1,125 @@
"use client";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {UserSchema, UserType} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.schema";
import {toast} from "sonner";
import {updateUserAction} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.action";
import { useSession } from "next-auth/react";
export type userFormProps = {
defaultValues?: UserType;
userId?: string;
}
export const UserForm = (props: userFormProps) => {
const isCreate = !Boolean(props.defaultValues)
const form = useZodForm({
schema: UserSchema,
defaultValues: props.defaultValues,
});
const router = useRouter();
const { data: session, update } = useSession();
const mutation = useMutation({
mutationFn: async (values: UserType) => {
console.log("values", values)
console.log(props.userId)
const updateUser = await updateUserAction({
id: props.userId ?? "-",
data: values
})
const data = updateUser?.data?.data
if (updateUser?.serverError || !data) {
console.log(updateUser?.serverError);
toast.error(updateUser?.serverError);
return;
}
console.log("email:", values.email)
const newSession = {
...session,
user: {
...session?.user,
name: values.name,
email: values.email
},
};
const updateSession = await update(newSession);
console.log(updateSession);
toast.success(`Success updating user informations`);
router.push(`/dashboard/profile`);
router.refresh()
}
})
return (
<TooltipProvider>
<Card>
<CardHeader>
<CardTitle>
Account
</CardTitle>
<CardDescription>
Your informations
</CardDescription>
</CardHeader>
<CardContent>
<Form form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder={"Your Name"} {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder={'exemple@portabase.com'} disabled {...field}
value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button>
{isCreate ? `` : `Save`}
</Button>
</Form>
</CardContent>
</Card>
</TooltipProvider>
)
}
@@ -0,0 +1,26 @@
"use server"
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {prisma} from "@/prisma";
import {UserSchema} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.schema";
export const updateUserAction = userAction
.schema(
z.object({
id: z.string(),
data: UserSchema,
}
)
)
.action(async ({parsedInput, ctx}) => {
const updatedUser = await prisma.user.update({
where: {
id: parsedInput.id,
},
data: parsedInput.data,
})
return {
data: updatedUser,
}
})
@@ -0,0 +1,9 @@
import {z} from "zod";
export const UserSchema = z.object({
name: z.string().optional(),
email: z.string().optional(),
role: z.string().optional(),
});
export type UserType = z.infer<typeof UserSchema>;
@@ -0,0 +1,31 @@
"use client"
import {ButtonWithConfirm} from "@/components/wrappers/common/button/ButtonWithConfirm/ButtonWithConfirm";
import {Trash2} from "lucide-react";
export type ButtonDeleteProjectProps = {
text? : string
}
export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
// const mutation = useMutation({
// mutationFn: () => deleteUserAction(""),
// onSuccess: async () => {
// await signOutAction();
// },
// })
return (
<ButtonWithConfirm
text={props.text ? props.text : ""}
onClick={() => {
// mutation.mutate()
console.log("ok")
}}
variant={"destructive"}
// isPending={mutation.isPending}
className="gap-2"
icon={<Trash2/>}
/>
)
}
@@ -0,0 +1,41 @@
"use client"
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@prisma/client";
export type DatabaseKpiPro = {
successRate: any,
database: Database,
totalBackups: number
}
export const DatabaseKpi = (props: DatabaseKpiPro) => {
return (
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Backups
</CardHeader>
<CardContent>{props.totalBackups}</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Success rate
</CardHeader>
<CardContent>
{props.successRate ?? "Unavailable for now."}
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Last contact
</CardHeader>
<CardContent>
{formatDateLastContact(props.database.lastContact)}
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,56 @@
"use client"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
import {backupColumns} from "@/features/backup/columns";
import {restoreColumns} from "@/features/restore/columns";
import {Backup, Database, Restoration} from "@prisma/client";
import {useEffect} from "react";
import {useRouter} from "next/navigation";
import {eventUpdate} from "@/types/events";
export type DatabaseTabsProps = {
backups: Backup[]
restorations: Restoration[]
isAlreadyRestore: boolean
database: Database
}
export const DatabaseTabs = (props: DatabaseTabsProps) => {
const router = useRouter();
useEffect(() => {
const eventSource = new EventSource('/api/events');
eventSource.addEventListener('modification', (event) => {
const data: eventUpdate = JSON.parse(event.data)
if (data.update) {
console.log("update", data.update)
router.refresh()
}
});
return () => {
eventSource.close();
};
}, []);
return (
<Tabs className="flex flex-col flex-1" defaultValue="backup">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="backup">Backup</TabsTrigger>
<TabsTrigger value="restore">Restoration</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="backup">
<DataTableWithPagination columns={backupColumns} data={props.backups}
extendedProps={props.isAlreadyRestore}/>
</TabsContent>
<TabsContent className="h-full justify-between" value="restore">
<DataTableWithPagination columns={restoreColumns} data={props.restorations}/>
</TabsContent>
</Tabs>
)
}
@@ -0,0 +1,28 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import Link from "next/link";
export type projectCardProps = {
data: any
}
export const ProjectCard = (props: projectCardProps) => {
const {data: project} = props;
return (
<Link href={`/dashboard/projects/${project.id}`}>
<Card className="flex flex-row justify-between">
<div className="">
<CardHeader>{project.name}</CardHeader>
<CardContent>
{project.databases.length} databases
</CardContent>
</div>
</Card>
</Link>
)
}
@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import Image from "next/image"
import {Database} from "@prisma/client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {ConnectionCircle} from "@/components/wrappers/connection-circle";
import {formatDateLastContact} from "@/utils/date-formatting";
export type projectDatabaseCardProps = {
data: Database,
extendedProps: any
}
export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
const {data: database, extendedProps: extendedProps} = props;
return (
<Link href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
<DatabaseCard data={database}/>
</Link>
)
}
export type databaseCardProps = {
data: Database,
}
export const DatabaseCard = (props: databaseCardProps) => {
const {data: database} = props;
return (
<Card className="flex flex-row justify-between">
<div className="flex flex-row items-center gap-2">
<Image
src="/PostgreSQL.png"
alt="Database type Icon"
width={60}
height={60}
className="object-cover ml-4"
/>
<div>
<CardHeader>Name : {database.name}</CardHeader>
<CardContent>
Last contact: {formatDateLastContact(database.lastContact)}
</CardContent>
</div>
</div>
<div className="mt-3 mr-3">
<ConnectionCircle date={database.lastContact}/>
</div>
</Card>
)
}
@@ -0,0 +1,10 @@
import {z} from "zod";
import Database from "@prisma/client"
export const ProjectSchema = z.object({
name: z.string(),
slug: z.string(),
databases: z.array(z.string()),
});
export type ProjectType = z.infer<typeof ProjectSchema>;
@@ -0,0 +1,167 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {ProjectSchema, ProjectType} from "@/components/wrappers/dashboard/Projects/ProjectsForm/ProjectForm.schema";
import {
createProjectAction,
updateProjectAction
} from "@/components/wrappers/dashboard/Projects/ProjectsForm/project-form.action";
import {useRouter} from "next/navigation";
import {Database, Organization, Projects} from "@prisma/client"
import {MultiSelect} from "@/components/wrappers/MultiSelect/MultiSelect";
import {toast} from "sonner";
export type projectFormProps = {
defaultValues?: ProjectType;
databases: Database[],
organization: Organization,
projectId?: string;
}
export const ProjectForm = (props: projectFormProps) => {
const router = useRouter();
const isCreate = !Boolean(props.defaultValues)
const formatDatabasesList = (databases: Database[]) => {
return databases.map(database => ({
value: database.id,
label: `${database.name} (${database.generatedId}) | ${database.agent.name}`,
}));
};
const formatDefaultDatabases = (databases: ProjectType['databases']): string[] => {
return databases.map(database => database.id);
};
const formattedDefaultValues = {
...props.defaultValues,
databases: !isCreate ? formatDefaultDatabases(props.defaultValues?.databases) : []
}
const form = useZodForm({
schema: ProjectSchema,
defaultValues: formattedDefaultValues,
});
const mutation = useMutation({
mutationFn: async (values: ProjectType) => {
console.log(values)
const project: Projects = isCreate ? await createProjectAction({
data: values,
organizationId: props.organization.id
}) : await updateProjectAction({
data: values,
organizationId: props.organization.id,
projectId: props.projectId
});
console.log(project)
if (project.data.success) {
toast.success(project.data.actionSuccess.message);
router.push(`/dashboard/projects/${project.data.value.id}`);
router.refresh()
} else {
toast.success(project.data.actionError.message);
}
}
})
return (
<Card>
<CardHeader>
</CardHeader>
<CardContent>
<Form form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Project 1" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="project-1" {...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase()
field.onChange(value)
}}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="databases"
render={({field}) => (
<FormItem>
<FormLabel>Databases</FormLabel>
<FormControl>
<MultiSelect
options={formatDatabasesList(props.databases)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select databases"
variant="inverted"
animation={2}
// maxCount={100}
/>
</FormControl>
<FormDescription>Select databases you want to add to this project</FormDescription>
<FormMessage/>
</FormItem>
)
}
/>
<Button>
{isCreate ? `Create Project` : `Update Project`}
</Button>
</Form>
</CardContent>
</Card>
)
}
@@ -0,0 +1,150 @@
"use server"
import {userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {ProjectSchema} from "@/components/wrappers/dashboard/Projects/ProjectsForm/ProjectForm.schema";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Projects} from "@prisma/client";
export const createProjectAction = userAction
.schema(
z.object({
data: ProjectSchema,
organizationId: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Projects>> => {
try {
const project = await prisma.project.create({
data: {
name: parsedInput.data.name,
slug: parsedInput.data.slug,
organizationId: parsedInput.organizationId,
}
})
for (const db of parsedInput.data.databases) {
await prisma.database.update({
where: {
id: db,
},
data:{
projectId: project.id,
}
})
}
return {
success: true,
value: project,
actionSuccess: {
message: "ProjectsForm has been successfully created.",
messageParams: {projectName: parsedInput.data.name},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to create ProjectsForm.",
status: 500, // Optional: Use a meaningful status code
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectName: parsedInput.data.name},
},
};
}
});
export const updateProjectAction = userAction
.schema(
z.object({
data: ProjectSchema,
organizationId: z.string(),
projectId: z.string(),
})
)
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Projects>> => {
try {
const newDatabaseList = parsedInput.data.databases
const project = await prisma.project.findFirst({
where:{
id: parsedInput.projectId,
},
include: {
databases : {}
}
})
const existingItemIds = project.databases.map((db) => db.id);
const databasesToAdd = newDatabaseList.filter(
(id) => !existingItemIds.includes(id)
);
const databasesToRemove = existingItemIds.filter(
(id) => !newDatabaseList.includes(id)
);
console.log(databasesToAdd);
console.log(databasesToRemove);
if (databasesToAdd.length > 0) {
await prisma.database.updateMany({
where: {
id: { in: databasesToAdd },
},
data: {
projectId: parsedInput.projectId,
},
});
}
if (databasesToRemove.length > 0) {
await prisma.database.updateMany({
where: {
id: { in: databasesToRemove },
},
data: {
projectId: null,
},
});
}
const updatedProject = await prisma.project.update({
where:{
id: parsedInput.projectId
},
data:{
name: parsedInput.data.name,
slug: parsedInput.data.slug,
}
})
return {
success: true,
value: updatedProject,
actionSuccess: {
message: "Project has been successfully updated.",
messageParams: {projectName: parsedInput.data.name},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to update project.",
status: 500, // Optional: Use a meaningful status code
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectName: parsedInput.data.name},
},
};
}
});
@@ -0,0 +1,54 @@
"use client"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
import {usersColumns} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/columns-users";
import {User, Settings} from "@prisma/client";
import {backupColumns} from "@/features/backup/columns";
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/AdminEmailTab/SettingsEmailTab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/AdminStorageTab/SettingsStorageTab";
import {SettingsUsersTab} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/SettingsUsersTab";
export type SettingsTabsProps = {
currentUser: User;
users: User[];
settings: Settings;
}
export const SettingsTabs = (props: SettingsTabsProps) => {
const {currentUser, users} = props;
return (
// <Tabs defaultValue="informations" >
// <TabsList className="w-full">
// <TabsTrigger className="w-full " value="informations">Info</TabsTrigger>
// <TabsTrigger className="w-full " value="users">Users</TabsTrigger>
// <TabsTrigger className="w-full " value="email">Email</TabsTrigger>
// <TabsTrigger className="w-full " value="storage">Storage</TabsTrigger>
// </TabsList>
// <TabsContent value="informations">
// <div className="flex flex-1 flex-col gap-4 py-4">
// <div className="grid auto-rows-min gap-4 md:grid-cols-3">
// <div className="aspect-video rounded-xl bg-muted/50"/>
// <div className="aspect-video rounded-xl bg-muted/50"/>
// <div className="aspect-video rounded-xl bg-muted/50"/>
// </div>
// <div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min"/>
// </div>
// </TabsContent>
// <TabsContent value="users" >
<SettingsUsersTab currentUser={currentUser} users={users}/>
// </TabsContent>
// <TabsContent value="email">
// <SettingsEmailTab settings={props.settings}/>
// </TabsContent>
// <TabsContent value="storage">
// <SettingsStorageTab settings={props.settings}/>
// </TabsContent>
// </Tabs>
)
}
@@ -0,0 +1,31 @@
import {User} from "@prisma/client";
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
import {usersColumns} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/columns-users";
import {UsersDataTable} from "@/components/wrappers/dashboard/admin/admin-user-table";
export type SettingsUsersTabProps = {
currentUser: User;
users: User[]
}
export const SettingsUsersTab = (props: SettingsUsersTabProps) => {
const {currentUser, users} = props;
return (
<div className="flex flex-col h-full py-4">
<div className="flex gap-4 h-fit justify-between">
<h1>List of organization's users</h1>
</div>
<div className="mt-5">
<DataTableWithPagination
columns={usersColumns}
data={users}
DataTable={UsersDataTable}
dataTableProps={{currentUser}}
/>
</div>
</div>
)
}
@@ -0,0 +1,102 @@
"use client"
import {ColumnDef} from "@tanstack/react-table"
import {Badge} from "@/components/ui/badge";
import {User} from "@prisma/client";
import {updateUserAction} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.action";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {Trash2} from "lucide-react";
import {deleteUserAction} from "@/components/wrappers/dashboard/Profile/ButtonDeleteAccount/delete-account.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
export const usersColumns: ColumnDef<User>[] = [
{
accessorKey: "role",
header: "Role",
cell: ({row}) => {
const router = useRouter();
const [role, setRole] = useState<string>(row.getValue("role"))
const updateMutation = useMutation({
mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}),
onSuccess: () => {
toast.success(`User updated successfully.`);
router.refresh()
},
onError: () => {
toast.error(`An error occurred while updating user information.`);
},
});
const handleUpdateRole = async () => {
const nextRole = role === "admin" ? "pending"
: role === "pending" ? "user"
: "admin";
setRole(nextRole);
await updateMutation.mutateAsync()
};
return <Badge
className="cursor-pointer"
onClick={() => handleUpdateRole()}
variant="outline">{role}</Badge>
},
},
{
accessorKey: "name",
header: "Name"
},
{
accessorKey: "email",
header: "Email"
},
{
accessorKey: "createdAt",
header: "Created At",
cell: ({row}) => {
return new Date(row.getValue("createdAt")).toLocaleString("fr-FR");
},
},
{
accessorKey: "authMethod",
header: "Method",
cell: ({row}) => {
return <Badge variant="outline">{row.getValue("authMethod")}</Badge>
},
},
{
header: "Action",
id: "actions",
cell: ({ row, table }) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: () => deleteUserAction(row.original.id),
onSuccess: async () => {
toast.success('User deleted successfully.');
router.refresh()
},
})
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
variant="outline"
text=""
icon={<Trash2 color="red" size={15} />}
onClick={async () => {
await mutation.mutateAsync();
}}
size="icon"
/>
</div>
);
},
},
]
@@ -0,0 +1,114 @@
"use client"
import {useEffect, useState} from "react";
import Link from "next/link";
import {SidebarMenu, SidebarMenuAction, SidebarMenuButton, SidebarMenuItem} from "@/components/ui/sidebar";
import {cn} from "@/lib/utils";
import {buttonVariants} from "@/components/ui/button";
import {ChartArea, Layers, Settings, ShieldHalf} from "lucide-react";
import {usePathname} from "next/navigation";
export type SidebarMenuCustomProps = {}
export const SidebarMenuCustom = (props: SidebarMenuCustomProps) => {
const BASE_URL = "/dashboard";
const pathname = usePathname();
// Menu items.
const items = [
{
title: "Projects",
url: "projects",
icon: Layers,
},
{
title: "Agents",
url: "agents",
icon: ShieldHalf,
},
{
title: "Statistics",
url: "statistics",
icon: ChartArea,
},
{
title: "Settings",
url: "settings",
icon: Settings,
},
]
useEffect(() => {
const currentUrl = pathname;
const currentItem = items.find((item) => `${BASE_URL}/${item.url}` === currentUrl);
if (currentItem) {
setActiveItem(currentItem.title);
}
}, [pathname]);
const [activeItem, setActiveItem] = useState(items[0].title);
const handleItemClick = (title: string) => {
setActiveItem(title);
console.log(title)
}
return (
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<Link
className={cn(buttonVariants({
size: "lg",
variant: activeItem === item.title ? "secondary" : 'ghost'
}), "justify-start p-0")}
href={`${BASE_URL}/${item.url}`}
onClick={() => handleItemClick(item.title)}
>
<item.icon/>
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
<SidebarMenuAction
className={`peer-data-[active=true]/menu-button:opacity-100 ${activeItem === item.title ? 'active' : ''}`}/>
</SidebarMenuItem>
))}
</SidebarMenu>
)
}
// export type SidebarMenuItemCustomProps = {
// title: string
// url: string
// icon: Element
// }
//
//
// export const SidebarMenuItemCustom = (props: SidebarMenuItemCustomProps) => {
//
// const {title, url, icon} = props;
//
// const BASE_URL = "/dashboard";
// const pathname = usePathname();
//
// const [activeItem, setActiveItem] = useState(title);
//
// return (
// <SidebarMenuItem key={title}>
// <SidebarMenuButton asChild>
// <Link
// className={cn(buttonVariants({
// size: "lg",
// variant: activeItem === title ? "secondary" : 'ghost'
// }), "justify-start p-0")}
// href={`${BASE_URL}/${url}`}
// onClick={() => handleItemClick(item.title)}
// >
// <icon/>
// <span>{title}</span>
// </Link>
// </SidebarMenuButton>
// <SidebarMenuAction
// className={`peer-data-[active=true]/menu-button:opacity-100 ${activeItem === title ? 'active' : ''}`}/>
// </SidebarMenuItem>
// )
// }
@@ -0,0 +1,69 @@
import {
Sidebar,
SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import {LoggedInButton} from "@/components/wrappers/dashboard/LoggedInButton/LoggedInButton";
import {SidebarMenuCustom} from "@/components/wrappers/dashboard/SideBar/SideBarMenu/SideBarMenu";
import {OrganizationCombobox} from "@/components/wrappers/dashboard/Organization/organization-combobox";
import {prisma} from "@/prisma";
import {requiredCurrentUser} from "@/auth/current-user";
import {SideBarLogo} from "@/components/wrappers/Dashboard/SideBar/SideBarLogo/SideBarLogo";
import {SideBarFooterCredit} from "@/components/wrappers/Dashboard/SideBar/SideBarFooterCredit/SideBarFooterCredit";
export async function AppSidebar() {
const user = await requiredCurrentUser()
const organizations = await prisma.organization.findMany({
where: {
users: {
some: {
userId: user.id
},
},
},
})
const defaultOrganization = await prisma.organization.findUnique({
where: {
slug: "default"
}
})
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SideBarLogo/>
<SidebarMenu>
<SidebarMenuItem>
<OrganizationCombobox
organizations={organizations}
defaultOrganization={defaultOrganization}
/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenuCustom/>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<LoggedInButton/>
</SidebarMenuItem>
</SidebarMenu>
<SideBarFooterCredit/>
</SidebarFooter>
</Sidebar>
)
}
@@ -0,0 +1,21 @@
import {currentUser, requiredCurrentUser} from "@/auth/current-user";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
export type UserAvatarProps = {}
export const UserAvatar = async () => {
const user = await currentUser()
return (
<div>
<Avatar className="size-6">
<AvatarFallback>{user.name?.[0]}</AvatarFallback>
{user.image ? (
<AvatarImage src={user.image} alt={`${user.name ?? "-"}'s profile picture`}/>
) : null}
</Avatar>
</div>
)
}
@@ -0,0 +1,163 @@
"use client";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {
EmailFormSchema,
EmailFormType
} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
import Link from "next/link";
import {PasswordInput} from "@/components/wrappers/auth/PaswordInput/password-input";
import {
updateEmailSettingsAction
} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
export type EmailFormProps = {
defaultValues?: EmailFormType;
}
export const EmailForm = (props: EmailFormProps) => {
const isCreate = !Boolean(props.defaultValues)
const form = useZodForm({
schema: EmailFormSchema,
defaultValues: props.defaultValues,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: EmailFormType) => {
const updateEmailSettings = await updateEmailSettingsAction({name: "system", data: values})
const data = updateEmailSettings?.data?.data
if (updateEmailSettings?.serverError || !data) {
console.log(updateEmailSettings?.serverError);
toast.error(updateEmailSettings?.serverError);
return;
}
toast.success(`Success updating email informations`);
router.refresh()
}
})
return (
<TooltipProvider>
<Card>
<CardContent>
<Form form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="smtpFrom"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>From Email *</FormLabel>
<FormControl>
<Input
placeholder={"exemple@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email from where the email will be send"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpHost"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Server Host *</FormLabel>
<FormControl>
<Input
placeholder={"ssl0.ovh.net"} {...field} />
</FormControl>
<FormDescription>{"Your email server host"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPort"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Server Port *</FormLabel>
<FormControl>
<Input
placeholder={"465"} {...field} />
</FormControl>
<FormDescription>{"Your email server port (send)"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpPassword"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Password" {...field}/>
</FormControl>
<FormDescription>{"Your email server password"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="smtpUser"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>User Email *</FormLabel>
<FormControl>
<Input
placeholder={"exemple@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email server user"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button>
Save
</Button>
</div>
</Form>
</CardContent>
</Card>
</TooltipProvider>
)
}
@@ -0,0 +1,31 @@
"use server"
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {prisma} from "@/prisma";
import {EmailFormSchema} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema";
export const updateEmailSettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: EmailFormSchema,
}
)
)
.action(async ({parsedInput, ctx}) => {
console.log("parsedInput", parsedInput.data)
const updatedSettings = await prisma.settings.update({
where: {
name: parsedInput.name,
},
data: parsedInput.data,
})
return {
data: updatedSettings,
}
})
@@ -0,0 +1,11 @@
import {z} from "zod";
export const EmailFormSchema = z.object({
smtpPassword: z.string(),
smtpFrom: z.string(),
smtpHost: z.string(),
smtpPort: z.string(),
smtpUser: z.string(),
});
export type EmailFormType = z.infer<typeof EmailFormSchema>;
@@ -0,0 +1,61 @@
import {EmailForm} from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/EmailForm";
import {Settings} from "@prisma/client";
import {Send} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
import {useMutation} from "@tanstack/react-query";
import {sendEmail} from "@/utils/email-helper";
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
import {render} from "@react-email/render";
import {toast} from "sonner";
import HelloEmail from "../../../../../../emails/HelloEmail";
export type SettingsEmailTabProps = {
settings: Settings
}
export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
const mutation = useMutation({
mutationFn: async () => {
const email = await sendEmail({
to: props.settings.smtpUser,
subject: "Portabase",
html: await render(TestEmailSettings(),{})
});
if(email.response){
toast.success("Test Email Successfully sent !");
}
}
})
const handleSendMailTest = async () => {
await mutation.mutateAsync()
}
return (
<div className="flex flex-col h-full py-4">
<div className="flex gap-4 h-fit justify-between">
<h1>Settings for Portabase email setup</h1>
{props.settings.smtpFrom && (
<ButtonWithLoading
isPending={mutation.isPending}
onClick={async () => {
await handleSendMailTest()
}}
icon={<Send/>}
text="Send email test"
size="default"
/>
)}
</div>
<div className="mt-5">
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings : null}/>
</div>
</div>
)
}
@@ -0,0 +1,99 @@
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
import {Info, Send, ShieldCheck} from "lucide-react";
import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label";
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/StorageS3Form";
import {useState} from "react";
import {Settings} from "@prisma/client";
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
import {useMutation} from "@tanstack/react-query";
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
import {toast} from "sonner";
import {updateUserAction} from "@/components/wrappers/dashboard/Profile/UserForm/user-form.action";
import {useRouter} from "next/navigation";
import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
export type SettingsStorageTabProps = {
settings: Settings
}
export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
const router = useRouter()
const mutation = useMutation({
mutationFn: async () => {
const result = await checkConnexionToS3()
if(result.error){
toast.error("An error occured during the connexion !")
}else{
toast.success("Connexion succeed!")
}
}
})
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
const updateMutation = useMutation({
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3": "local"}}),
onSuccess: () => {
toast.success(`Settings updated successfully.`);
router.refresh()
},
onError: () => {
toast.error(`An error occurred while updating settings information.`);
},
});
const HandleSwitchStorage = async () => {
setIsSwitched(!isSwitched);
await updateMutation.mutateAsync()
}
return (
<div className="flex flex-col h-full py-4">
<h1>Settings for Portabase storage</h1>
<Alert className="mt-3">
<Info className="h-4 w-4"/>
<AlertTitle>Informations</AlertTitle>
<AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local.
For exemple you cannot choose to store images in one place and .dump files in another.
</AlertDescription>
</Alert>
<div className="flex flex-col h-full py-4 ">
<div className="flex items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<Label htmlFor="storage-mode">Storage Mode (Local/s3 compatible)</Label>
<Switch
checked={isSwitched}
onCheckedChange={async () => {
await HandleSwitchStorage()
}}
id="storage-mode"/>
</div>
<div>
<ButtonWithLoading
size={"default"}
disabled={!isSwitched}
isPending={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync()
}}
icon={<ShieldCheck />}
text="Test connexion"
/>
</div>
</div>
{isSwitched && (
<div className="mt-5">
<StorageS3Form defaultValues={props.settings.s3EndPointUrl ? props.settings : null}/>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,131 @@
"use client";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {
S3FormSchema,
S3FormType
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {
updateS3SettingsAction
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
export type S3FormProps = {
defaultValues?: S3FormType;
}
export const StorageS3Form = (props: S3FormProps) => {
const form = useZodForm({
schema: S3FormSchema,
defaultValues: props.defaultValues,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: S3FormType) => {
console.log(values)
const updateS3Settings = await updateS3SettingsAction({name: "system", data: values})
const data = updateS3Settings?.data?.data
if (updateS3Settings?.serverError || !data) {
console.log(updateS3Settings?.serverError);
toast.error(updateS3Settings?.serverError);
return;
}
toast.success(`Success updating storage informations`);
router.refresh()
}
})
return (
<TooltipProvider>
<Card>
<CardContent>
<Form form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="s3EndPointUrl"
render={({field}) => (
<FormItem>
<FormLabel>Endpoint Url *</FormLabel>
<FormControl>
<Input
placeholder={"s3.eu-west-3.amazonaws.com"} {...field} />
</FormControl>
<FormDescription>{"Your s3 compatible url"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="s3AccessKeyId"
render={({field}) => (
<FormItem>
<FormLabel>Access Key *</FormLabel>
<FormControl>
<Input
placeholder={"The access key token"} {...field} />
</FormControl>
<FormDescription>{"Add your access key"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="s3SecretAccessKey"
render={({field}) => (
<FormItem>
<FormLabel>Secret Key *</FormLabel>
<FormControl>
<Input
placeholder={"The secret key token"} {...field} />
</FormControl>
<FormDescription>{"Add your secret key"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="S3BucketName"
render={({field}) => (
<FormItem>
<FormLabel>Bucket name *</FormLabel>
<FormControl>
<Input
placeholder={"my-bucket"} {...field} />
</FormControl>
<FormDescription>{"The bucket name where you want to store your data"}</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button>
Save
</Button>
</div>
</Form>
</CardContent>
</Card>
</TooltipProvider>
)
}
@@ -0,0 +1,51 @@
"use server"
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {prisma} from "@/prisma";
import {
S3FormSchema,
StorageSwitchSchema
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema";
export const updateS3SettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: S3FormSchema,
}
)
)
.action(async ({parsedInput, ctx}) => {
const updatedSettings = await prisma.settings.update({
where: {
name: parsedInput.name,
},
data: parsedInput.data,
})
return {
data: updatedSettings,
}
})
export const updateStorageSettingsAction = userAction
.schema(
z.object({
name: z.string(),
data: StorageSwitchSchema,
}
)
)
.action(async ({parsedInput, ctx}) => {
const updatedSettings = await prisma.settings.update({
where: {
name: parsedInput.name,
},
data: parsedInput.data,
})
return {
data: updatedSettings,
}
})
@@ -0,0 +1,17 @@
import {z} from "zod";
export const S3FormSchema = z.object({
s3EndPointUrl: z.string(),
s3AccessKeyId: z.string(),
s3SecretAccessKey: z.string(),
S3BucketName: z.string(),
});
export type S3FormType = z.infer<typeof S3FormSchema>;
export const StorageSwitchSchema = z.object({
storage: z.string(),
})
export type StorageType= z.infer<typeof StorageSwitchSchema>;
@@ -0,0 +1,42 @@
"use client"
import {User, Settings} from "@prisma/client";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/AdminEmailTab/SettingsEmailTab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/AdminStorageTab/SettingsStorageTab";
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-table";
export type AdminTabsProps = {
currentUser: User;
users: User[];
settings: Settings;
}
export const AdminTabs = (props: AdminTabsProps) => {
const {currentUser, users, settings} = props;
return (
<Tabs defaultValue="users">
<TabsList className="w-full">
<TabsTrigger className="w-full " value="users">Users</TabsTrigger>
<TabsTrigger className="w-full " value="email">Email</TabsTrigger>
<TabsTrigger className="w-full " value="storage">Storage</TabsTrigger>
</TabsList>
<TabsContent value="users">
<AdminUsersTable currentUser={currentUser} users={users}/>
</TabsContent>
<TabsContent value="email">
<SettingsEmailTab settings={settings}/>
</TabsContent>
<TabsContent value="storage">
<SettingsStorageTab settings={settings}/>
</TabsContent>
</Tabs>
)
}
@@ -0,0 +1,92 @@
import {flexRender, Row, RowData} from "@tanstack/react-table";
import {User} from "@prisma/client";
import {DataTableWithPagination} from "@/components/wrappers/table/data-table-with-pagination";
import {usersColumns} from "@/components/wrappers/dashboard/Settings/SettingsUsersTab/columns-users";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {cn} from "@/lib/utils";
export type AdminUsersTableProps = {
currentUser: User;
users: User[]
}
export const AdminUsersTable = (props: AdminUsersTableProps) => {
const {currentUser, users} = props;
return (
<div className="flex flex-col h-full py-4">
<div className="flex gap-4 h-fit justify-between">
<h1>List of Portabase's users</h1>
</div>
<div className="mt-5">
<DataTableWithPagination
columns={usersColumns}
data={users}
DataTable={UsersDataTable}
dataTableProps={{currentUser}}
/>
</div>
</div>
)
}
export type usersDataTableProps = {
currentUser: User;
table: any,
}
export const UsersDataTable = ({currentUser, table}: usersDataTableProps) => {
return (
<div className="rounded-md border w-full ">
<Table className="w-full">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row: Row<User>) => (
<TableRow
className={cn(row.original.id === currentUser.id ? "opacity-40 pointer-events-none" : "")}
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,34 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import Link from "next/link";
import {ValueIcon} from "@radix-ui/react-icons";
import {Circle} from "lucide-react";
import {ConnectionCircle} from "@/components/wrappers/connection-circle";
import {formatDateLastContact} from "@/utils/date-formatting";
export type agentCardProps = {
data: any
}
export const AgentCard = (props: agentCardProps) => {
const {data: agent} = props;
return (
<Link href={`/dashboard/agents/${agent.id}`}>
<Card className="flex flex-row justify-between">
<div className="">
<CardHeader>{agent.name}</CardHeader>
<CardContent>
Last contact : {formatDateLastContact(agent.lastContact)}
</CardContent>
</div>
<div className="mt-3 mr-3">
<ConnectionCircle date={agent.lastContact}/>
</div>
</Card>
</Link>
)
}
@@ -0,0 +1,130 @@
"use client";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.action";
export type agentFormProps = {
defaultValues?: AgentType;
agentId?: string;
}
export const AgentForm = (props: agentFormProps) => {
const isCreate = !Boolean(props.defaultValues)
// const defaultValues = isCreate ? {slug: ""} : props.defaultValues
const form = useZodForm({
schema: AgentSchema,
defaultValues: props.defaultValues,
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: AgentType) => {
console.log("values", values)
const createAgent = isCreate ? await createAgentAction(values) : await updateAgentAction({
id: props.agentId ?? "-",
data: values
});
const data = createAgent?.data?.data
if (createAgent?.serverError || !data) {
console.log(createAgent?.serverError);
toast.error(createAgent?.serverError);
return;
}
toast.success(`Success`);
router.push(`/dashboard/agents/${data.id}`);
router.refresh()
}
})
return (
<TooltipProvider>
<Card>
<CardContent>
<Form form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Agent 1" {...field} />
</FormControl>
<FormDescription>Your agent project name</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue=""
name="slug"
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
value={field.value ?? ""}
placeholder="agent-1" {...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase()
field.onChange(value)
}}
/>
</FormControl>
<FormDescription>The slug is used in the url of the agent</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
defaultValue=""
name="description"
render={({field}) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder='This agent is for the client exemple.com' {...field}
value={field.value ?? ""}/>
</FormControl>
<FormDescription>Enter your project agent description</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<Button>
{isCreate ? `Create agent` : `Save agent`}
</Button>
</Form>
</CardContent>
</Card>
</TooltipProvider>
)
}
@@ -0,0 +1,68 @@
"use server"
import {ActionError, userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {AgentSchema} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
import {z} from "zod";
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
const slugExists = await prisma.agent.count({
where: {
slug: slug,
id: agentId ? {
not: agentId
} : undefined,
},
})
console.log(slugExists)
if (slugExists) {
throw new ActionError("Slug already exists");
}
}
export const createAgentAction = userAction
.schema(AgentSchema)
.action(async ({parsedInput, ctx}) => {
// Verify if slug already exist
await verifySlugUniqueness(parsedInput.slug);
const agent = await prisma.agent.create({
data: {
...parsedInput
}
})
// await sendEmailIfUserCreatedFirstForm(ctx.user)
return {
data: agent,
}
});
export const updateAgentAction = userAction
.schema(
z.object({
id: z.string(),
data: AgentSchema,
}
)
)
.action(async ({parsedInput, ctx}) => {
await verifySlugUniqueness(parsedInput.data.slug, parsedInput.id);
console.log("parsedInput", parsedInput.data)
const updatedAgent = await prisma.agent.update({
where: {
id: parsedInput.id,
},
data: parsedInput.data,
})
return {
data: updatedAgent,
}
})
@@ -0,0 +1,9 @@
import {z} from "zod";
export const AgentSchema = z.object({
name: z.string(),
slug: z.string().regex(/^[a-zA-Z0-9_-]*$/).min(5).max(25),
description: z.string().optional().nullable(),
});
export type AgentType = z.infer<typeof AgentSchema>;
@@ -0,0 +1,55 @@
"use client"
import {Button} from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {CodeSnippet} from "@/components/wrappers/CodeSnippet/CodeSnippet";
import {generateEdgeKey} from "@/utils/edge_key";
import {Copy} from "lucide-react";
import {PropsWithChildren, useState} from "react";
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
import {Agent} from "@prisma/client";
import {getServerUrl} from "@/utils/get-server-url";
export type agentRegistrationDialogProps = PropsWithChildren<{
agent: Agent
}>
export function AgentModalKey(props: agentRegistrationDialogProps) {
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
const code = `EDGE_KEY = ${edge_key}`;
return (
<Dialog>
<DialogTrigger asChild>
{props.children}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px] w-full">
<DialogHeader>
<DialogTitle>Agent Edge Key</DialogTitle>
</DialogHeader>
<div className="sm:max-w-[375px] w-full">
<CodeSnippet
code={code}
// className="w-full overflow-x-auto break-words"
/>
</div>
<DialogFooter>
<div className="flex items-center justify-between w-full">
<CopyButton value={code}/>
<Button type="submit">Save changes</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,43 @@
"use server"
import {z} from "zod";
import {userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {ServerActionResult} from "@/types/action-type";
import {Backup} from "@prisma/client";
export const backupButtonAction = userAction
.schema(z.string())
.action(async ({ parsedInput, ctx }): Promise<ServerActionResult<Backup>> => {
try {
const backup = await prisma.backup.create({
data: {
databaseId: parsedInput,
status: "waiting",
},
});
return {
success: true,
value: backup,
actionSuccess: {
message: "Backup has been successfully created.",
messageParams: { databaseId: parsedInput },
},
};
} catch (error) {
console.error("Error creating backup:", error);
return {
success: false,
actionError: {
message: "Failed to create backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { databaseId: parsedInput },
},
};
}
});
@@ -0,0 +1,45 @@
"use client"
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/ButtonWithLoading/ButtonWithLoading";
export type BackupButtonProps = {
databaseId: string
disable: boolean
}
export const BackupButton = (props: BackupButtonProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: async (databaseId: string) => {
const backup = await backupButtonAction(databaseId)
console.log(backup)
if (backup.data.success) {
toast.success(backup.data.actionSuccess?.message || "Backup created successfully!");
router.refresh()
} else {
toast.error(backup.serverError || "Failed to create backup.");
}
}
})
const HandleAction = async () => {
await mutation.mutateAsync(props.databaseId)
}
return (
<ButtonWithLoading
disabled={props.disable}
text="Backup"
isPending={mutation.isPending}
size={"default"}
onClick={async () => {
await HandleAction()
}}
/>
)
}
@@ -0,0 +1,94 @@
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import {isValidCronPart} from "@/utils/cron";
export const AdvancedCronSelect = ({
id,
label,
options,
type,
value,
defaultValue,
onValueChange,
}: {
id: string;
label: string;
options: string[];
type: string;
value: string;
defaultValue: string;
onValueChange: (value: string) => void;
}) => {
const [isAdvanced, setIsAdvanced] = useState(false);
const [customValue, setCustomValue] = useState(defaultValue || value);
const [error, setError] = useState<string | null>(null);
const handleBlur = () => {
if (customValue.trim() === "") {
setIsAdvanced(false);
} else if (!isValidCronPart(type, customValue)) {
setError("Invalid cron part value.");
} else {
setError(null);
onValueChange(customValue);
}
};
return (
<div className="grid grid-cols-2 items-center gap-2">
<Label htmlFor={id} className="text-left">{label}</Label>
{!isAdvanced ? (
<Select
id={id}
className="col-span-4"
value={defaultValue}
onValueChange={(value: string) => {
if (value === "advanced") {
setIsAdvanced(true);
} else {
setCustomValue(value);
onValueChange(value);
}
}}
>
<SelectTrigger>
<SelectValue>
{value}
{/*{options.includes(value) ? value : "Custom value"}*/}
</SelectValue>
{/*<SelectValue placeholder="Select value" />*/}
</SelectTrigger>
<SelectContent>
{options.map((opt: string) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
<SelectItem value="advanced">Advanced</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={id}
className="col-span-4"
type="text"
value={customValue}
onChange={(e) => {
const newValue = e.target.value;
setCustomValue(newValue);
if (isValidCronPart(type, newValue)) {
setError(null);
} else {
setError("Invalid cron part value.");
}
}}
onBlur={handleBlur}
placeholder="e.g., *, 1-5, */5"
/>
)}
{error && <p className="text-sm text-red-500 col-span-4">{error}</p>}
</div>
);
};
@@ -0,0 +1,95 @@
"use client"
import {Clock9} from "lucide-react";
import {Button} from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription, DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger
} from "@/components/ui/dialog";
import {CronInput} from "@/components/wrappers/dashboard/database/CronButton/CronInput";
import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label"
import {Separator} from "@/components/ui/separator";
import {useState} from "react";
import {useMutation} from "@tanstack/react-query";
import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {Database} from "@prisma/client";
import {
updateBackupPolicyAction,
updateDatabaseBackupPolicyAction
} from "@/components/wrappers/dashboard/database/CronButton/cron.action";
export type CronButtonProps = {
database: Database
}
export const CronButton = (props: CronButtonProps) => {
const router = useRouter();
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
const updateDatabaseBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: props.database.id, backupPolicy:value}),
onSuccess: () => {
toast.success(`Method updated successfully.`);
router.refresh()
},
onError: () => {
toast.error(`An error occurred while updating backup method.`);
},
});
const handleTypeChange = async (state: boolean) => {
setIsSwitched(state);
if(state == false) {
await updateDatabaseBackupPolicy.mutateAsync("")
}
}
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
{...props}
>
<Clock9/>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Backup method</DialogTitle>
<DialogDescription>
Your settings for the backup method
</DialogDescription>
</DialogHeader>
<Separator/>
<h1>
Select your backup method
</h1>
<div className="flex items-center space-x-2">
<Label>Manual / Automatic </Label>
<Switch
checked={isSwitched}
onCheckedChange={async () => {
await handleTypeChange(!isSwitched)
}}
id="type-mode"/>
</div>
{isSwitched ?
<CronInput database={props.database}/>
:null}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,123 @@
import { AdvancedCronSelect } from "./AdvancedCronSelect";
import {
updateBackupPolicyAction,
updateDatabaseBackupPolicyAction
} from "@/components/wrappers/dashboard/database/CronButton/cron.action";
import {useMutation} from "@tanstack/react-query";
import {useState} from "react";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {Database} from "@prisma/client";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
export type CronInputProps = {
database : Database
}
export const CronInput = ({ database }: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const router = useRouter();
const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({ databaseId: database.id, backupPolicy: value }),
onSuccess: () => {
toast.success(`Cron updated successfully.`);
router.refresh();
},
onError: () => {
toast.error(`An error occurred while updating cron value.`);
},
});
const handleChangeCron = (type: string, value: string) => {
const cronParts = cron.split(" ");
const indexMap = { minute: 0, hour: 1, "day-of-month": 2, month: 3, "day-of-week": 4 };
cronParts[indexMap[type]] = value;
setCron(cronParts.join(" "));
};
const handleUpdateCron = async (cron: string) => {
await updateBackupPolicy.mutateAsync(cron);
};
return (
<>
<h1>Configure your cron schedule</h1>
<AdvancedCronSelect
id="minute"
label="Minute"
options={Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))}
type="minute"
value={cron.split(" ")[0]}
defaultValue={cron.split(" ")[0]}
onValueChange={(value) => handleChangeCron("minute", value)}
/>
<AdvancedCronSelect
id="hour"
label="Hour"
options={Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))}
type="hour"
value={cron.split(" ")[1]}
defaultValue={cron.split(" ")[1]}
onValueChange={(value) => handleChangeCron("hour", value)}
/>
<AdvancedCronSelect
id="day-of-month"
label="Day of Month"
options={Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0"))}
type="day-of-month"
value={cron.split(" ")[2]}
defaultValue={cron.split(" ")[2]}
onValueChange={(value) => handleChangeCron("day-of-month", value)}
/>
<AdvancedCronSelect
id="month"
label="Month"
options={["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]}
type="month"
value={cron.split(" ")[3]}
defaultValue={cron.split(" ")[3]}
onValueChange={(value) => handleChangeCron("month", value)}
/>
<AdvancedCronSelect
id="day-of-week"
label="Day of Week"
options={["0", "1", "2", "3", "4", "5", "6"]}
type="day-of-week"
value={cron.split(" ")[4]}
defaultValue={cron.split(" ")[4]}
onValueChange={(value) => handleChangeCron("day-of-week", value)}
/>
<Separator />
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div className="font-semibold">Cron Expression</div>
<div className="font-mono text-muted-foreground">{cron}</div>
</div>
<div className="text-sm text-muted-foreground">
This cron expression determines when the job will run.
</div>
</div>
<div className="flex justify-between gap-2">
<Button
onClick={async () => {
setCron("* * * * *");
await handleUpdateCron("* * * * *");
}}
variant="destructive"
>
Reset
</Button>
<Button
onClick={async () => {
await handleUpdateCron(cron);
}}
>
Save cron
</Button>
</div>
</>
);
};
@@ -0,0 +1,31 @@
"use server"
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {prisma} from "@/prisma";
export const updateDatabaseBackupPolicyAction = userAction
.schema(
z.object({
databaseId: z.string(),
backupPolicy: z.string(),
}
)
)
.action(async ({parsedInput, ctx}) => {
const cronPolicy = parsedInput.backupPolicy == "" ? null : parsedInput.backupPolicy
const updatedDatabase = await prisma.database.update({
where: {
id: parsedInput.databaseId,
},
data: {
backupPolicy: cronPolicy,
}
})
return {
data: updatedDatabase,
}
})
@@ -0,0 +1,119 @@
"use client";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"
import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import {DatabaseSchema, DatabaseType} from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema";
import {updateDatabaseAction} from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.action";
import {toast} from "sonner";
export type DatabaseFormProps = {
defaultValues?: DatabaseType;
databaseId?: string;
}
export const DatabaseForm = (props: DatabaseFormProps) => {
const {defaultValues, databaseId} = props;
const isCreate = !Boolean(defaultValues)
const form = useZodForm({
schema: DatabaseSchema,
defaultValues: {...defaultValues},
});
const router = useRouter();
const mutation = useMutation({
mutationFn: async (values: DatabaseType) => {
const database = await updateDatabaseAction({id: databaseId, data: values});
if (database.serverError) {
console.error(database?.serverError);
toast.error(database?.serverError);
return;
}
console.log(database)
toast.success(`Database settings successfully updated!`);
router.back()
}
})
return (
<TooltipProvider>
<Card>
<CardContent>
<Form form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
console.log("sssssss")
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input disabled placeholder="Database 1" {...field}/>
</FormControl>
<FormDescription>Your database project name setup in agent</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dbms"
render={({field}) => (
<FormItem>
<FormLabel>Database type</FormLabel>
<FormControl>
<Input disabled placeholder="PostgreSQL" {...field}/>
</FormControl>
<FormDescription>Your database project name setup in agent</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({field}) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input placeholder="Prod database for project 1" {...field}/>
</FormControl>
<FormDescription>Add a short description about this database</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<Button>
{isCreate ? `Create database` : `Save database`}
</Button>
</Form>
</CardContent>
</Card>
</TooltipProvider>
)
}
@@ -0,0 +1,25 @@
"use server"
import {z} from "zod";
import {userAction} from "@/safe-actions";
import {prisma} from "@/prisma";
import {DatabaseSchema} from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema";
export const updateDatabaseAction = userAction
.schema(
z.object({
id: z.string(),
data: DatabaseSchema,
}
)
)
.action(async ({parsedInput, ctx}) => {
return prisma.database.update({
where: {
id: parsedInput.id,
},
data: parsedInput.data,
});
})
@@ -0,0 +1,12 @@
import {z} from "zod";
const cronRegex = /^(\d{1,2}|\*|(\d{1,2}-\d{1,2})|(\d{1,2}\/\d{1,2}))\s+(\d{1,2}|\*|(\d{1,2}-\d{1,2})|(\d{1,2}\/\d{1,2}))\s+(\d{1,2}|\*|(\d{1,2}-\d{1,2})|(\d{1,2}\/\d{1,2}))\s+(\d{1,7}|\*|(\d{1,7}-\d{1,7})|(\d{1,7}\/\d{1,7}))$/;
export const DatabaseSchema = z.object({
name: z.string().readonly(),
description: z.string().optional(),
dbms: z.string().readonly(),
});
export type DatabaseType = z.infer<typeof DatabaseSchema>;
@@ -0,0 +1,20 @@
"use client"
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import {usePathname} from "next/navigation";
export type EditButtonProps = {}
export const EditButton = (props: EditButtonProps) => {
const pathname = usePathname();
return(
<Link
className={buttonVariants({ variant: "outline" })}
href={`${pathname}/edit`}
>
<GearIcon className="w-7 h-7" />
</Link>
)
}
@@ -0,0 +1,207 @@
"use client";
import {useState} from "react";
import {DateTimePicker} from "@/components/wrappers/daytime-picker";
import {Button} from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {RestoreSchema} from "@/components/wrappers/dashboard/database/restore-form.schema";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {useMutation} from "@tanstack/react-query";
import {ComboBox, ComboBoxFormItem} from "@/components/wrappers/common/combobox";
import {Label} from "@/components/ui/label";
export type restoreFormProps = {
databaseToRestore: any
databases: any[]
backups: any[]
}
export const RestoreForm = (props: restoreFormProps) => {
const {databaseToRestore, databases, backups} = props
console.log("bacups", backups)
console.log("bacups", databaseToRestore)
const backupLocations = [
{
value: "remote-file",
label: "Remote File",
},
{
value: "desktop-file",
label: "Desktop File",
},
]
const executionModes = [
{
value: "immediate",
label: "Immediate",
},
{
value: "scheduled",
label: "Scheduled",
},
]
const [selectedDatabaseId, setSelectedDatabaseId] = useState(databaseToRestore.id)
const filteredBackups = backups.filter(backup => backup.databaseId == selectedDatabaseId)
const defaultValues = {
executionMode: "immediate",
backupLocation: "remote-file",
}
const form = useZodForm({
schema: RestoreSchema,
defaultValues: defaultValues
});
const values = form.getValues()
console.log("values", values)
const mutation = useMutation({})
return (
<div>
<Form
form={form}
onSubmit={async (values) => {
console.log(values);
}}
>
<FormField
control={form.control}
name="backupLocation"
render={({field}) => (
<FormItem>
<FormLabel>Backup location</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue/>
</SelectTrigger>
</FormControl>
<SelectContent>
{backupLocations.map((backupLocation, key) =>
<SelectItem key={key} value={backupLocation.value}>
{backupLocation.label}
</SelectItem>
)}
</SelectContent>
</Select>
<FormMessage/>
</FormItem>
)}
/>
{values.backupLocation == "remote-file" ?
<>
<div className="flex flex-col">
<Label>Database</Label>
<ComboBox
values={databases.map(database =>
({"value": database.id, "label": database.name})
)}
onValueChange={setSelectedDatabaseId}
defaultValue={selectedDatabaseId}
searchField
/>
</div>
<FormField
control={form.control}
name="remoteBackup"
render={({field}) => (
<FormItem className="flex flex-col">
<FormLabel>Remote backup</FormLabel>
<ComboBoxFormItem
values={filteredBackups.map(backup => ({
"value": backup.id,
"label": backup.createdAt.toString()
})
)}
{...field}
/>
<FormMessage/>
</FormItem>
)}
/>
</>
: null}
{values.backupLocation == "desktop-file" ?
<FormField
control={form.control}
name="uploadedBackupFile"
render={({field}) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Input id="uploadedBackupFile" type="file" {...field}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/> : null}
<FormField
control={form.control}
name="executionMode"
render={({field}) => (
<FormItem>
<FormLabel>Execution mode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue/>
</SelectTrigger>
</FormControl>
<SelectContent>
{executionModes.map((executionMode, key) =>
<SelectItem key={key} value={executionMode.value}>
{executionMode.label}
</SelectItem>
)}
</SelectContent>
</Select>
<FormMessage/>
</FormItem>
)}
/>
{values.executionMode == "scheduled" ?
<FormField
control={form.control}
name="scheduledDatetime"
render={({field}) => (
<FormItem>
<FormLabel>Scheduled date</FormLabel>
<FormControl>
<DateTimePicker value={field.value} onChange={field.onChange}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/> : null}
<Button type="submit">Launch restore</Button>
</Form>
</div>
)
}
@@ -0,0 +1,24 @@
import {z} from "zod";
const ImmediateExecutionSchema = z.object({
executionMode: z.literal('immediate'),
});
const ScheduledExecutionSchema = z.object({
executionMode: z.literal('scheduled'),
scheduledDatetime: z.date(),
});
const CommonBackupSchema = z.union([ImmediateExecutionSchema, ScheduledExecutionSchema]);
const RemoteBackupSchema = z.object({
backupLocation: z.literal('remote-file'),
}).merge(CommonBackupSchema);
const DesktopBackupSchema = z.object({
backupLocation: z.literal('desktop-file'),
uploadedBackupFile: z.instanceof(File),
}).merge(CommonBackupSchema);
export const RestoreSchema = z.union([RemoteBackupSchema, DesktopBackupSchema]);
export type RestoreType = z.infer<typeof RestoreSchema>;
@@ -0,0 +1,98 @@
"use client"
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart";
import {CartesianGrid, Line, LineChart, XAxis, YAxis} from "recharts";
import {humanReadableDate} from "@/utils/date-formatting";
const data = [
{date: "2024-12-01", count: 1},
{date: "2024-12-02", count: 2},
{date: "2024-12-03", count: 4},
{date: "2024-12-04", count: 8},
{date: "2024-12-05", count: 9},
{date: "2024-12-06", count: 9},
{date: "2024-12-07", count: 10},
{date: "2024-12-08", count: 13},
{date: "2024-12-09", count: 15},
{date: "2024-12-10", count: 18},
]
type Data = {
createdAt: Date;
}
export type evolutionLineChartProps = {
data: Data[]
}
export function EvolutionLineChart(props: evolutionLineChartProps) {
const {data} = props
console.log("aaa data", data)
// Process data to calculate cumulative count
const cumulativeData = data.reduce((acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
// Increment count for the current date or initialize it
if (acc.length && acc[acc.length - 1].date === date) {
acc[acc.length - 1].count += 1;
} else {
const lastCount = acc.length ? acc[acc.length - 1].count : 0;
acc.push({date, count: lastCount + 1});
}
return acc;
}, [] as { date: string; count: number }[]);
const chartConfig = {
date: {
label: "Date",
color: "#2563eb",
},
count: {
label: "Number of backups",
color: "#60a5fa",
},
} satisfies ChartConfig
return (
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false}/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => humanReadableDate(Date(value)).split(' ')[0]}
/>
<YAxis/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel/>}
/>
<Line
dataKey="count"
type="linear"
stroke="var(--color-desktop)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
)
}
@@ -0,0 +1,85 @@
"use client"
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart";
import {CartesianGrid, Line, LineChart, XAxis, YAxis} from "recharts";
type Data = {
createdAt: Date;
status: "success" | "failed";
_count: { id: number };
}
export type percentageLineChartProps = {
data: Data[]
}
export function PercentageLineChart(props: percentageLineChartProps) {
const {data} = props
const chartConfig = {
date: {
label: "Date",
color: "#2563eb",
},
successRate: {
label: "Success Rate",
color: "#60a5fa",
},
} satisfies ChartConfig
const dailyStats = data.reduce((acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format YYYY-MM-DD
const status = backup.status;
if (!acc[date]) {
acc[date] = {success: 0, failed: 0, total: 0};
}
acc[date][status === "success" ? "success" : "failed"] += backup._count.id;
acc[date].total += backup._count.id;
return acc;
}, {} as Record<string, { success: number; failed: number; total: number }>);
// Format data for the chart
const formattedData = Object.entries(dailyStats).map(([date, stats]) => ({
date,
successRate: (stats.success / stats.total) * 100,
}));
return (
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={formattedData}
>
<CartesianGrid strokeDasharray="3 3"/>
<XAxis dataKey="date"/>
<YAxis domain={[0, 100]} tickFormatter={(tick) => `${tick}%`}/>
<ChartTooltip
content={<ChartTooltipContent/>}
cursor={false}
defaultIndex={1}
formatter={(value, name) => (
<div className="flex min-w-[130px] items-center text-xs text-muted-foreground">
{chartConfig[name as keyof typeof chartConfig]?.label ||
name}
<div
className="ml-auto flex items-baseline gap-0.5 font-mono font-medium tabular-nums text-foreground">
{value}
<span className="font-normal text-muted-foreground">
%
</span>
</div>
</div>
)}
/>
<Line type="step" dataKey="successRate" stroke="#8884d8" strokeWidth={2}/>
</LineChart>
</ChartContainer>
)
}