Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847a567c52 | ||
|
|
1ffbd6efc8 | ||
|
|
f8f599e4dd | ||
|
|
f7cd169090 | ||
|
|
bb4003b0db | ||
|
|
159398465f |
@@ -1,19 +1,47 @@
|
||||
import React from "react";
|
||||
"use client"
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {LayoutAdmin} from "@/components/layout";
|
||||
import Image from "next/image";
|
||||
import {env} from "@/env.mjs";
|
||||
import {useTheme} from "next-themes";
|
||||
|
||||
|
||||
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||
export default function Layout({children}: { children: React.ReactNode }) {
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<LayoutAdmin>
|
||||
<div
|
||||
className="w-full h-full grid "
|
||||
>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
{children}
|
||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||
<Image
|
||||
className="p-12 text-black dark:text-white"
|
||||
src={imageTheme}
|
||||
loading="eager"
|
||||
alt="Logo"
|
||||
width={1024}
|
||||
height={1024}
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-muted-foreground -ml-12 -mb-12">
|
||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
||||
</span>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</LayoutAdmin>
|
||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
||||
Powered by <span className="font-medium">Soluce Technologies</span>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useRef} from "react";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {useSearchParams} from "next/navigation";
|
||||
|
||||
import {LoginForm} from "@/components/wrappers/auth/login/loginForm/LoginForm";
|
||||
@@ -10,19 +10,22 @@ export default function SignInPage(props: {
|
||||
searchParams: Promise<{ callbackUrl: string | undefined }>
|
||||
}) {
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const error = searchParams.get("error");
|
||||
const isFirstRender = useRef(true);
|
||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current && error) {
|
||||
toast.error("Error occurred.");
|
||||
isFirstRender.current = false;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams(urlParams);
|
||||
const error = urlParams.get("error");
|
||||
console.log(urlParams.get("redirect"));
|
||||
if (error?.includes("pending")) {
|
||||
toast.error("Your account is not active.");
|
||||
urlParams.delete("error");
|
||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||
}
|
||||
}, [error]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<LoginForm/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import {RegisterForm} from "@/components/wrappers/auth/register/register-form/re
|
||||
|
||||
export default async function RoutePage(props: PageParams<{}>) {
|
||||
return (
|
||||
<div className="mx-auto grid w-[350px] gap-6">
|
||||
<div className="mx-auto grid w-full gap-6">
|
||||
<RegisterForm/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -24,8 +24,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
databases: true
|
||||
}
|
||||
})
|
||||
//
|
||||
console.log(agent)
|
||||
|
||||
|
||||
if (!agent) {
|
||||
notFound()
|
||||
@@ -33,6 +32,13 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
//
|
||||
// const databaseId = 'db-123';
|
||||
//
|
||||
|
||||
console.log("agent",agent)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// const totalBackupsResult = await db
|
||||
// .select({ count: drizzleDb.schemas.backup.id })
|
||||
// .from(drizzleDb.schemas.backup)
|
||||
@@ -67,11 +73,6 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
{/*<AgentModalKey agent={agent}>*/}
|
||||
{/* <Button variant="outline">*/}
|
||||
{/* <KeyRound/>*/}
|
||||
{/* </Button>*/}
|
||||
{/*</AgentModalKey>*/}
|
||||
</PageTitle>
|
||||
|
||||
</div>
|
||||
@@ -80,9 +81,9 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
<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
|
||||
Databases
|
||||
</CardHeader>
|
||||
<CardContent></CardContent>
|
||||
<CardContent>{agent.databases.length}</CardContent>
|
||||
</Card>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
@@ -102,7 +103,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
<Card className="w-full sm:w-auto flex-1">
|
||||
<Card className="w-full sm:w-auto flex-1 mb-4">
|
||||
<CardHeader className="font-bold text-xl">
|
||||
Edge Key
|
||||
</CardHeader>
|
||||
@@ -110,7 +111,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
<AgentCardKey agent={agent}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CardsWithPagination data={agent.databases} cardItem={DatabaseCard}/>
|
||||
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
"use client";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { PageParams } from "@/types/next";
|
||||
import {Page, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
|
||||
export default function RoutePage(props: PageParams<{}>) {
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-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" />
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageTitle>Dashboard</PageTitle>
|
||||
</PageHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-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" />*/}
|
||||
{/*{JSON.stringify(activeOrganization)}*/}
|
||||
{/*{activeOrganization?.name}*/}
|
||||
</div>
|
||||
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
|
||||
{JSON.stringify(activeOrganization)}
|
||||
{activeOrganization?.name}
|
||||
</div>
|
||||
|
||||
</Page>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
if (!proj) notFound();
|
||||
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="justify-between gap-2 sm:flex">
|
||||
@@ -62,6 +63,7 @@ export default async function RoutePage(props: PageParams<{
|
||||
<CardsWithPagination
|
||||
data={proj.databases}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={4}
|
||||
numberOfColumns={1}
|
||||
|
||||
@@ -4,13 +4,12 @@ import {notFound} from "next/navigation";
|
||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
|
||||
}>) {
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
if (!organization || organization.slug == "default") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import {redirect} from "next/navigation";
|
||||
|
||||
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/sideBar/app-sidebar";
|
||||
import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar";
|
||||
import {Header} from "@/features/layout/Header";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export default async function Index() {
|
||||
const user = await currentUser();
|
||||
if (user) {
|
||||
const currentOrganizationSlug = await getCurrentOrganizationSlug();
|
||||
redirect(`/dashboard/${currentOrganizationSlug}/projects`);
|
||||
redirect(`/dashboard/home`);
|
||||
}
|
||||
redirect("/login");
|
||||
//Do not delete
|
||||
|
||||
@@ -82,8 +82,13 @@ COPY --from=builder /app/public ./public
|
||||
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
RUN mkdir -p /app/private/uploads
|
||||
RUN chown -R nextjs:nodejs /app/private
|
||||
RUN chown -R nextjs:nodejs /app/public
|
||||
|
||||
|
||||
COPY --from=builder /app/next.config.ts ./
|
||||
COPY --from=builder /app/portabase.config.ts ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
@@ -3,9 +3,11 @@ import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
||||
import { errorHandler } from "@/middleware/errorHandler";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { signOut } from "@/lib/auth/auth-client";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl.clone();
|
||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
||||
|
||||
if (url.pathname.startsWith("/dashboard")) {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -13,7 +15,17 @@ export async function middleware(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
|
||||
if (session.user.banned) {
|
||||
signOut();
|
||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||
}
|
||||
|
||||
if (session.user.role === "pending") {
|
||||
signOut();
|
||||
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
import type { NextConfig } from "next";
|
||||
import type {NextConfig} from "next";
|
||||
import {PORTABASE_DEFAULT_SETTINGS} from "./portabase.config";
|
||||
|
||||
|
||||
function buildCSPHeader(): string {
|
||||
const {CSP} = PORTABASE_DEFAULT_SETTINGS.SECURITY;
|
||||
|
||||
const directives = [
|
||||
`default-src ${CSP.DEFAULT_SRC.join(" ")}`,
|
||||
`script-src ${CSP.SCRIPT_SRC.join(" ")}`,
|
||||
`style-src ${CSP.STYLE_SRC.join(" ")}`,
|
||||
`img-src ${CSP.IMG_SRC.join(" ")}`,
|
||||
`font-src ${CSP.FONT_SRC.join(" ")}`,
|
||||
`object-src ${CSP.OBJECT_SRC.join(" ")}`,
|
||||
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
|
||||
`base-uri ${CSP.BASE_URI.join(" ")}`,
|
||||
`form-action ${CSP.FORM_ACTION.join(" ")}`,
|
||||
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
|
||||
];
|
||||
|
||||
if (CSP.BLOCK_ALL_MIXED_CONTENT) {
|
||||
directives.push("block-all-mixed-content");
|
||||
}
|
||||
|
||||
if (CSP.UPGRADE_INSECURE_REQUESTS) {
|
||||
directives.push("upgrade-insecure-requests");
|
||||
}
|
||||
|
||||
return directives.join("; ");
|
||||
}
|
||||
|
||||
function buildPermissionsPolicy(): string {
|
||||
return Object.entries(PORTABASE_DEFAULT_SETTINGS.SECURITY.PERMISSIONS_POLICY)
|
||||
.map(([feature, values]) => `${feature.toLowerCase()}=${values.join(", ")}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
@@ -14,6 +50,40 @@ const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
nodeMiddleware: true,
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: buildCSPHeader(),
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: buildPermissionsPolicy(),
|
||||
},
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
},
|
||||
{
|
||||
key: 'Referrer-Policy',
|
||||
value: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
{
|
||||
key: 'Strict-Transport-Security',
|
||||
value: 'max-age=63072000; includeSubDomains; preload',
|
||||
}
|
||||
// ...other security headers
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
SECURITY: {
|
||||
CSP: {
|
||||
DEFAULT_SRC: ["'self'"],
|
||||
SCRIPT_SRC: [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"'unsafe-inline'",
|
||||
"https://cdn.jsdelivr.net",
|
||||
"https://www.googletagmanager.com",
|
||||
"https://code.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
"https://cdn.iconify.design",
|
||||
"https://api.iconify.design",
|
||||
],
|
||||
STYLE_SRC: [
|
||||
"'self'",
|
||||
"'unsafe-inline'",
|
||||
"https://fonts.googleapis.com",
|
||||
"https://code.iconify.design",
|
||||
"https://cdn.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
],
|
||||
IMG_SRC: [
|
||||
"'self'",
|
||||
"blob:",
|
||||
"data:",
|
||||
"https:",
|
||||
"https://code.iconify.design",
|
||||
"https://cdn.iconify.design",
|
||||
"https://code.iconify.com",
|
||||
"https://api.iconify.design",
|
||||
],
|
||||
FONT_SRC: [
|
||||
"'self'",
|
||||
"https://fonts.gstatic.com",
|
||||
"https://cdn.iconify.design",
|
||||
],
|
||||
CONNECT_SRC: [
|
||||
"'self'",
|
||||
"https://api.iconify.design",
|
||||
"https://code.iconify.design",
|
||||
],
|
||||
OBJECT_SRC: ["'none'"],
|
||||
BASE_URI: ["'self'"],
|
||||
FORM_ACTION: ["'self'"],
|
||||
FRAME_ANCESTORS: ["'none'"],
|
||||
BLOCK_ALL_MIXED_CONTENT: false,
|
||||
UPGRADE_INSECURE_REQUESTS: true,
|
||||
},
|
||||
PERMISSIONS_POLICY: {
|
||||
CAMERA: ["()"],
|
||||
MICROPHONE: ["()"],
|
||||
GEOLOCATION: ["()"],
|
||||
FULLSCREEN: ["(self)"],
|
||||
// ...other features
|
||||
},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB |
@@ -48,7 +48,7 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
|
||||
))}
|
||||
</div>
|
||||
<PaginationNavigation
|
||||
className="justify-end"
|
||||
className="justify-end mt-4"
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
goToPage={goToPage}
|
||||
|
||||
@@ -7,11 +7,13 @@ export type ConnectionCircleProps = {
|
||||
export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
|
||||
let style = "bg-gray-300 border-gray-400";
|
||||
|
||||
if (date) {
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const timestamp = new Date(date).getTime();
|
||||
const timestamp = date.getTime();
|
||||
const interval = now - timestamp;
|
||||
|
||||
console.log({ now, timestamp, interval });
|
||||
|
||||
if (interval < 10000) {
|
||||
style = "bg-green-400 border-green-600";
|
||||
} else if (interval <= 20000) {
|
||||
@@ -19,7 +21,11 @@ export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
|
||||
} else {
|
||||
style = "bg-red-400 border-red-600";
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid date passed to ConnectionCircle:", date);
|
||||
}
|
||||
|
||||
console.log(style);
|
||||
|
||||
return <div className={cn("w-5 h-5 rounded-full border-4", style)} />;
|
||||
};
|
||||
|
||||
@@ -14,13 +14,13 @@ export const AgentCard = (props: agentCardProps) => {
|
||||
const { data: agent } = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/agents/${agent.id}`}>
|
||||
<Link href={`/dashboard/agents/${agent.id}`} className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md">
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="">
|
||||
<CardHeader>{agent.name}</CardHeader>
|
||||
<CardContent>Last contact : {formatDateLastContact(agent.lastContact)}</CardContent>
|
||||
<div>
|
||||
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
|
||||
<CardContent>Last contact: {formatDateLastContact(agent.lastContact)}</CardContent>
|
||||
</div>
|
||||
<div className="mt-3 mr-3">
|
||||
<div className="flex items-center px-4">
|
||||
<ConnectionCircle date={agent.lastContact} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
"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";
|
||||
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;
|
||||
@@ -29,14 +37,13 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: AgentType) => {
|
||||
console.log("values", values);
|
||||
|
||||
const createAgent = isCreate
|
||||
? await createAgentAction(values)
|
||||
: await updateAgentAction({
|
||||
id: props.agentId ?? "-",
|
||||
data: values,
|
||||
});
|
||||
id: props.agentId ?? "-",
|
||||
data: values,
|
||||
});
|
||||
|
||||
const data = createAgent?.data?.data;
|
||||
if (createAgent?.serverError || !data) {
|
||||
@@ -44,7 +51,7 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
toast.error(createAgent?.serverError);
|
||||
return;
|
||||
}
|
||||
toast.success(`Success`);
|
||||
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
||||
router.push(`/dashboard/agents/${data.id}`);
|
||||
router.refresh();
|
||||
},
|
||||
@@ -65,37 +72,14 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
control={form.control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
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 />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -103,14 +87,15 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
control={form.control}
|
||||
defaultValue=""
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="This agent is for the client exemple.com" {...field} value={field.value ?? ""} />
|
||||
<Input placeholder="This agent is for the client exemple.com" {...field}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormDescription>Enter your project agent description</FormDescription>
|
||||
<FormMessage />
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use server";
|
||||
import { ActionError, userAction } from "@/safe-actions";
|
||||
import { AgentSchema } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
|
||||
import { z } from "zod";
|
||||
import { eq, and, ne, count } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {ActionError, userAction} from "@/safe-actions";
|
||||
import {AgentSchema} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
|
||||
import {z} from "zod";
|
||||
import {eq, and, ne, count} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
|
||||
const verifySlugUniqueness = async (slug: string, agentId?: string) => {
|
||||
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
||||
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
|
||||
|
||||
const [countResult] = await db.select({ count: count() }).from(drizzleDb.schemas.agent).where(conditions);
|
||||
const [countResult] = await db.select({count: count()}).from(drizzleDb.schemas.agent).where(conditions);
|
||||
|
||||
if (countResult.count > 0) {
|
||||
throw new ActionError("Slug already exists");
|
||||
}
|
||||
};
|
||||
|
||||
export const createAgentAction = userAction.schema(AgentSchema).action(async ({ parsedInput }) => {
|
||||
await verifySlugUniqueness(parsedInput.slug);
|
||||
|
||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values(parsedInput).returning();
|
||||
|
||||
export const createAgentAction = userAction.schema(AgentSchema).action(async ({parsedInput}) => {
|
||||
const slug = slugify(parsedInput.name);
|
||||
await verifySlugUniqueness(slug);
|
||||
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
|
||||
return {
|
||||
data: createdAgent,
|
||||
};
|
||||
@@ -33,10 +33,14 @@ export const updateAgentAction = userAction
|
||||
data: AgentSchema,
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }) => {
|
||||
await verifySlugUniqueness(parsedInput.data.slug, parsedInput.id);
|
||||
.action(async ({parsedInput}) => {
|
||||
const slug = slugify(parsedInput.data.name);
|
||||
await verifySlugUniqueness(slug, parsedInput.id);
|
||||
|
||||
const [updatedAgent] = await db.update(drizzleDb.schemas.agent).set(parsedInput.data).where(eq(drizzleDb.schemas.agent.id, parsedInput.id)).returning();
|
||||
const [updatedAgent] = await db.update(drizzleDb.schemas.agent).set({
|
||||
...parsedInput.data,
|
||||
slug: slug
|
||||
}).where(eq(drizzleDb.schemas.agent.id, parsedInput.id)).returning();
|
||||
|
||||
return {
|
||||
data: updatedAgent,
|
||||
|
||||
@@ -2,11 +2,6 @@ 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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem
|
||||
} from "@/components/ui/sidebar";
|
||||
import {SidebarLogo} from "@/components/wrappers/dashboard/common/sidebar/logo-sidebar";
|
||||
import {SidebarMenuCustomMain} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar-main";
|
||||
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/loggedInButton/LoggedInButton";
|
||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarLogo/>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<OrganizationCombobox/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarMenuCustomMain/>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<LoggedInButton />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SideBarFooterCredit />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import Link from "next/link";
|
||||
import { env } from "@/env.mjs";
|
||||
import { useTheme } from "next-themes";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const SidebarLogo = () => {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||
|
||||
return (
|
||||
<div className="ml-1 flex items-center justify-center">
|
||||
<Link href={"/"}>
|
||||
{state === 'collapsed' && !isMobile ? (
|
||||
<Image
|
||||
loading="eager"
|
||||
src={"/images/logo.png"}
|
||||
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
|
||||
className="h-10 w-10 object-contain"
|
||||
height={10}
|
||||
width={10}
|
||||
/>
|
||||
) : (
|
||||
<div className="m-4">
|
||||
<Image
|
||||
loading="eager"
|
||||
src={imageTheme}
|
||||
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
|
||||
className="object-contain"
|
||||
width={190}
|
||||
height={90}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import {Home, Settings, Target, Users, Pickaxe, Euro, BanknoteX, Layers, ChartArea, ShieldHalf} from "lucide-react";
|
||||
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
|
||||
|
||||
export const SidebarMenuCustomMain = () => {
|
||||
|
||||
const BASE_URL = `/dashboard`;
|
||||
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: organizations } = authClient.useListOrganizations();
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
const member = authClient.useActiveMember();
|
||||
|
||||
|
||||
if (isPending) return null;
|
||||
|
||||
if (error || !session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupContent: SidebarGroupItem["group_content"] = [
|
||||
{ title: "Dashboard", url: "/home", icon: Home },
|
||||
{ title: "Projects", url: "/projects", icon: Layers, details:true },
|
||||
{ title: "Statistics", url: "/statistics", icon: ChartArea },
|
||||
];
|
||||
|
||||
if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
|
||||
groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
|
||||
}
|
||||
|
||||
const items: SidebarGroupItem[] = [
|
||||
{
|
||||
label: "Application",
|
||||
type: "list",
|
||||
group_content: groupContent,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
if (session?.user.role == "admin" || session?.user.role == "superadmin") {
|
||||
items.push({
|
||||
label: "Administration",
|
||||
type: "list",
|
||||
group_content: [
|
||||
{title: "Agents", url: "/agents", icon: ShieldHalf, details: true},
|
||||
{title: "Administration panel", url: "/admin", icon: Settings, details: true},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
ChevronDown,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger
|
||||
} from "@radix-ui/react-collapsible";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export type SidebarItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
redirect?: boolean;
|
||||
not_from_base_url?: boolean;
|
||||
icon: React.ElementType;
|
||||
dropdown?: SidebarItem[];
|
||||
submenu?: SidebarItem[];
|
||||
details?: boolean;
|
||||
};
|
||||
|
||||
export type SidebarGroupItem = {
|
||||
label: string;
|
||||
type: "list" | "collapse";
|
||||
group_content: SidebarItem[];
|
||||
};
|
||||
|
||||
type SidebarMenuCustomBaseProps = {
|
||||
baseUrl: string;
|
||||
items: SidebarGroupItem[];
|
||||
};
|
||||
|
||||
export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => {
|
||||
const pathname = usePathname();
|
||||
const [activeItem, setActiveItem] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const normalize = (url: string) => url.split("?")[0].replace(/\/$/, "");
|
||||
|
||||
const findActiveUrl = () => {
|
||||
const normalizedPathname = normalize(pathname);
|
||||
console.log(normalizedPathname);
|
||||
for (const group of items) {
|
||||
for (const item of group.group_content) {
|
||||
const itemUrl = normalize(item.url);
|
||||
|
||||
const fullUrl = item.not_from_base_url
|
||||
? itemUrl
|
||||
: normalize(`${baseUrl}${itemUrl}`);
|
||||
|
||||
if (
|
||||
normalizedPathname === fullUrl ||
|
||||
(item.details && normalizedPathname.startsWith(`${fullUrl}/`))
|
||||
) {
|
||||
return itemUrl;
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
for (const subItem of item.submenu) {
|
||||
const subItemUrl = normalize(subItem.url);
|
||||
const subFullUrl = normalize(`${baseUrl}${subItemUrl}`);
|
||||
|
||||
if (
|
||||
normalizedPathname === subFullUrl ||
|
||||
(subItem.details && normalizedPathname.startsWith(`${subFullUrl}/`))
|
||||
) {
|
||||
return subItemUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
const activeUrl = findActiveUrl();
|
||||
setActiveItem(activeUrl);
|
||||
}, [pathname, baseUrl, items]);
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((group, index) => (
|
||||
<Collapsible key={index} defaultOpen disabled={group.type === "list"}>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger>
|
||||
{group.label ?? "Missing label"}
|
||||
{group.type === "collapse" && (
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
{group.group_content.map((item, index) => (
|
||||
<SidebarMenuItem key={index} className="mb-1">
|
||||
<SidebarMenuButton asChild>
|
||||
<Link
|
||||
href={
|
||||
item.redirect || item.not_from_base_url
|
||||
? item.url
|
||||
: `${baseUrl}${item.url}`
|
||||
}
|
||||
target={item.redirect ? "_blank" : ""}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "lg",
|
||||
variant: activeItem === item.url ? "secondary" : "ghost"
|
||||
}),
|
||||
"justify-start p-0",
|
||||
"hover:bg-gray-700"
|
||||
)}
|
||||
onClick={() => setActiveItem(item.url)}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
{item.submenu && (
|
||||
<SidebarMenuSub>
|
||||
{item.submenu.map((sub, idx) => (
|
||||
<SidebarMenuSubItem key={idx}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<Link
|
||||
href={`${baseUrl}${sub.url}`}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
size: "lg",
|
||||
variant: activeItem === sub.url ? "secondary" : "ghost"
|
||||
}),
|
||||
"justify-start p-0",
|
||||
"hover:bg-gray-700"
|
||||
)}
|
||||
onClick={() => setActiveItem(sub.url)}
|
||||
>
|
||||
<sub.icon />
|
||||
{sub.title}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
|
||||
{item.dropdown && (
|
||||
<SidebarMenuAction>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<MoreHorizontal />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
{item.dropdown.map((dropdown, idx) => (
|
||||
<DropdownMenuItem key={idx} asChild>
|
||||
<Link
|
||||
href={
|
||||
dropdown.redirect || dropdown.not_from_base_url
|
||||
? dropdown.url
|
||||
: `${baseUrl}${dropdown.url}`
|
||||
}
|
||||
className="justify-start p-0"
|
||||
target={dropdown.redirect ? "_blank" : ""}
|
||||
onClick={() => setActiveItem(dropdown.url)}
|
||||
>
|
||||
<span>{dropdown.title}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuAction>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export type SideBarFooterCreditProps = {};
|
||||
|
||||
export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
|
||||
const { state } = useSidebar();
|
||||
|
||||
return (
|
||||
<>
|
||||
{state === "expanded" && (
|
||||
<div className="text-center">
|
||||
<h1 className="text-[10px]">Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}</h1>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useTheme} from "next-themes";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export type SideBarLogoProps = {};
|
||||
|
||||
export const SideBarLogo = (props: SideBarLogoProps) => {
|
||||
const {state, isMobile} = useSidebar();
|
||||
const {resolvedTheme} = useTheme();
|
||||
return state === "collapsed" && !isMobile ? (
|
||||
<Link href={"/dashboard"}>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Image
|
||||
src={"/images/logo/picto.png"}
|
||||
alt="Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
loading="eager"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={"/dashboard"}>
|
||||
|
||||
<div className="w-full h-auto p-4 flex items-center justify-center">
|
||||
<Image
|
||||
loading="eager"
|
||||
src={"/images/logo/logo.png"}
|
||||
alt="Logo"
|
||||
width={190}
|
||||
height={190}
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { setCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
|
||||
export type DeleteOrganizationButtonProps = {
|
||||
organizationSlug: string;
|
||||
@@ -14,15 +15,26 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
||||
const router = useRouter();
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
|
||||
|
||||
onSuccess: async (result) => {
|
||||
if (result.data.success) {
|
||||
await setCurrentOrganizationSlug("default");
|
||||
if (result?.data?.success) {
|
||||
await authClient.organization.setActive({
|
||||
organizationSlug: "default",
|
||||
});
|
||||
router.push("/");
|
||||
toast.success(result.data.actionSuccess.message);
|
||||
|
||||
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
|
||||
} else {
|
||||
toast.error(result.data.actionError.message);
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error: any) => {
|
||||
console.error("Mutation network error:", error);
|
||||
toast.error(error?.message || "A network error occurred.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,34 +9,35 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
|
||||
import { OrganizationFormSchema, OrganizationFormType } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
|
||||
import { updateOrganizationAction } from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import { toast } from "sonner";
|
||||
import {Member, Organization} from "better-auth/plugins";
|
||||
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
|
||||
|
||||
export type organizationFormProps = {
|
||||
defaultValues?: Organization;
|
||||
members: Member[];
|
||||
defaultValues?: OrganizationWithMembers;
|
||||
members: MemberWithUser[];
|
||||
};
|
||||
|
||||
export const OrganizationForm = (props: organizationFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(props.defaultValues);
|
||||
|
||||
const formatUsersList = (members: Member[]) => {
|
||||
const formatUsersList = (members: MemberWithUser[]) => {
|
||||
|
||||
return members.map((member) => ({
|
||||
value: member.id,
|
||||
value: member.user.id,
|
||||
label: `${member.user.name} | ${member.user.email}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const formatDefaultUsers = (members: OrganizationFormType["members"]): string[] => {
|
||||
console.log(members);
|
||||
const formatDefaultUsers = (members: MemberWithUser[]): string[] => {
|
||||
console.log("ici",members);
|
||||
return members.map((member) => member.userId);
|
||||
};
|
||||
|
||||
const formattedDefaultValues = {
|
||||
...props.defaultValues,
|
||||
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members) : [],
|
||||
name: props.defaultValues?.name,
|
||||
slug: props.defaultValues?.slug,
|
||||
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members as MemberWithUser[]) : [],
|
||||
};
|
||||
|
||||
const form = useZodForm({
|
||||
@@ -59,6 +60,8 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
console.log(formatUsersList(props.members))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader></CardHeader>
|
||||
@@ -110,13 +113,13 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Databases</FormLabel>
|
||||
<FormLabel>Users</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelect
|
||||
options={formatUsersList(props.members)}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ?? []}
|
||||
placeholder="Select databases"
|
||||
placeholder="Select users"
|
||||
variant="inverted"
|
||||
animation={2}
|
||||
// maxCount={100}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {createOrganizationAction} from "@/components/wrappers/dashboard/organiza
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export type createOrganizationModalProps = {
|
||||
open: boolean;
|
||||
@@ -35,15 +36,19 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
|
||||
|
||||
const result = await createOrganizationAction(values);
|
||||
|
||||
if (result && result.data) {
|
||||
if (result.data.success && result.data.value) {
|
||||
onOpenChange(false);
|
||||
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
|
||||
onSuccess?.();
|
||||
router.replace(`/dashboard/home`);
|
||||
// router.refresh();
|
||||
}
|
||||
if (result?.data?.success && result.data.value) {
|
||||
onOpenChange(false);
|
||||
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
|
||||
onSuccess?.();
|
||||
toast.success(result.data.actionSuccess?.message || "Organization Created.");
|
||||
// router.push("/");
|
||||
router.replace(`/dashboard/home`);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to create the organization.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
@@ -77,26 +82,26 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
defaultValue=""
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
|
||||
field.onChange(value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/*<FormField*/}
|
||||
{/* control={form.control}*/}
|
||||
{/* name="slug"*/}
|
||||
{/* defaultValue=""*/}
|
||||
{/* render={({field}) => (*/}
|
||||
{/* <FormItem>*/}
|
||||
{/* <FormLabel>Slug</FormLabel>*/}
|
||||
{/* <FormControl>*/}
|
||||
{/* <Input*/}
|
||||
{/* {...field}*/}
|
||||
{/* onChange={(e) => {*/}
|
||||
{/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
|
||||
{/* field.onChange(value);*/}
|
||||
{/* }}*/}
|
||||
{/* />*/}
|
||||
{/* </FormControl>*/}
|
||||
{/* <FormMessage/>*/}
|
||||
{/* </FormItem>*/}
|
||||
{/* )}*/}
|
||||
{/*/>*/}
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<Button type="submit">Create</Button>
|
||||
|
||||
@@ -7,13 +7,16 @@ import { z } from "zod";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { OrganizationFormSchema } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
|
||||
import { db } from "@/db";
|
||||
import { Organization, organization as drizzleOrganization, organizationMember as drizzleOrganizationMember } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { checkSlugOrganization, createOrganization } from "@/lib/auth/auth";
|
||||
import {checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
import {Organization} from "@/db/schema/02_organization";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({ parsedInput }): Promise<ServerActionResult<Organization>> => {
|
||||
try {
|
||||
if (!checkSlugOrganization(parsedInput.slug)) {
|
||||
const slug = slugify(parsedInput.name);
|
||||
if (!await checkSlugOrganization(slug)) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
@@ -24,14 +27,29 @@ export const createOrganizationAction = userAction.schema(OrganizationSchema).ac
|
||||
};
|
||||
}
|
||||
|
||||
const organization = await createOrganization(parsedInput.name, parsedInput.slug);
|
||||
let createdOrganization: Organization;
|
||||
|
||||
try {
|
||||
createdOrganization = await createOrganization(parsedInput.name, slug) as unknown as Organization;
|
||||
} catch (authError: any) {
|
||||
console.error("Auth deletion failed:", authError);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: authError.message || "Authentication service error.",
|
||||
status: authError.status || 500,
|
||||
cause: "auth_error",
|
||||
messageParams: { message: authError.message },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: organization!,
|
||||
value: createdOrganization,
|
||||
actionSuccess: {
|
||||
message: "Organization has been successfully created.",
|
||||
messageParams: { organizationId: organization!.id },
|
||||
messageParams: { organizationId: createdOrganization!.id },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -58,7 +76,7 @@ export const updateOrganizationAction = userAction
|
||||
try {
|
||||
const newUserList = parsedInput.data.users;
|
||||
|
||||
const organization = await db.select().from(organization).where(eq(organization.id, parsedInput.organizationId)).execute();
|
||||
const organization = await db.select().from(drizzleDb.schemas.organization).where(eq(drizzleDb.schemas.organization.id, parsedInput.organizationId)).execute();
|
||||
|
||||
if (organization.length === 0) {
|
||||
throw new Error("Organization not found.");
|
||||
@@ -70,7 +88,7 @@ export const updateOrganizationAction = userAction
|
||||
|
||||
if (usersToAdd.length > 0) {
|
||||
await db
|
||||
.insert(userOrganization)
|
||||
.insert(drizzleDb.schemas.organization)
|
||||
.values(
|
||||
usersToAdd.map((userId) => ({
|
||||
userId,
|
||||
@@ -117,44 +135,60 @@ export const updateOrganizationAction = userAction
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }): Promise<ServerActionResult<Organization>> => {
|
||||
try {
|
||||
const uuid = uuidv4();
|
||||
const organization = await db.select().from(organization).where(eq(organization.slug, parsedInput)).execute();
|
||||
export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
||||
async ({ parsedInput, ctx }): Promise<ServerActionResult<Organization>> => {
|
||||
try {
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
|
||||
});
|
||||
|
||||
if (organization.length === 0) {
|
||||
throw new Error("Organization not found.");
|
||||
if (!org) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Organization not found.",
|
||||
status: 404,
|
||||
cause: "not_found",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let deletedOrganization: Organization;
|
||||
|
||||
try {
|
||||
deletedOrganization = await deleteOrganization(org.id) as Organization;
|
||||
} catch (authError: any) {
|
||||
console.error("Auth deletion failed:", authError);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: authError.message || "Authentication service error.",
|
||||
status: authError.status || 500,
|
||||
cause: "auth_error",
|
||||
messageParams: { message: authError.message },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: deletedOrganization,
|
||||
actionSuccess: {
|
||||
message: "Organization has been successfully deleted.",
|
||||
messageParams: { organizationId: deletedOrganization.id },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Unexpected error in deleteOrganizationAction:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to delete organization due to a server error.",
|
||||
status: 500,
|
||||
cause: "server_error",
|
||||
messageParams: { message: "Internal server error while deleting the organization" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatedOrganization = await db
|
||||
.update(organization)
|
||||
.set({
|
||||
name: `${organization[0].name}-${uuid}`,
|
||||
slug: `${organization[0].slug}-${uuid}`,
|
||||
deleted: true,
|
||||
})
|
||||
.where(eq(organization.id, organization[0].id))
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: updatedOrganization[0],
|
||||
actionSuccess: {
|
||||
message: "Organization has been successfully deleted.",
|
||||
messageParams: { organizationId: updatedOrganization[0].id },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error deleting organization:", error);
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to delete organization.",
|
||||
status: 500,
|
||||
cause: error.message ?? "Unknown error",
|
||||
messageParams: { message: "Error deleting the organization" },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@ import { z } from "zod";
|
||||
|
||||
export const OrganizationSchema = z.object({
|
||||
name: z.string().min(5, "Name must be at least 5 characters long").max(40, "Name must be at most 40 characters long"),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens")
|
||||
.min(5, "Slug must be at least 5 characters long")
|
||||
.max(20, "Slug must be at most 20 characters long"),
|
||||
// slug: z
|
||||
// .string()
|
||||
// .regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens")
|
||||
// .min(5, "Slug must be at least 5 characters long")
|
||||
// .max(20, "Slug must be at most 20 characters long"),
|
||||
});
|
||||
|
||||
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
|
||||
|
||||
@@ -41,13 +41,10 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="h-full justify-between" value="backup">
|
||||
|
||||
{/*<DataTable columns={backupColumns} data={props.backups} extendedProps={props.isAlreadyRestore} />*/}
|
||||
<DataTable columns={backupColumns(props.isAlreadyRestore)} data={props.backups} enablePagination />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="h-full justify-between" value="restore">
|
||||
<DataTable columns={restoreColumns} data={props.restorations} enablePagination />
|
||||
<DataTable columns={restoreColumns(props.isAlreadyRestore)} data={props.restorations} enablePagination />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@@ -13,10 +13,10 @@ export const ProjectCard = (props: projectCardProps) => {
|
||||
const { data: project, organizationSlug } = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/projects/${project.id}`}>
|
||||
<Link href={`/dashboard/projects/${project.id}`} className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md">
|
||||
<Card className="flex flex-row justify-between">
|
||||
<div className="">
|
||||
<CardHeader>{project.name}</CardHeader>
|
||||
<CardHeader className="text-2xl font-bold">{project.name}</CardHeader>
|
||||
<CardContent>{project.databases.length} databases</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
|
||||
import { formatDateLastContact } from "@/utils/date-formatting";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
|
||||
export type projectDatabaseCardProps = {
|
||||
data: Database;
|
||||
@@ -17,7 +18,7 @@ export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
|
||||
const { organizationSlug, data: database, extendedProps: extendedProps } = props;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
|
||||
<Link className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md" href={`/dashboard/projects/${extendedProps.id}/database/${database.id}`}>
|
||||
<DatabaseCard data={database} />
|
||||
</Link>
|
||||
);
|
||||
@@ -31,15 +32,21 @@ 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">
|
||||
<div className="flex items-center space-x-4 px-4">
|
||||
<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 className="justify-between">
|
||||
<div className="font-medium">Name: {database.name}</div>
|
||||
<div className="text-sm text-muted-foreground">Generated Id: {database.agentDatabaseId}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last contact: {formatDateLastContact(database.lastContact)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 mr-3">
|
||||
|
||||
<div className="flex items-center px-4">
|
||||
<ConnectionCircle date={database.lastContact} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
databases: z.array(z.string()),
|
||||
});
|
||||
|
||||
|
||||
@@ -103,27 +103,6 @@ export const ProjectForm = (props: projectFormProps) => {
|
||||
</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"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { eq, inArray } from "drizzle-orm";
|
||||
import {Project} from "@/db/schema/05_project";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Database} from "@/db/schema/06_database";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
|
||||
export const createProjectAction = userAction
|
||||
.schema(
|
||||
@@ -19,11 +20,12 @@ export const createProjectAction = userAction
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<Project>> => {
|
||||
try {
|
||||
const slug = slugify(parsedInput.data.name);
|
||||
const [createdProject] = await db
|
||||
.insert(drizzleDb.schemas.project)
|
||||
.values({
|
||||
name: parsedInput.data.name,
|
||||
slug: parsedInput.data.slug,
|
||||
slug: slug,
|
||||
organizationId: parsedInput.organizationId,
|
||||
})
|
||||
.returning();
|
||||
@@ -87,12 +89,13 @@ export const updateProjectAction = userAction
|
||||
if (databasesToRemove.length > 0) {
|
||||
await db.update(drizzleDb.schemas.database).set({ projectId: null }).where(inArray(drizzleDb.schemas.database.id, databasesToRemove));
|
||||
}
|
||||
const slug = slugify(parsedInput.data.name);
|
||||
|
||||
const [updatedProject] = await db
|
||||
.update(drizzleDb.schemas.project)
|
||||
.set({
|
||||
name: parsedInput.data.name,
|
||||
slug: parsedInput.data.slug,
|
||||
slug: slug,
|
||||
})
|
||||
.where(eq(drizzleDb.schemas.project.id, parsedInput.projectId))
|
||||
.returning();
|
||||
|
||||
@@ -23,9 +23,9 @@ export async function AppSidebar() {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const organization = await getOrganization({organizationId: member.organizationId});
|
||||
// const organization = await getOrganization({organizationId: member.organizationId});
|
||||
|
||||
console.log("organization", organization);
|
||||
// console.log("organization", organization);
|
||||
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,9 @@ import { relations } from "drizzle-orm";
|
||||
import { project } from "./05_project";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
import {invitation} from "@/db/schema/04_invitation";
|
||||
import {member} from "@/db/schema/03_member";
|
||||
import {invitation, OrganizationInvitation} from "@/db/schema/04_invitation";
|
||||
import {member, OrganizationMember} from "@/db/schema/03_member";
|
||||
import {User} from "@/db/schema/01_user";
|
||||
|
||||
export const organization = pgTable("organization", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
@@ -28,3 +29,15 @@ export const organizationRelations = relations(organization, ({ many }) => ({
|
||||
export const organizationSchema = createSelectSchema(organization);
|
||||
export type Organization = z.infer<typeof organizationSchema>;
|
||||
|
||||
export type OrganizationWithMembers = Organization & {
|
||||
members: OrganizationMember[];
|
||||
};
|
||||
|
||||
export type MemberWithUser = OrganizationMember & {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export type OrganizationWithMembersAndUsers = Organization & {
|
||||
members: MemberWithUser[];
|
||||
invitations: OrganizationInvitation[];
|
||||
};
|
||||
@@ -1,15 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { StatusBadge } from "@/components/wrappers/common/status-badge";
|
||||
import {Restoration} from "@/db/schema/06_database";
|
||||
import {Backup, Restoration} from "@/db/schema/06_database";
|
||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {
|
||||
deleteBackupAction,
|
||||
deleteRestoreAction,
|
||||
rerunRestorationAction
|
||||
} from "@/features/dashboard/restore/restore.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {TooltipCustom} from "@/components/wrappers/common/tooltipCustom/TooltipCustom";
|
||||
|
||||
export const restoreColumns: ColumnDef<Restoration>[] = [
|
||||
|
||||
export function restoreColumns(isAlreadyRestore: boolean): ColumnDef<Restoration>[] {
|
||||
return[
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "Reference",
|
||||
@@ -31,6 +49,58 @@ export const restoreColumns: ColumnDef<Restoration>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status");
|
||||
|
||||
const router = useRouter();
|
||||
const rowData: Restoration = row.original;
|
||||
|
||||
|
||||
const mutationDeleteRestore = useMutation({
|
||||
mutationFn: async () => {
|
||||
const restoration = await deleteRestoreAction({
|
||||
restorationId: rowData.id,
|
||||
});
|
||||
// @ts-ignore
|
||||
if (restoration.data.success) {
|
||||
// @ts-ignore
|
||||
toast.success(restoration.data.actionSuccess.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.error(restoration.data.actionError.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const mutationRerunRestore = useMutation({
|
||||
mutationFn: async () => {
|
||||
const restoration = await rerunRestorationAction({
|
||||
restorationId: rowData.id,
|
||||
});
|
||||
// @ts-ignore
|
||||
if (restoration.data.success) {
|
||||
// @ts-ignore
|
||||
toast.success(restoration.data.actionSuccess.message);
|
||||
router.refresh();
|
||||
} else {
|
||||
// @ts-ignore
|
||||
toast.error(restoration.data.actionError.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleDelete = async () => {
|
||||
await mutationDeleteRestore.mutateAsync();
|
||||
};
|
||||
|
||||
const handleRerunRestore = async () => {
|
||||
await mutationRerunRestore.mutateAsync();
|
||||
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -42,8 +112,26 @@ export const restoreColumns: ColumnDef<Restoration>[] = [
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem onClick={() => {}}>
|
||||
<ReloadIcon /> Rerun
|
||||
<TooltipCustom disabled={isAlreadyRestore} text="Already a restoration waiting">
|
||||
<DropdownMenuItem
|
||||
disabled={mutationRerunRestore.isPending || isAlreadyRestore}
|
||||
onClick={async () => {
|
||||
await handleRerunRestore();
|
||||
}}
|
||||
>
|
||||
<ReloadIcon/> Rerun
|
||||
</DropdownMenuItem>
|
||||
</TooltipCustom>
|
||||
<DropdownMenuSeparator/>
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={status == "waiting"}
|
||||
className="text-red-600"
|
||||
onClick={async () => {
|
||||
await handleDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2/> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -51,3 +139,5 @@ export const restoreColumns: ColumnDef<Restoration>[] = [
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,43 @@ import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {Backup, Restoration} from "@/db/schema/06_database";
|
||||
|
||||
export const deleteRestoreAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
restorationId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<Backup>> => {
|
||||
try {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.restoration)
|
||||
.where(and(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId)))
|
||||
.execute();
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
actionSuccess: {
|
||||
message: "Restoration deleted successfully.",
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to delete restoration.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { message: "Error deleting the restoration" },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
export const deleteBackupAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
@@ -60,6 +97,58 @@ export const deleteBackupAction = userAction
|
||||
});
|
||||
|
||||
|
||||
|
||||
export const rerunRestorationAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
restorationId: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<Restoration>> => {
|
||||
try {
|
||||
const updateResult = await db
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set({ status: "waiting" })
|
||||
.where(eq(drizzleDb.schemas.restoration.id, parsedInput.restorationId))
|
||||
.returning()
|
||||
.execute();
|
||||
|
||||
const updatedRestoration = updateResult[0];
|
||||
|
||||
if (!updatedRestoration) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Restoration not found.",
|
||||
status: 404,
|
||||
cause: "No restoration with the given ID exists.",
|
||||
messageParams: { message: "Restoration not found" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: updatedRestoration,
|
||||
actionSuccess: {
|
||||
message: "Restoration has been requeued.",
|
||||
messageParams: { restorationId: updatedRestoration.id },
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to rerun restoration.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: { message: "Error updating the restoration" },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Create Restoration Action (Drizzle version)
|
||||
export const createRestorationAction = userAction
|
||||
.schema(
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {betterAuth} from "better-auth";
|
||||
import {drizzleAdapter} from "better-auth/adapters/drizzle";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {nextCookies} from "better-auth/next-js";
|
||||
import {admin as adminPlugin, openAPI, organization} from "better-auth/plugins";
|
||||
import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {headers} from "next/headers";
|
||||
import {count, eq} from "drizzle-orm";
|
||||
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/02_organization";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -40,9 +41,9 @@ export const auth = betterAuth({
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
orgOwner,
|
||||
orgAdmin,
|
||||
orgMember,
|
||||
owner: orgOwner,
|
||||
admin: orgAdmin,
|
||||
member: orgMember,
|
||||
},
|
||||
}),
|
||||
adminPlugin({
|
||||
@@ -98,7 +99,7 @@ export const auth = betterAuth({
|
||||
await db.insert(drizzleDb.schemas.member).values({
|
||||
userId: user.id,
|
||||
organizationId: defaultOrg.id,
|
||||
role: "owner",
|
||||
role: "orgOwner",
|
||||
});
|
||||
} else {
|
||||
console.warn("Default organization not found. Cannot assign member.");
|
||||
@@ -112,25 +113,41 @@ export const auth = betterAuth({
|
||||
before: async (session, context) => {
|
||||
const userId = session.userId;
|
||||
|
||||
const memberships = await db.query.member.findMany({
|
||||
|
||||
let memberships = await db.query.member.findMany({
|
||||
where: eq(drizzleDb.schemas.member.userId, userId),
|
||||
});
|
||||
|
||||
if (!memberships.length) {
|
||||
// Fail the login attempt explicitly
|
||||
throw new Error("User is not part of any organization.");
|
||||
}
|
||||
const defaultOrgSlug = "default";
|
||||
const defaultOrg = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, defaultOrgSlug),
|
||||
});
|
||||
|
||||
const firstOrgId = memberships[0].organizationId;
|
||||
if (!defaultOrg) {
|
||||
throw new Error("No organization found. Cannot assign member.");
|
||||
}
|
||||
|
||||
await db.insert(drizzleDb.schemas.member).values({
|
||||
userId,
|
||||
organizationId: defaultOrg.id,
|
||||
role: "member",
|
||||
});
|
||||
|
||||
memberships = await db.query.member.findMany({
|
||||
where: eq(drizzleDb.schemas.member.userId, userId),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
activeOrganizationId: firstOrgId,
|
||||
activeOrganizationId: memberships[0].organizationId,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
session: {
|
||||
additionalFields: {
|
||||
@@ -277,13 +294,39 @@ export const unlinkAccount = async (provider: string, account: string) => {
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
// export const getOrganization = async ({
|
||||
// organizationId,
|
||||
// organizationSlug,
|
||||
// }: {
|
||||
// organizationId?: string;
|
||||
// organizationSlug?: string;
|
||||
// } = {}) => {
|
||||
// const query =
|
||||
// organizationId != null
|
||||
// ? {organizationId}
|
||||
// : organizationSlug != null
|
||||
// ? {organizationSlug}
|
||||
// : undefined;
|
||||
//
|
||||
// console.log(query);
|
||||
//
|
||||
// try {
|
||||
// return await auth.api.getFullOrganization({
|
||||
// headers: await headers(),
|
||||
// ...(query ? {query} : {}),
|
||||
// });
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
export const getOrganization = async ({
|
||||
organizationId,
|
||||
organizationSlug,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
organizationSlug?: string;
|
||||
} = {}) => {
|
||||
} = {}): Promise<OrganizationWithMembersAndUsers | null> => {
|
||||
const query =
|
||||
organizationId != null
|
||||
? { organizationId }
|
||||
@@ -291,13 +334,13 @@ export const getOrganization = async ({
|
||||
? { organizationSlug }
|
||||
: undefined;
|
||||
|
||||
console.log(query);
|
||||
|
||||
try {
|
||||
return await auth.api.getFullOrganization({
|
||||
const response = await auth.api.getFullOrganization({
|
||||
headers: await headers(),
|
||||
...(query ? { query } : {}),
|
||||
});
|
||||
|
||||
return response as OrganizationWithMembersAndUsers;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
@@ -328,22 +371,63 @@ export const getLastOrganizationOrFirst = async (userId: string) => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const createOrganization = async (name: string, slug: string) => {
|
||||
try {
|
||||
const organization = await auth.api.createOrganization({
|
||||
headers: await headers(),
|
||||
return await auth.api.createOrganization({
|
||||
headers: await headers(),
|
||||
body: {
|
||||
name,
|
||||
slug,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
|
||||
const status = e?.response?.status || 500;
|
||||
|
||||
return organization;
|
||||
} catch (e) {
|
||||
console.log("err", e);
|
||||
console.error("Auth API createOrganization error:", {
|
||||
message: errorMessage,
|
||||
status,
|
||||
raw: e,
|
||||
});
|
||||
|
||||
throw {
|
||||
name: "AuthCreateOrganizationError",
|
||||
message: errorMessage,
|
||||
status,
|
||||
cause: e,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteOrganization = async (organizationId: string) => {
|
||||
try {
|
||||
return await auth.api.deleteOrganization({
|
||||
body: {
|
||||
organizationId,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch (e: any) {
|
||||
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
|
||||
const status = e?.response?.status || 500;
|
||||
|
||||
console.error("Auth API deleteOrganization error:", {
|
||||
message: errorMessage,
|
||||
status,
|
||||
raw: e,
|
||||
});
|
||||
|
||||
throw {
|
||||
name: "AuthDeleteOrganizationError",
|
||||
message: errorMessage,
|
||||
status,
|
||||
cause: e,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
export const checkSlugOrganization = async (slug: string) => {
|
||||
try {
|
||||
const {status} = await auth.api.checkOrganizationSlug({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
|
||||
import { defaultStatements as orgDefaultStatements, adminAc as orgAdminAc } from "better-auth/plugins/organization/access";
|
||||
import { defaultStatements as orgDefaultStatements, adminAc as orgAdminAc, ownerAc as orgOwnerAc } from "better-auth/plugins/organization/access";
|
||||
|
||||
const statement = {
|
||||
...defaultStatements,
|
||||
@@ -18,6 +18,7 @@ const superadmin = ac.newRole({
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
});
|
||||
|
||||
const admin = ac.newRole({
|
||||
@@ -54,6 +55,7 @@ const orgAdmin = ac.newRole({
|
||||
const orgOwner = ac.newRole({
|
||||
project: ["create", "update", "delete"],
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
});
|
||||
|
||||
export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export const slugify = (text: string) => {
|
||||
return text
|
||||
.toString()
|
||||
.normalize('NFKD') // Normalize accents
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\_/g, '-') // _ → dash
|
||||
.replace(/\s+/g, '-') // spaces → dash
|
||||
.replace(/[^\w\-]+/g, '') // Remove non-word chars
|
||||
.replace(/\-\-+/g, '-') // multiple dashes → one
|
||||
.replace(/^-+/, '') // Remove leading dash
|
||||
.replace(/-+$/, ''); // Remove trailing dash
|
||||
};
|
||||