Compare commits

..
51 changed files with 1202 additions and 280 deletions
+37 -9
View File
@@ -1,19 +1,47 @@
import React from "react"; "use client"
import React, {useEffect, useState} from "react";
import {LayoutAdmin} from "@/components/layout"; 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 ( return (
<LayoutAdmin> <div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div <div className="mx-auto w-full max-w-md">
className="w-full h-full grid " <div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
> <Image
<div className="flex items-center justify-center py-12"> className="p-12 text-black dark:text-white"
{children} 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>
<div>{children}</div>
</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>
) )
} }
+12 -9
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import {useEffect, useRef} from "react"; import {useEffect, useRef, useState} from "react";
import {useSearchParams} from "next/navigation"; import {useSearchParams} from "next/navigation";
import {LoginForm} from "@/components/wrappers/auth/login/loginForm/LoginForm"; import {LoginForm} from "@/components/wrappers/auth/login/loginForm/LoginForm";
@@ -10,19 +10,22 @@ export default function SignInPage(props: {
searchParams: Promise<{ callbackUrl: string | undefined }> searchParams: Promise<{ callbackUrl: string | undefined }>
}) { }) {
const searchParams = useSearchParams(); const [urlParams, setUrlParams] = useState<URLSearchParams>();
const error = searchParams.get("error");
const isFirstRender = useRef(true);
useEffect(() => { useEffect(() => {
if (isFirstRender.current && error) { const urlParams = new URLSearchParams(window.location.search);
toast.error("Error occurred."); setUrlParams(urlParams);
isFirstRender.current = false; 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 ( return (
<div className="mx-auto grid w-[350px] gap-6"> <div className="mx-auto grid w-full gap-6">
<LoginForm/> <LoginForm/>
</div> </div>
) )
+1 -1
View File
@@ -3,7 +3,7 @@ import {RegisterForm} from "@/components/wrappers/auth/register/register-form/re
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
return ( return (
<div className="mx-auto grid w-[350px] gap-6"> <div className="mx-auto grid w-full gap-6">
<RegisterForm/> <RegisterForm/>
</div> </div>
) )
@@ -24,8 +24,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
databases: true databases: true
} }
}) })
//
console.log(agent)
if (!agent) { if (!agent) {
notFound() notFound()
@@ -33,6 +32,13 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
// //
// const databaseId = 'db-123'; // const databaseId = 'db-123';
// //
console.log("agent",agent)
// const totalBackupsResult = await db // const totalBackupsResult = await db
// .select({ count: drizzleDb.schemas.backup.id }) // .select({ count: drizzleDb.schemas.backup.id })
// .from(drizzleDb.schemas.backup) // .from(drizzleDb.schemas.backup)
@@ -67,11 +73,6 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
href={`/dashboard/agents/${agent.id}/edit`}> href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/> <GearIcon className="w-7 h-7"/>
</Link> </Link>
{/*<AgentModalKey agent={agent}>*/}
{/* <Button variant="outline">*/}
{/* <KeyRound/>*/}
{/* </Button>*/}
{/*</AgentModalKey>*/}
</PageTitle> </PageTitle>
</div> </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"> <div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl"> <CardHeader className="font-bold text-xl">
Backups Databases
</CardHeader> </CardHeader>
<CardContent></CardContent> <CardContent>{agent.databases.length}</CardContent>
</Card> </Card>
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl"> <CardHeader className="font-bold text-xl">
@@ -102,7 +103,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</Card> </Card>
</div> </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"> <CardHeader className="font-bold text-xl">
Edge Key Edge Key
</CardHeader> </CardHeader>
@@ -110,7 +111,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<AgentCardKey agent={agent}/> <AgentCardKey agent={agent}/>
</CardContent> </CardContent>
</Card> </Card>
<CardsWithPagination data={agent.databases} cardItem={DatabaseCard}/> <CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
</PageContent> </PageContent>
</Page> </Page>
) )
@@ -1,20 +1,29 @@
"use client"; "use client";
import { authClient } from "@/lib/auth/auth-client"; import { authClient } from "@/lib/auth/auth-client";
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageHeader, PageTitle} from "@/features/layout/page";
export default function RoutePage(props: PageParams<{}>) { export default function RoutePage(props: PageParams<{}>) {
const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: activeOrganization } = authClient.useActiveOrganization();
return ( return (
<div className="flex flex-1 flex-col gap-4 p-4"> <Page>
<div className="grid auto-rows-min gap-4 md:grid-cols-3"> <PageHeader>
<div className="aspect-video rounded-xl bg-muted/50" /> <PageTitle>Dashboard</PageTitle>
<div className="aspect-video rounded-xl bg-muted/50" /> </PageHeader>
<div className="aspect-video rounded-xl bg-muted/50" />
<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>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
{JSON.stringify(activeOrganization)} </Page>
{activeOrganization?.name}
</div>
); );
} }
@@ -41,6 +41,7 @@ export default async function RoutePage(props: PageParams<{
if (!proj) notFound(); if (!proj) notFound();
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
@@ -62,6 +63,7 @@ export default async function RoutePage(props: PageParams<{
<CardsWithPagination <CardsWithPagination
data={proj.databases} data={proj.databases}
organizationSlug={organization.slug} organizationSlug={organization.slug}
// @ts-ignore
cardItem={ProjectDatabaseCard} cardItem={ProjectDatabaseCard}
cardsPerPage={4} cardsPerPage={4}
numberOfColumns={1} numberOfColumns={1}
@@ -4,13 +4,12 @@ import {notFound} from "next/navigation";
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm"; import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm";
import {getOrganization} from "@/lib/auth/auth"; import {getOrganization} from "@/lib/auth/auth";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
}>) { }>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
if (!organization) { if (!organization || organization.slug == "default") {
notFound(); notFound();
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import React from "react";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"; 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 {Header} from "@/features/layout/Header";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
+1 -1
View File
@@ -6,7 +6,7 @@ export default async function Index() {
const user = await currentUser(); const user = await currentUser();
if (user) { if (user) {
const currentOrganizationSlug = await getCurrentOrganizationSlug(); const currentOrganizationSlug = await getCurrentOrganizationSlug();
redirect(`/dashboard/${currentOrganizationSlug}/projects`); redirect(`/dashboard/home`);
} }
redirect("/login"); redirect("/login");
//Do not delete //Do not delete
+1
View File
@@ -88,6 +88,7 @@ RUN chown -R nextjs:nodejs /app/public
COPY --from=builder /app/next.config.ts ./ COPY --from=builder /app/next.config.ts ./
COPY --from=builder /app/portabase.config.ts ./
COPY --from=builder /app/drizzle.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/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+13 -1
View File
@@ -3,9 +3,11 @@ import { loggingMiddleware } from "@/middleware/loggingMiddleware";
import { errorHandler } from "@/middleware/errorHandler"; import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { signOut } from "@/lib/auth/auth-client";
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
if (url.pathname.startsWith("/dashboard")) { if (url.pathname.startsWith("/dashboard")) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
@@ -13,7 +15,17 @@ export async function middleware(request: NextRequest) {
}); });
if (!session) { 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(); return NextResponse.next();
+71 -1
View File
@@ -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 = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
@@ -14,6 +50,40 @@ const nextConfig: NextConfig = {
experimental: { experimental: {
nodeMiddleware: true, 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; export default nextConfig;
+60
View File
@@ -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
},
},
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

@@ -48,7 +48,7 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
))} ))}
</div> </div>
<PaginationNavigation <PaginationNavigation
className="justify-end" className="justify-end mt-4"
totalPages={totalPages} totalPages={totalPages}
currentPage={currentPage} currentPage={currentPage}
goToPage={goToPage} goToPage={goToPage}
@@ -7,11 +7,13 @@ export type ConnectionCircleProps = {
export const ConnectionCircle = ({ date }: ConnectionCircleProps) => { export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
let style = "bg-gray-300 border-gray-400"; let style = "bg-gray-300 border-gray-400";
if (date) { if (date instanceof Date && !isNaN(date.getTime())) {
const now = Date.now(); const now = Date.now();
const timestamp = new Date(date).getTime(); const timestamp = date.getTime();
const interval = now - timestamp; const interval = now - timestamp;
console.log({ now, timestamp, interval });
if (interval < 10000) { if (interval < 10000) {
style = "bg-green-400 border-green-600"; style = "bg-green-400 border-green-600";
} else if (interval <= 20000) { } else if (interval <= 20000) {
@@ -19,7 +21,11 @@ export const ConnectionCircle = ({ date }: ConnectionCircleProps) => {
} else { } else {
style = "bg-red-400 border-red-600"; 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)} />; 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; const { data: agent } = props;
return ( 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"> <Card className="flex flex-row justify-between">
<div className=""> <div>
<CardHeader>{agent.name}</CardHeader> <CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
<CardContent>Last contact : {formatDateLastContact(agent.lastContact)}</CardContent> <CardContent>Last contact: {formatDateLastContact(agent.lastContact)}</CardContent>
</div> </div>
<div className="mt-3 mr-3"> <div className="flex items-center px-4">
<ConnectionCircle date={agent.lastContact} /> <ConnectionCircle date={agent.lastContact} />
</div> </div>
</Card> </Card>
@@ -1,16 +1,24 @@
"use client"; "use client";
import { Card, CardContent } from "@/components/ui/card"; import {Card, CardContent} from "@/components/ui/card";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; import {
import { Input } from "@/components/ui/input"; FormControl,
import { Form } from "@/components/ui/form"; FormDescription,
import { Button } from "@/components/ui/button"; FormField,
import { useRouter } from "next/navigation"; FormItem,
import { useMutation } from "@tanstack/react-query"; FormLabel,
import { TooltipProvider } from "@/components/ui/tooltip"; FormMessage,
import { AgentSchema, AgentType } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema"; useZodForm
import { toast } from "sonner"; } from "@/components/ui/form";
import { createAgentAction, updateAgentAction } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.action"; 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 = { export type agentFormProps = {
defaultValues?: AgentType; defaultValues?: AgentType;
@@ -29,14 +37,13 @@ export const AgentForm = (props: agentFormProps) => {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: AgentType) => { mutationFn: async (values: AgentType) => {
console.log("values", values);
const createAgent = isCreate const createAgent = isCreate
? await createAgentAction(values) ? await createAgentAction(values)
: await updateAgentAction({ : await updateAgentAction({
id: props.agentId ?? "-", id: props.agentId ?? "-",
data: values, data: values,
}); });
const data = createAgent?.data?.data; const data = createAgent?.data?.data;
if (createAgent?.serverError || !data) { if (createAgent?.serverError || !data) {
@@ -44,7 +51,7 @@ export const AgentForm = (props: agentFormProps) => {
toast.error(createAgent?.serverError); toast.error(createAgent?.serverError);
return; return;
} }
toast.success(`Success`); toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
router.push(`/dashboard/agents/${data.id}`); router.push(`/dashboard/agents/${data.id}`);
router.refresh(); router.refresh();
}, },
@@ -65,37 +72,14 @@ export const AgentForm = (props: agentFormProps) => {
control={form.control} control={form.control}
name="name" name="name"
defaultValue="" defaultValue=""
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="Agent 1" {...field} /> <Input placeholder="Agent 1" {...field} />
</FormControl> </FormControl>
<FormDescription>Your agent project name</FormDescription> <FormDescription>Your agent project name</FormDescription>
<FormMessage /> <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> </FormItem>
)} )}
/> />
@@ -103,14 +87,15 @@ export const AgentForm = (props: agentFormProps) => {
control={form.control} control={form.control}
defaultValue="" defaultValue=""
name="description" name="description"
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>Description</FormLabel> <FormLabel>Description</FormLabel>
<FormControl> <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> </FormControl>
<FormDescription>Enter your project agent description</FormDescription> <FormDescription>Enter your project agent description</FormDescription>
<FormMessage /> <FormMessage/>
</FormItem> </FormItem>
)} )}
/> />
@@ -1,26 +1,26 @@
"use server"; "use server";
import { ActionError, userAction } from "@/safe-actions"; import {ActionError, userAction} from "@/safe-actions";
import { AgentSchema } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema"; import {AgentSchema} from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema";
import { z } from "zod"; import {z} from "zod";
import { eq, and, ne, count } from "drizzle-orm"; import {eq, and, ne, count} from "drizzle-orm";
import { db } from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {slugify} from "@/utils/slugify";
const verifySlugUniqueness = async (slug: string, agentId?: string) => { 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) { if (countResult.count > 0) {
throw new ActionError("Slug already exists"); throw new ActionError("Slug already exists");
} }
}; };
export const createAgentAction = userAction.schema(AgentSchema).action(async ({ parsedInput }) => { export const createAgentAction = userAction.schema(AgentSchema).action(async ({parsedInput}) => {
await verifySlugUniqueness(parsedInput.slug); const slug = slugify(parsedInput.name);
await verifySlugUniqueness(slug);
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values(parsedInput).returning(); const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
return { return {
data: createdAgent, data: createdAgent,
}; };
@@ -33,10 +33,14 @@ export const updateAgentAction = userAction
data: AgentSchema, data: AgentSchema,
}) })
) )
.action(async ({ parsedInput }) => { .action(async ({parsedInput}) => {
await verifySlugUniqueness(parsedInput.data.slug, parsedInput.id); 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 { return {
data: updatedAgent, data: updatedAgent,
@@ -2,11 +2,6 @@ import { z } from "zod";
export const AgentSchema = z.object({ export const AgentSchema = z.object({
name: z.string(), name: z.string(),
slug: z
.string()
.regex(/^[a-zA-Z0-9_-]*$/)
.min(5)
.max(25),
description: z.string(), 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 { setCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import {authClient} from "@/lib/auth/auth-client";
export type DeleteOrganizationButtonProps = { export type DeleteOrganizationButtonProps = {
organizationSlug: string; organizationSlug: string;
@@ -14,15 +15,26 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
const router = useRouter(); const router = useRouter();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => deleteOrganizationAction(props.organizationSlug), mutationFn: () => deleteOrganizationAction(props.organizationSlug),
onSuccess: async (result) => { onSuccess: async (result) => {
if (result.data.success) { if (result?.data?.success) {
await setCurrentOrganizationSlug("default"); await authClient.organization.setActive({
organizationSlug: "default",
});
router.push("/"); router.push("/");
toast.success(result.data.actionSuccess.message);
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
} else { } 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 ( return (
@@ -9,34 +9,35 @@ import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select"; import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
import { OrganizationFormSchema, OrganizationFormType } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema"; import { OrganizationFormSchema, OrganizationFormType } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
import { updateOrganizationAction } from "@/components/wrappers/dashboard/organization/organization.action"; import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
import { toast } from "sonner";
import {Member, Organization} from "better-auth/plugins";
export type organizationFormProps = { export type organizationFormProps = {
defaultValues?: Organization; defaultValues?: OrganizationWithMembers;
members: Member[]; members: MemberWithUser[];
}; };
export const OrganizationForm = (props: organizationFormProps) => { export const OrganizationForm = (props: organizationFormProps) => {
const router = useRouter(); const router = useRouter();
const isCreate = !Boolean(props.defaultValues); const isCreate = !Boolean(props.defaultValues);
const formatUsersList = (members: Member[]) => { const formatUsersList = (members: MemberWithUser[]) => {
return members.map((member) => ({ return members.map((member) => ({
value: member.id, value: member.user.id,
label: `${member.user.name} | ${member.user.email}`, label: `${member.user.name} | ${member.user.email}`,
})); }));
}; };
const formatDefaultUsers = (members: OrganizationFormType["members"]): string[] => { const formatDefaultUsers = (members: MemberWithUser[]): string[] => {
console.log(members); console.log("ici",members);
return members.map((member) => member.userId); return members.map((member) => member.userId);
}; };
const formattedDefaultValues = { const formattedDefaultValues = {
...props.defaultValues, name: props.defaultValues?.name,
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members) : [], slug: props.defaultValues?.slug,
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members as MemberWithUser[]) : [],
}; };
const form = useZodForm({ const form = useZodForm({
@@ -59,6 +60,8 @@ export const OrganizationForm = (props: organizationFormProps) => {
}, },
}); });
console.log(formatUsersList(props.members))
return ( return (
<Card> <Card>
<CardHeader></CardHeader> <CardHeader></CardHeader>
@@ -110,13 +113,13 @@ export const OrganizationForm = (props: organizationFormProps) => {
name="users" name="users"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Databases</FormLabel> <FormLabel>Users</FormLabel>
<FormControl> <FormControl>
<MultiSelect <MultiSelect
options={formatUsersList(props.members)} options={formatUsersList(props.members)}
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value ?? []} defaultValue={field.value ?? []}
placeholder="Select databases" placeholder="Select users"
variant="inverted" variant="inverted"
animation={2} animation={2}
// maxCount={100} // maxCount={100}
@@ -11,6 +11,7 @@ import {createOrganizationAction} from "@/components/wrappers/dashboard/organiza
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {useState} from "react"; import {useState} from "react";
import {authClient} from "@/lib/auth/auth-client"; import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
export type createOrganizationModalProps = { export type createOrganizationModalProps = {
open: boolean; open: boolean;
@@ -35,15 +36,19 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
const result = await createOrganizationAction(values); const result = await createOrganizationAction(values);
if (result && result.data) { if (result?.data?.success && result.data.value) {
if (result.data.success && result.data.value) { onOpenChange(false);
onOpenChange(false); await authClient.organization.setActive({organizationSlug: result.data.value.slug});
await authClient.organization.setActive({organizationSlug: result.data.value.slug}); onSuccess?.();
onSuccess?.(); toast.success(result.data.actionSuccess?.message || "Organization Created.");
router.replace(`/dashboard/home`); // router.push("/");
// router.refresh(); 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> </FormItem>
)} )}
/> />
<FormField {/*<FormField*/}
control={form.control} {/* control={form.control}*/}
name="slug" {/* name="slug"*/}
defaultValue="" {/* defaultValue=""*/}
render={({field}) => ( {/* render={({field}) => (*/}
<FormItem> {/* <FormItem>*/}
<FormLabel>Slug</FormLabel> {/* <FormLabel>Slug</FormLabel>*/}
<FormControl> {/* <FormControl>*/}
<Input {/* <Input*/}
{...field} {/* {...field}*/}
onChange={(e) => { {/* onChange={(e) => {*/}
const value = e.target.value.replaceAll(" ", "-").toLowerCase(); {/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
field.onChange(value); {/* field.onChange(value);*/}
}} {/* }}*/}
/> {/* />*/}
</FormControl> {/* </FormControl>*/}
<FormMessage/> {/* <FormMessage/>*/}
</FormItem> {/* </FormItem>*/}
)} {/* )}*/}
/> {/*/>*/}
<DialogFooter> <DialogFooter>
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<Button type="submit">Create</Button> <Button type="submit">Create</Button>
@@ -7,13 +7,16 @@ import { z } from "zod";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { OrganizationFormSchema } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema"; import { OrganizationFormSchema } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
import { db } from "@/db"; import { db } from "@/db";
import { Organization, organization as drizzleOrganization, organizationMember as drizzleOrganizationMember } from "@/db/schema";
import { eq } from "drizzle-orm"; 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>> => { export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({ parsedInput }): Promise<ServerActionResult<Organization>> => {
try { try {
if (!checkSlugOrganization(parsedInput.slug)) { const slug = slugify(parsedInput.name);
if (!await checkSlugOrganization(slug)) {
return { return {
success: false, success: false,
actionError: { 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 { return {
success: true, success: true,
value: organization!, value: createdOrganization,
actionSuccess: { actionSuccess: {
message: "Organization has been successfully created.", message: "Organization has been successfully created.",
messageParams: { organizationId: organization!.id }, messageParams: { organizationId: createdOrganization!.id },
}, },
}; };
} catch (error) { } catch (error) {
@@ -58,7 +76,7 @@ export const updateOrganizationAction = userAction
try { try {
const newUserList = parsedInput.data.users; 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) { if (organization.length === 0) {
throw new Error("Organization not found."); throw new Error("Organization not found.");
@@ -70,7 +88,7 @@ export const updateOrganizationAction = userAction
if (usersToAdd.length > 0) { if (usersToAdd.length > 0) {
await db await db
.insert(userOrganization) .insert(drizzleDb.schemas.organization)
.values( .values(
usersToAdd.map((userId) => ({ usersToAdd.map((userId) => ({
userId, userId,
@@ -117,44 +135,60 @@ export const updateOrganizationAction = userAction
} }
}); });
export const deleteOrganizationAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }): Promise<ServerActionResult<Organization>> => { export const deleteOrganizationAction = userAction.schema(z.string()).action(
try { async ({ parsedInput, ctx }): Promise<ServerActionResult<Organization>> => {
const uuid = uuidv4(); try {
const organization = await db.select().from(organization).where(eq(organization.slug, parsedInput)).execute(); const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
});
if (organization.length === 0) { if (!org) {
throw new Error("Organization not found."); 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({ 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"), 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 // slug: z
.string() // .string()
.regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens") // .regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens")
.min(5, "Slug must be at least 5 characters long") // .min(5, "Slug must be at least 5 characters long")
.max(20, "Slug must be at most 20 characters long"), // .max(20, "Slug must be at most 20 characters long"),
}); });
export type OrganizationSchema = z.infer<typeof OrganizationSchema>; export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
@@ -41,13 +41,10 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
</TabsList> </TabsList>
<TabsContent className="h-full justify-between" value="backup"> <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 /> <DataTable columns={backupColumns(props.isAlreadyRestore)} data={props.backups} enablePagination />
</TabsContent> </TabsContent>
<TabsContent className="h-full justify-between" value="restore"> <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> </TabsContent>
</Tabs> </Tabs>
); );
@@ -13,10 +13,10 @@ export const ProjectCard = (props: projectCardProps) => {
const { data: project, organizationSlug } = props; const { data: project, organizationSlug } = props;
return ( 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"> <Card className="flex flex-row justify-between">
<div className=""> <div className="">
<CardHeader>{project.name}</CardHeader> <CardHeader className="text-2xl font-bold">{project.name}</CardHeader>
<CardContent>{project.databases.length} databases</CardContent> <CardContent>{project.databases.length} databases</CardContent>
</div> </div>
</Card> </Card>
@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle"; import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
import { formatDateLastContact } from "@/utils/date-formatting"; import { formatDateLastContact } from "@/utils/date-formatting";
import {Database} from "@/db/schema/06_database"; import {Database} from "@/db/schema/06_database";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
export type projectDatabaseCardProps = { export type projectDatabaseCardProps = {
data: Database; data: Database;
@@ -17,7 +18,7 @@ export const ProjectDatabaseCard = (props: projectDatabaseCardProps) => {
const { organizationSlug, data: database, extendedProps: extendedProps } = props; const { organizationSlug, data: database, extendedProps: extendedProps } = props;
return ( 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} /> <DatabaseCard data={database} />
</Link> </Link>
); );
@@ -31,15 +32,21 @@ export const DatabaseCard = (props: databaseCardProps) => {
const { data: database } = props; const { data: database } = props;
return ( return (
<Card className="flex flex-row justify-between"> <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" /> <Image src="/PostgreSQL.png" alt="Database type Icon" width={60} height={60} className="object-cover ml-4" />
<div>
<CardHeader>Name : {database.name}</CardHeader> <div className="justify-between">
<CardContent>Last contact: {formatDateLastContact(database.lastContact)}</CardContent> <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> </div>
<div className="mt-3 mr-3">
<div className="flex items-center px-4">
<ConnectionCircle date={database.lastContact} /> <ConnectionCircle date={database.lastContact} />
</div> </div>
</Card> </Card>
@@ -2,7 +2,6 @@ import { z } from "zod";
export const ProjectSchema = z.object({ export const ProjectSchema = z.object({
name: z.string(), name: z.string(),
slug: z.string(),
databases: z.array(z.string()), databases: z.array(z.string()),
}); });
@@ -103,27 +103,6 @@ export const ProjectForm = (props: projectFormProps) => {
</FormItem> </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 <FormField
control={form.control} control={form.control}
name="databases" name="databases"
@@ -9,6 +9,7 @@ import { eq, inArray } from "drizzle-orm";
import {Project} from "@/db/schema/05_project"; import {Project} from "@/db/schema/05_project";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {Database} from "@/db/schema/06_database"; import {Database} from "@/db/schema/06_database";
import {slugify} from "@/utils/slugify";
export const createProjectAction = userAction export const createProjectAction = userAction
.schema( .schema(
@@ -19,11 +20,12 @@ export const createProjectAction = userAction
) )
.action(async ({ parsedInput }): Promise<ServerActionResult<Project>> => { .action(async ({ parsedInput }): Promise<ServerActionResult<Project>> => {
try { try {
const slug = slugify(parsedInput.data.name);
const [createdProject] = await db const [createdProject] = await db
.insert(drizzleDb.schemas.project) .insert(drizzleDb.schemas.project)
.values({ .values({
name: parsedInput.data.name, name: parsedInput.data.name,
slug: parsedInput.data.slug, slug: slug,
organizationId: parsedInput.organizationId, organizationId: parsedInput.organizationId,
}) })
.returning(); .returning();
@@ -87,12 +89,13 @@ export const updateProjectAction = userAction
if (databasesToRemove.length > 0) { if (databasesToRemove.length > 0) {
await db.update(drizzleDb.schemas.database).set({ projectId: null }).where(inArray(drizzleDb.schemas.database.id, databasesToRemove)); 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 const [updatedProject] = await db
.update(drizzleDb.schemas.project) .update(drizzleDb.schemas.project)
.set({ .set({
name: parsedInput.data.name, name: parsedInput.data.name,
slug: parsedInput.data.slug, slug: slug,
}) })
.where(eq(drizzleDb.schemas.project.id, parsedInput.projectId)) .where(eq(drizzleDb.schemas.project.id, parsedInput.projectId))
.returning(); .returning();
@@ -23,9 +23,9 @@ export async function AppSidebar() {
return notFound(); 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 ( return (
+15 -2
View File
@@ -3,8 +3,9 @@ import { relations } from "drizzle-orm";
import { project } from "./05_project"; import { project } from "./05_project";
import { createSelectSchema } from "drizzle-zod"; import { createSelectSchema } from "drizzle-zod";
import { z } from "zod"; import { z } from "zod";
import {invitation} from "@/db/schema/04_invitation"; import {invitation, OrganizationInvitation} from "@/db/schema/04_invitation";
import {member} from "@/db/schema/03_member"; import {member, OrganizationMember} from "@/db/schema/03_member";
import {User} from "@/db/schema/01_user";
export const organization = pgTable("organization", { export const organization = pgTable("organization", {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
@@ -28,3 +29,15 @@ export const organizationRelations = relations(organization, ({ many }) => ({
export const organizationSchema = createSelectSchema(organization); export const organizationSchema = createSelectSchema(organization);
export type Organization = z.infer<typeof organizationSchema>; 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[];
};
+96 -6
View File
@@ -1,15 +1,33 @@
"use client"; "use client";
import { ColumnDef } from "@tanstack/react-table"; 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 { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react"; import {MoreHorizontal, Trash2} from "lucide-react";
import { ReloadIcon } from "@radix-ui/react-icons"; import { ReloadIcon } from "@radix-ui/react-icons";
import { StatusBadge } from "@/components/wrappers/common/status-badge"; 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 {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", accessorKey: "id",
header: "Reference", header: "Reference",
@@ -31,6 +49,58 @@ export const restoreColumns: ColumnDef<Restoration>[] = [
{ {
id: "actions", id: "actions",
cell: ({ row }) => { 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 ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -42,8 +112,26 @@ export const restoreColumns: ColumnDef<Restoration>[] = [
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => {}}> <TooltipCustom disabled={isAlreadyRestore} text="Already a restoration waiting">
<ReloadIcon /> Rerun <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> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </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 { and, eq } from "drizzle-orm";
import {Backup, Restoration} from "@/db/schema/06_database"; 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 export const deleteBackupAction = userAction
.schema( .schema(
z.object({ 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) // Create Restoration Action (Drizzle version)
export const createRestorationAction = userAction export const createRestorationAction = userAction
.schema( .schema(
+104 -20
View File
@@ -1,13 +1,14 @@
import {betterAuth} from "better-auth"; import {betterAuth} from "better-auth";
import {drizzleAdapter} from "better-auth/adapters/drizzle"; import {drizzleAdapter} from "better-auth/adapters/drizzle";
import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {env} from "@/env.mjs"; import {env} from "@/env.mjs";
import {nextCookies} from "better-auth/next-js"; import {nextCookies} from "better-auth/next-js";
import {admin as adminPlugin, openAPI, organization} from "better-auth/plugins"; import {admin as adminPlugin, openAPI, organization} from "better-auth/plugins";
import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions"; import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions";
import * as drizzleDb from "@/db";
import {headers} from "next/headers"; import {headers} from "next/headers";
import {count, eq} from "drizzle-orm"; import {count, eq} from "drizzle-orm";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/02_organization";
export const auth = betterAuth({ export const auth = betterAuth({
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
@@ -40,9 +41,9 @@ export const auth = betterAuth({
organization({ organization({
ac, ac,
roles: { roles: {
orgOwner, owner: orgOwner,
orgAdmin, admin: orgAdmin,
orgMember, member: orgMember,
}, },
}), }),
adminPlugin({ adminPlugin({
@@ -98,7 +99,7 @@ export const auth = betterAuth({
await db.insert(drizzleDb.schemas.member).values({ await db.insert(drizzleDb.schemas.member).values({
userId: user.id, userId: user.id,
organizationId: defaultOrg.id, organizationId: defaultOrg.id,
role: "owner", role: "orgOwner",
}); });
} else { } else {
console.warn("Default organization not found. Cannot assign member."); console.warn("Default organization not found. Cannot assign member.");
@@ -112,25 +113,41 @@ export const auth = betterAuth({
before: async (session, context) => { before: async (session, context) => {
const userId = session.userId; 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), where: eq(drizzleDb.schemas.member.userId, userId),
}); });
if (!memberships.length) { if (!memberships.length) {
// Fail the login attempt explicitly const defaultOrgSlug = "default";
throw new Error("User is not part of any organization."); 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 { return {
data: { data: {
activeOrganizationId: firstOrgId, activeOrganizationId: memberships[0].organizationId,
}, },
}; };
}, },
}, },
}, },
}, },
session: { session: {
additionalFields: { additionalFields: {
@@ -277,13 +294,39 @@ export const unlinkAccount = async (provider: string, account: string) => {
// return null; // 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 ({ export const getOrganization = async ({
organizationId, organizationId,
organizationSlug, organizationSlug,
}: { }: {
organizationId?: string; organizationId?: string;
organizationSlug?: string; organizationSlug?: string;
} = {}) => { } = {}): Promise<OrganizationWithMembersAndUsers | null> => {
const query = const query =
organizationId != null organizationId != null
? { organizationId } ? { organizationId }
@@ -291,13 +334,13 @@ export const getOrganization = async ({
? { organizationSlug } ? { organizationSlug }
: undefined; : undefined;
console.log(query);
try { try {
return await auth.api.getFullOrganization({ const response = await auth.api.getFullOrganization({
headers: await headers(), headers: await headers(),
...(query ? { query } : {}), ...(query ? { query } : {}),
}); });
return response as OrganizationWithMembersAndUsers;
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return null; return null;
@@ -328,22 +371,63 @@ export const getLastOrganizationOrFirst = async (userId: string) => {
return null; return null;
} }
}; };
export const createOrganization = async (name: string, slug: string) => { export const createOrganization = async (name: string, slug: string) => {
try { try {
const organization = await auth.api.createOrganization({ return await auth.api.createOrganization({
headers: await headers(), headers: await headers(),
body: { body: {
name, name,
slug, slug,
}, },
}); });
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
return organization; console.error("Auth API createOrganization error:", {
} catch (e) { message: errorMessage,
console.log("err", e); 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) => { export const checkSlugOrganization = async (slug: string) => {
try { try {
const {status} = await auth.api.checkOrganizationSlug({ const {status} = await auth.api.checkOrganizationSlug({
+3 -1
View File
@@ -1,6 +1,6 @@
import { createAccessControl } from "better-auth/plugins/access"; import { createAccessControl } from "better-auth/plugins/access";
import { defaultStatements, adminAc } from "better-auth/plugins/admin/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 = { const statement = {
...defaultStatements, ...defaultStatements,
@@ -18,6 +18,7 @@ const superadmin = ac.newRole({
agent: ["create", "list", "update", "delete"], agent: ["create", "list", "update", "delete"],
...adminAc.statements, ...adminAc.statements,
...orgAdminAc.statements, ...orgAdminAc.statements,
...orgOwnerAc.statements,
}); });
const admin = ac.newRole({ const admin = ac.newRole({
@@ -54,6 +55,7 @@ const orgAdmin = ac.newRole({
const orgOwner = ac.newRole({ const orgOwner = ac.newRole({
project: ["create", "update", "delete"], project: ["create", "update", "delete"],
...orgAdminAc.statements, ...orgAdminAc.statements,
...orgOwnerAc.statements,
}); });
export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner }; export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner };
+14
View File
@@ -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
};