Compare commits

..
152 changed files with 3886 additions and 1679 deletions
+22 -1
View File
@@ -1,4 +1,25 @@
# Ignore Docker-related configs
.dockerignore
docker-compose.yml docker-compose.yml
README.md docker-compose*.yml
# Development/Editor config
.idea .idea
.vscode
# Node-related
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
# Next.js build output
.next .next
out
# Docs and markdowns
README.md
*.md
Binary file not shown.
+4 -2
View File
@@ -1,2 +1,4 @@
migrate: CLUSTER_SCRIPT=docker/entrypoints/app-dev-entrypoint.sh
docker compose run --rm app
up:
@bash $(CLUSTER_SCRIPT)
+5 -1
View File
@@ -1,7 +1,7 @@
<br /> <br />
<div align="center"> <div align="center">
<a href="https://portabase.io"> <a href="https://portabase.io">
<img src="images/logo.png" alt="Logo" width="80" height="80"> <img src="/public/images/logo.png" alt="Logo" width="80" height="80">
</a> </a>
<h3 align="center">Portabase</h3> <h3 align="center">Portabase</h3>
@@ -18,8 +18,12 @@
· ·
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a> <a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
</p> </p>
<iframe width="560" height="315" src="https://www.youtube.com/watch?v=D9uFrGxLc4s" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div> </div>
## 📚 Table of Contents ## 📚 Table of Contents
- [About The Project](#about-the-project) - [About The Project](#about-the-project)
+47 -9
View File
@@ -1,19 +1,57 @@
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";
import {useSession} from "@/lib/auth/auth-client";
import {useRouter} from "next/navigation";
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);
const router = useRouter();
const { data: session } = useSession();
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
router.replace("/dashboard/home");
}
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">
> <img
<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>
<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> </div>
</LayoutAdmin>
) )
} }
+13 -10
View File
@@ -1,28 +1,31 @@
"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/login-form/login-form";
import {toast} from "sonner"; import {toast} from "sonner";
export default function SignInPage(props: { 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>
) )
@@ -1,21 +1,15 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import * as drizzleDb from "@/db";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page"; import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AdminTabs } from "@/components/wrappers/dashboard/admin/admin-tabs"; import { AdminTabs } from "@/components/wrappers/dashboard/admin/admin-tabs";
import { currentUser } from "@/lib/auth/current-user";
import { db } from "@/db"; import { db } from "@/db";
import {eq, isNotNull, isNull} from "drizzle-orm"; import {isNull} from "drizzle-orm";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
const users = await db.query.user.findMany({ const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt) where: (fields) => isNull(fields.deletedAt)
}); });
// const users = await db.query.user.findMany();
console.log("users",users);
const settings = await db.query.setting.findFirst({ const settings = await db.query.setting.findFirst({
where: (fields, { eq }) => eq(fields.name, "system"), where: (fields, { eq }) => eq(fields.name, "system"),
@@ -26,7 +20,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageHeader> <PageHeader>
<PageTitle>Administration Panel</PageTitle> <PageTitle>Administration Panel</PageTitle>
</PageHeader> </PageHeader>
<PageContent className="mt-10"> <PageContent>
<AdminTabs settings={settings!} users={users} /> <AdminTabs settings={settings!} users={users} />
</PageContent> </PageContent>
</Page> </Page>
@@ -1,6 +1,6 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page"; import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/AgentForm/AgentForm"; import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { db } from "@/db"; import { db } from "@/db";
@@ -1,17 +1,21 @@
import Link from "next/link"; import Link from "next/link";
import {GearIcon} from "@radix-ui/react-icons"; import {GearIcon} from "@radix-ui/react-icons";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {formatDateLastContact} from "@/utils/date-formatting"; import {formatDateLastContact} from "@/utils/date-formatting";
import {buttonVariants} from "@/components/ui/button"; import {buttonVariants} from "@/components/ui/button";
import {Card, CardContent, CardHeader} from "@/components/ui/card"; import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectDatabaseCard"; import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/AgentCardKey/AgentCardKey"; import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {
ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
export default async function RoutePage(props: PageParams<{ agentId: string }>) { export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -24,8 +28,8 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
databases: true databases: true
} }
}) })
//
console.log(agent)
if (!agent) { if (!agent) {
notFound() notFound()
@@ -33,6 +37,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,22 +78,19 @@ 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>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
</PageActions>
</div> </div>
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
<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 +110,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 +118,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,6 +1,6 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page"; import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/AgentForm/AgentForm"; import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
return ( return (
@@ -1,15 +1,22 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import { AgentCard } from "@/components/wrappers/dashboard/agent/AgentCard/AgentCard"; import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination"; import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Link from "next/link"; import Link from "next/link";
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page"; import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, not} from "drizzle-orm";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany();
const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true))
});
if (!agents) { if (!agents) {
notFound(); notFound();
@@ -27,7 +34,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</PageActions> </PageActions>
)} )}
</PageHeader> </PageHeader>
<PageContent className="mt-10"> <PageContent>
{agents.length > 0 ? ( {agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} /> <CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
) : ( ) : (
@@ -1,7 +1,7 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {RestoreForm} from "@/components/wrappers/dashboard/database/RestoreForm"; import {RestoreForm} from "@/components/wrappers/dashboard/database/restore-form";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {db} from "@/db"; import {db} from "@/db";
@@ -1,20 +0,0 @@
"use client";
import { authClient } from "@/lib/auth/auth-client";
import { PageParams } from "@/types/next";
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" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
{JSON.stringify(activeOrganization)}
{activeOrganization?.name}
</div>
);
}
@@ -1,7 +1,7 @@
import { PageParams } from "@/types/next"; import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page"; import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { DatabaseForm } from "@/components/wrappers/dashboard/database/DatabaseForm/DatabaseForm"; import { DatabaseForm } from "@/components/wrappers/dashboard/database/database-form/database-form";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
@@ -2,10 +2,10 @@ import {PageParams} from "@/types/next";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button"; import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/Database/DatabaseTabs"; import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/Database/DatabaseKpi"; import {DatabaseKpi} from "@/components/wrappers/dashboard/projects/database/database-kpi";
import {EditButton} from "@/components/wrappers/dashboard/database/EditButton/EditButton"; import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/edit-button";
import {CronButton} from "@/components/wrappers/dashboard/database/CronButton/CronButton"; import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db"; import {db} from "@/db";
import {eq, and} from "drizzle-orm"; import {eq, and} from "drizzle-orm";
@@ -1,7 +1,7 @@
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/ProjectsForm/ProjectForm"; import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
@@ -3,9 +3,9 @@ import { Page, PageActions, PageContent, PageDescription, PageTitle } from "@/fe
import { buttonVariants } from "@/components/ui/button"; import { buttonVariants } from "@/components/ui/button";
import { GearIcon } from "@radix-ui/react-icons"; import { GearIcon } from "@radix-ui/react-icons";
import Link from "next/link"; import Link from "next/link";
import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/ButtonDeleteProject/ButtonDeleteProject"; import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination"; import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectDatabaseCard"; import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { db } from "@/db"; import { db } from "@/db";
@@ -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,12 +63,18 @@ 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}
extendedProps={proj} extendedProps={proj}
/> />
) : null} ) : (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
<p className="text-lg font-medium">No databases found</p>
<p className="text-sm mt-2">You havent added any databases to this project yet.</p>
</div>
)}
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -1,6 +1,6 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectForm} from "@/components/wrappers/dashboard/projects/ProjectsForm/ProjectForm"; import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
@@ -4,7 +4,7 @@ import {PageParams} from "@/types/next";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {ProjectCard} from "@/components/wrappers/dashboard/projects/ProjectCard/ProjectCard"; import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
import {db} from "@/db"; import {db} from "@/db";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth"; import {getOrganization} from "@/lib/auth/auth";
@@ -41,7 +41,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
)} )}
</PageHeader> </PageHeader>
<PageContent className="mt-10"> <PageContent>
{projects.length > 0 ? ( {projects.length > 0 ? (
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard} <CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
cardsPerPage={4} numberOfColumns={1}/> cardsPerPage={4} numberOfColumns={1}/>
@@ -1,16 +1,25 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm"; import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
import {getOrganization} from "@/lib/auth/auth"; import {getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {isNull} from "drizzle-orm";
import {currentUser} from "@/lib/auth/current-user";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
}>) { }>) {
const user = await currentUser();
const organization = await getOrganization({}); const organization = await getOrganization({});
if (!organization) { const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
});
if (!user || !users || !organization || organization.slug == "default") {
notFound(); notFound();
} }
@@ -22,7 +31,7 @@ export default async function RoutePage(props: PageParams<{
</PageTitle> </PageTitle>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<OrganizationForm members={organization.members} defaultValues={organization}/> <OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
</PageContent> </PageContent>
</Page> </Page>
) )
@@ -1,42 +1,54 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageActions, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import { import {
DeleteOrganizationButton DeleteOrganizationButton
} from "@/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton"; } from "@/components/wrappers/dashboard/organization/delete-organization/delete-organization-button";
import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/EditButtonSettings/EditButtonSettings"; import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-button-settings/edit-button-settings";
import {
SettingsOrganizationMembersTable
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
export default async function RoutePage(props: PageParams<{ slug: string }>) { export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
const user = await currentUser(); const user = await currentUser();
const activeMember = await getActiveMember()
if (!organization) { if (!organization) {
notFound(); notFound();
} }
const isMember = activeMember?.role === "member";
if (isMember) {
notFound();
}
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle className="flex items-center"> <PageTitle className="flex items-center">
Settings Organization settings
{organization.slug != "default" ? {!isMember && organization.slug !== "default" && (
<EditButtonSettings /> <EditButtonSettings />
: null} )}
</PageTitle> </PageTitle>
<PageActions> <PageActions>
{organization.slug != "default" ? {!isMember && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug} /> <DeleteOrganizationButton organizationSlug={organization.slug} />
: null} )}
</PageActions> </PageActions>
</PageHeader> </PageHeader>
<PageDescription> {/*<PageDescription>*/}
Manage your organization settings. {/* Manage your organization settings.*/}
</PageDescription> {/*</PageDescription>*/}
<PageContent> <PageContent>
{/* TODO add the list of organisation members (add, remove, edit) */} <SettingsOrganizationMembersTable organization={organization} />
</PageContent> </PageContent>
</Page> </Page>
) )
@@ -5,9 +5,10 @@ import { EvolutionLineChart } from "@/components/wrappers/dashboard/statistics/c
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart"; import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import { asc, count, eq, inArray } from "drizzle-orm"; import {and, asc, count, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganization} from "@/lib/auth/auth"; import {getOrganization} from "@/lib/auth/auth";
import {DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
@@ -26,7 +27,13 @@ export default async function RoutePage(props: PageParams<{ }>) {
where: eq(drizzleDb.schemas.project.organizationId, org.id), where: eq(drizzleDb.schemas.project.organizationId, org.id),
}); });
const projectsCount = projects.length; const projectIds = projects.map(project => project.id);
const databasesOfAllProjects = await db.query.database.findMany({
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
})
const databaseIds = databasesOfAllProjects.map((database) => database.id);
const backupsEvolution = await db.query.backup.findMany({ const backupsEvolution = await db.query.backup.findMany({
columns: { columns: {
@@ -34,9 +41,11 @@ export default async function RoutePage(props: PageParams<{ }>) {
createdAt: true, createdAt: true,
}, },
orderBy: [asc(drizzleDb.schemas.backup.id)], orderBy: [asc(drizzleDb.schemas.backup.id)],
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
}); });
const backupsRate = await db const backupsRate = await db
.select({ .select({
createdAt: drizzleDb.schemas.backup.createdAt, createdAt: drizzleDb.schemas.backup.createdAt,
@@ -44,11 +53,24 @@ export default async function RoutePage(props: PageParams<{ }>) {
_count: count(), _count: count(),
}) })
.from(drizzleDb.schemas.backup) .from(drizzleDb.schemas.backup)
.where(inArray(drizzleDb.schemas.backup.status, ["success", "failed"])) .where(and(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]), inArray(drizzleDb.schemas.backup.databaseId, databaseIds)))
.groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status) .groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status)
.orderBy(drizzleDb.schemas.backup.createdAt); .orderBy(drizzleDb.schemas.backup.createdAt);
console.log(backupsRate);
const restorationsCountResult = await db
.select({
count: count(),
})
.from(drizzleDb.schemas.restoration)
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
const projectsCount = projects.length;
const backupsEvolutionCount = backupsEvolution.length;
return ( return (
@@ -58,23 +80,26 @@ export default async function RoutePage(props: PageParams<{ }>) {
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-y-4"> <PageContent className="flex flex-col gap-y-4">
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
<Card className="w-full"> <Card className="w-full flex-1">
<CardHeader> <CardHeader className="flex items-center gap-2">
<Folder className="w-5 h-5 text-muted-foreground" />
<CardTitle>Projects</CardTitle> <CardTitle>Projects</CardTitle>
</CardHeader> </CardHeader>
<CardContent>{projectsCount}</CardContent> <CardContent className="text-3xl font-bold">{projectsCount}</CardContent>
</Card> </Card>
<Card className="w-full"> <Card className="w-full flex-1">
<CardHeader> <CardHeader className="flex items-center gap-2">
<CardTitle>KPI 2</CardTitle> <DatabaseBackup className="w-5 h-5 text-muted-foreground" />
<CardTitle>Backups</CardTitle>
</CardHeader> </CardHeader>
<CardContent></CardContent> <CardContent className="text-3xl font-bold">{backupsEvolutionCount}</CardContent>
</Card> </Card>
<Card className="w-full"> <Card className="w-full flex-1">
<CardHeader> <CardHeader className="flex items-center gap-2">
<CardTitle>KPI 3</CardTitle> <RefreshCcw className="w-5 h-5 text-muted-foreground" />
<CardTitle>Restorations</CardTitle>
</CardHeader> </CardHeader>
<CardContent></CardContent> <CardContent className="text-3xl font-bold">{restorationsCount}</CardContent>
</Card> </Card>
</div> </div>
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
+90
View File
@@ -0,0 +1,90 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/charts/evolution-line-chart";
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {asc, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {auth, listOrganizations} from "@/lib/auth/auth";
import {authClient} from "@/lib/auth/auth-client";
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
const organizations = await listOrganizations()
if (!user || !organizations) notFound();
const organizationIds = organizations.map(project => project.id);
const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
});
const projectIds = projects.map(project => project.id);
const databasesOfAllProjects = await db.query.database.findMany({
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
})
const databaseIds = databasesOfAllProjects.map((database) => database.id);
const backupsEvolution = await db.query.backup.findMany({
columns: {
id: true,
createdAt: true,
},
orderBy: [asc(drizzleDb.schemas.backup.id)],
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
});
return (
<Page>
<PageHeader>
<PageTitle>Dashboard</PageTitle>
</PageHeader>
<PageContent className="flex flex-col gap-y-4">
<div className="flex flex-col md:flex-row gap-4">
<Card className="w-full flex-1">
<CardHeader className="flex items-center gap-2">
<Building2 className="w-5 h-5 text-muted-foreground"/>
<CardTitle>Organizations</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">{organizations.length}</CardContent>
</Card>
<Card className="w-full flex-1">
<CardHeader className="flex items-center gap-2">
<Folder className="w-5 h-5 text-muted-foreground"/>
<CardTitle>Projects</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">{projects.length}</CardContent>
</Card>
<Card className="w-full flex-1">
<CardHeader className="flex items-center gap-2">
<DatabaseBackup className="w-5 h-5 text-muted-foreground"/>
<CardTitle>Backups</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-bold">{backupsEvolution.length}</CardContent>
</Card>
</div>
<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>
</PageContent>
</Page>
);
}
+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
@@ -1,4 +1,4 @@
import { LoadingSpinner } from "@/components/wrappers/common/loading/LoadingSpinner"; import { LoadingSpinner } from "@/components/wrappers/common/loading/loading-spinner";
export default function Loading() { export default function Loading() {
return ( return (
+7 -10
View File
@@ -1,13 +1,11 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page"; import {Page, PageContent, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {UserForm} from "@/components/wrappers/dashboard/profile/UserForm/UserForm"; import {UserForm} from "@/components/wrappers/dashboard/profile/user-form/user-form";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/ButtonDeleteAccount/ButtonDeleteAccount"; import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/Avatar/AvatarWithUpload"; import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSessions} from "@/lib/auth/auth";
//import { getAccounts, getSessions } from "@/lib/auth/auth";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser(); const user = await currentUser();
@@ -16,7 +14,6 @@ export default async function RoutePage(props: PageParams<{}>) {
return notFound(); return notFound();
} }
if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") { if (user.role !== "user" && user.role !== "admin" && user.role !== "superadmin") {
return notFound(); return notFound();
} }
@@ -42,9 +39,9 @@ export default async function RoutePage(props: PageParams<{}>) {
{user.name} {user.name}
<Badge className="ml-3 hidden lg:block">{user.role}</Badge> <Badge className="ml-3 hidden lg:block">{user.role}</Badge>
</PageTitle> </PageTitle>
<PageActions className="mt-2 hidden sm:block"> {/*<PageActions className="mt-2 hidden sm:block">*/}
<ButtonDeleteAccount text="Delete my account"/> {/* <ButtonDeleteAccount text="Delete my account"/>*/}
</PageActions> {/*</PageActions>*/}
</div> </div>
<PageContent> <PageContent>
<UserForm <UserForm
+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
+9 -1
View File
@@ -2,9 +2,17 @@ import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
export async function GET({ params }: { params: Promise<{ fileName: string }> }) { export async function GET(
request: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
try { try {
const fileName = (await params).fileName; const fileName = (await params).fileName;
console.log("fileName", fileName);
const filePath = path.join(process.cwd(), "private/uploads/images", fileName); const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
// Check if the file exists // Check if the file exists
+8 -5
View File
@@ -1,10 +1,13 @@
import { redirect } from "next/navigation";
export default async function NotFound() { export default async function NotFound() {
// redirect("/dashboard/profile");
return( return(
<> <div className="flex items-center min-h-screen px-4 py-12 sm:px-6 md:px-8 lg:px-12 xl:px-16">
Error <div className="w-full space-y-6 text-center">
</> <div className="space-y-3">
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
</div>
</div>
</div>
) )
} }
-1
View File
@@ -7,7 +7,6 @@ services:
context: . context: .
dockerfile: docker/dockerfile/Dockerfile dockerfile: docker/dockerfile/Dockerfile
target: prod target: prod
ports: ports:
- '8887:80' - '8887:80'
env_file: env_file:
+37 -41
View File
@@ -1,31 +1,24 @@
name: portabase-dev name: portabase-dev
services: services:
app: # app:
build: # build:
context: . # context: .
dockerfile: docker/dockerfile/Dockerfile # dockerfile: docker/dockerfile/Dockerfile
target: dev # target: dev
ports: # ports:
- "8887:80" # - "8887:8887"
environment: # environment:
- TIME_ZONE="Europe/Paris" # - TIME_ZONE="Europe/Paris"
- NODE_ENV=development # - NODE_ENV=development
depends_on: # depends_on:
db: # db:
condition: service_healthy # condition: service_healthy
volumes: # volumes:
- .:/app # - .:/app
- /app/node_modules # - /app/node_modules
container_name: portabase-app # container_name: portabase-app
#
develop:
watch:
- action: sync
path: .
target: /app
- action: rebuild
path: package.json
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@@ -43,35 +36,38 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
db-pgadmin: # db-pgadmin:
image: dpage/pgadmin4 # image: dpage/pgadmin4
environment: # environment:
PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser" # PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
PGADMIN_DEFAULT_PASSWORD: "changeme" # PGADMIN_DEFAULT_PASSWORD: "changeme"
PGADMIN_CONFIG_SERVER_MODE: "False" # PGADMIN_CONFIG_SERVER_MODE: "False"
POSTGRES_USER: "devuser" # POSTGRES_USER: "devuser"
POSTGRES_PASSWORD: "changeme" # POSTGRES_PASSWORD: "changeme"
volumes: # volumes:
- pgadmin-data:/var/lib/pgadmin # - pgadmin-data:/var/lib/pgadmin
ports: # ports:
- "8080:80" # - "8080:80"
restart: unless-stopped # restart: unless-stopped
depends_on: # depends_on:
- db # - db
s3: s3:
container_name: s3-portabase-dev
image: docker.io/bitnami/minio:latest image: docker.io/bitnami/minio:latest
ports: ports:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
volumes: volumes:
- minio_data:/data - minio_data:/data
environment: environment:
- MINIO_ROOT_USER=${S3_ACCESS_KEY} - MINIO_ROOT_USER=${S3_ACCESS_KEY}
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY} - MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME} - MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
- MINIO_BROWSER=on
volumes: volumes:
postgres-data: postgres-data:
minio_data: minio_data:
pgadmin-data: # pgadmin-data:
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:23-alpine AS base FROM node:22-alpine AS base
ENV YARN_VERSION=4.9.1 ENV YARN_VERSION=4.9.1
+13 -6
View File
@@ -1,11 +1,18 @@
#!/bin/bash #!/bin/bash
#
#npx drizzle-kit generate
#npx drizzle-kit migrate
#
#npm run dev
#
#exec "$@"
set -euo pipefail
npm install -g npm@11.0.0 echo "▶ Running Drizzle codegen..."
npm -v
npx drizzle-kit generate npx drizzle-kit generate
echo "▶ Applying migrations..."
npx drizzle-kit migrate npx drizzle-kit migrate
npm run dev echo "▶ Starting Next.js dev server..."
exec npm run dev
exec "$@"
+17 -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,21 @@ 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));
}
if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
} }
return NextResponse.next(); return NextResponse.next();
-20
View File
@@ -1,20 +0,0 @@
// next-auth.d.ts
import NextAuth from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
email: string;
name: string;
authMethod: string; // Add authMethod to the session user type
};
}
interface User {
id: string;
email: string;
name: string;
authMethod: string; // Add authMethod to the user type
}
}
+1 -1
View File
@@ -12,6 +12,7 @@ function buildCSPHeader(): string {
`img-src ${CSP.IMG_SRC.join(" ")}`, `img-src ${CSP.IMG_SRC.join(" ")}`,
`font-src ${CSP.FONT_SRC.join(" ")}`, `font-src ${CSP.FONT_SRC.join(" ")}`,
`object-src ${CSP.OBJECT_SRC.join(" ")}`, `object-src ${CSP.OBJECT_SRC.join(" ")}`,
`connect-src ${CSP.CONNECT_SRC.join(" ")}`,
`base-uri ${CSP.BASE_URI.join(" ")}`, `base-uri ${CSP.BASE_URI.join(" ")}`,
`form-action ${CSP.FORM_ACTION.join(" ")}`, `form-action ${CSP.FORM_ACTION.join(" ")}`,
`frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`, `frame-ancestors ${CSP.FRAME_ANCESTORS.join(" ")}`,
@@ -35,7 +36,6 @@ function buildPermissionsPolicy(): string {
} }
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
typescript: { typescript: {
+3 -3
View File
@@ -3,7 +3,7 @@
"version": "1.1.0", "version": "1.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 80", "dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile", "build": "next build --experimental-build-mode compile",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "next lint",
@@ -52,7 +52,7 @@
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "^1.2.8", "better-auth": "1.3.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -65,7 +65,7 @@
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.510.0", "lucide-react": "^0.510.0",
"minio": "^8.0.5", "minio": "^8.0.5",
"next": "^15.4.2-canary.5", "next": "15.4.2-canary.10",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
+39 -4
View File
@@ -3,10 +3,45 @@ export const PORTABASE_DEFAULT_SETTINGS = {
SECURITY: { SECURITY: {
CSP: { CSP: {
DEFAULT_SRC: ["'self'"], DEFAULT_SRC: ["'self'"],
SCRIPT_SRC: ["'self'", "'unsafe-eval'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://www.googletagmanager.com"], SCRIPT_SRC: [
STYLE_SRC: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"], "'self'",
IMG_SRC: ["'self'", "blob:", "data:", "https:"], "'unsafe-eval'",
FONT_SRC: ["'self'"], "'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'"], OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"], BASE_URI: ["'self'"],
FORM_ACTION: ["'self'"], FORM_ACTION: ["'self'"],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 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

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 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

@@ -9,9 +9,9 @@ import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import Link from "next/link"; import Link from "next/link";
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input"; import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/loginForm/login-form.schema"; import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/login-form/login-form.schema";
import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/buttonAuth/SocialAuthButton"; import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/button-auth/social-auth-button";
import { signIn } from "@/lib/auth/auth-client"; import { signIn } from "@/lib/auth/auth-client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
@@ -12,7 +12,7 @@ import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TooltipProvider, TooltipTrigger, Tooltip, TooltipContent } from "@/components/ui/tooltip"; import { TooltipProvider, TooltipTrigger, Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { RegisterSchema, RegisterType } from "@/components/wrappers/auth/register/register-form/register-form.schema"; import { RegisterSchema, RegisterType } from "@/components/wrappers/auth/register/register-form/register-form.schema";
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input"; import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
import {signUp} from "@/lib/auth/auth-client"; import {signUp} from "@/lib/auth/auth-client";
export type registerFormProps = { export type registerFormProps = {
@@ -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)} />;
}; };
@@ -1,5 +1,158 @@
"use client"; // "use client";
//
// import {
// ColumnDef,
// flexRender,
// getCoreRowModel,
// useReactTable,
// getPaginationRowModel,
// getSortedRowModel,
// SortingState,
// ColumnFiltersState,
// getFilteredRowModel,
// } from "@tanstack/react-table";
//
// import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// import { Input } from "@/components/ui/input";
//
// import { useState } from "react";
// import { TablePagination } from "./table-pagination";
// import { Checkbox } from "@/components/ui/checkbox";
//
// interface DataTableProps<TData, TValue> {
// columns: ColumnDef<TData, TValue>[];
// data: TData[];
// enableFilter?: boolean;
// enableSelect?: boolean;
// enablePagination?: boolean;
// paginationOptions?: {
// pageSize: number[];
// pageVisible: number;
// className?: string;
// };
// filterOptions?: {
// title?: string;
// key: string;
// };
// emptyText?: string;
// }
//
// export function DataTable<TData, TValue>({
// columns,
// data,
// enableFilter = false,
// enablePagination = true,
// enableSelect = true,
// paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
// filterOptions = { key: "id", title: "Filter by ID" },
// emptyText = "No data.",
// }: DataTableProps<TData, TValue>) {
// const [sorting, setSorting] = useState<SortingState>([]);
// const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// const [rowSelection, setRowSelection] = useState({});
//
// if (enableSelect && data.length > 0) {
// const selectColumnExists = columns.some((column) => column.id === "select");
//
// if (!selectColumnExists) {
// columns.unshift({
// id: "select",
// header: ({ table }) => (
// <Checkbox
// checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
// aria-label="Select all"
// />
// ),
// cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
// enableSorting: false,
// enableHiding: false,
// });
// }
// }
//
// const table = useReactTable({
// data,
// columns,
// getCoreRowModel: getCoreRowModel(),
// getPaginationRowModel: getPaginationRowModel(),
// onSortingChange: setSorting,
// getSortedRowModel: getSortedRowModel(),
// onColumnFiltersChange: setColumnFilters,
// getFilteredRowModel: getFilteredRowModel(),
// onRowSelectionChange: setRowSelection,
// state: {
// sorting,
// columnFilters,
// rowSelection,
// },
// });
//
// return (
// <div className="h-full">
// {enableFilter && (
// <div className="flex items-center py-4">
// <Input
// placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
// value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
// onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
// className="max-w-sm"
// />
// </div>
// )}
// <div className="rounded-md border w-full">
// <Table className="w-full">
// <TableHeader>
// {table.getHeaderGroups().map((headerGroup) => (
// <TableRow key={headerGroup.id}>
// {headerGroup.headers.map((header) => {
// return (
// <TableHead key={header.id}>
// {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
// </TableHead>
// );
// })}
// </TableRow>
// ))}
// </TableHeader>
// <TableBody>
// {table.getRowModel().rows?.length ? (
// table.getRowModel().rows.map((row) => (
// <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
// {row.getVisibleCells().map((cell) => (
// <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
// ))}
// </TableRow>
// ))
// ) : (
// <TableRow>
// <TableCell colSpan={columns.length} className="h-24 text-center">
// {emptyText}
// </TableCell>
// </TableRow>
// )}
// </TableBody>
// </Table>
// </div>
// <div className="flex items-center justify-end space-x-2 py-4 mt-6">
// {enableSelect && (
// <div className="flex-1 text-sm text-muted-foreground">
// {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
// </div>
// )}
// {enablePagination && (
// <TablePagination
// table={table}
// maxVisiblePages={paginationOptions?.pageVisible}
// pageSizeOptions={paginationOptions.pageSize}
// className={paginationOptions.className}
// />
// )}
// </div>
// </div>
// );
// }
"use client"
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -18,6 +171,8 @@ import { Input } from "@/components/ui/input";
import {useState} from "react"; import {useState} from "react";
import {TablePagination} from "./table-pagination"; import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox"; import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@@ -34,7 +189,13 @@ interface DataTableProps<TData, TValue> {
title?: string; title?: string;
key: string; key: string;
}; };
emptyText?: string; emptyButton?: {
text: string;
variant: "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined;
path: string;
};
highlightRow?: (row: TData) => boolean;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@@ -45,12 +206,14 @@ export function DataTable<TData, TValue>({
enableSelect = true, enableSelect = true,
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3}, paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
filterOptions = {key: "id", title: "Filter by ID"}, filterOptions = {key: "id", title: "Filter by ID"},
emptyText = "No data.", emptyButton,
highlightRow
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({}); const [rowSelection, setRowSelection] = useState({});
if (enableSelect && data.length > 0) { if (enableSelect && data.length > 0) {
const selectColumnExists = columns.some((column) => column.id === "select"); const selectColumnExists = columns.some((column) => column.id === "select");
@@ -64,7 +227,9 @@ export function DataTable<TData, TValue>({
aria-label="Select all" aria-label="Select all"
/> />
), ),
cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />, cell: ({row}) => <Checkbox checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"/>,
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
}); });
@@ -87,9 +252,11 @@ export function DataTable<TData, TValue>({
rowSelection, rowSelection,
}, },
}); });
const router = useRouter();
return ( return (
<div> <div
className="flex flex-col h-full"
>
{enableFilter && ( {enableFilter && (
<div className="flex items-center py-4"> <div className="flex items-center py-4">
<Input <Input
@@ -100,6 +267,7 @@ export function DataTable<TData, TValue>({
/> />
</div> </div>
)} )}
<div className="flex flex-col justify-between h-full">
<div className="rounded-md border w-full"> <div className="rounded-md border w-full">
<Table className="w-full"> <Table className="w-full">
<TableHeader> <TableHeader>
@@ -118,16 +286,33 @@ export function DataTable<TData, TValue>({
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}> <TableRow key={row.id}
className={highlightRow && highlightRow(row.original) ? "bg-gray-100 pointer-events-none" : ""}
data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell> <TableCell
key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableCell colSpan={columns.length} className="p-0">
{emptyText} {emptyButton ? (
<Button
variant={emptyButton.variant}
className="btn btn-primary cursor-pointer w-full rounded-tr-none rounded-tl-none py-12 text-lg font-bold"
onClick={() => {
router.push(emptyButton.path)
}}
>
<span>{emptyButton.text}</span>
</Button>
) : (
<div className="flex items-center justify-center py-12 text-lg font-bold">
<span className="text-lg font-bold">No data available</span>
</div>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@@ -135,20 +320,33 @@ export function DataTable<TData, TValue>({
</Table> </Table>
</div> </div>
<div className="flex items-center justify-end space-x-2 py-4 mt-6"> <div className="flex items-center justify-end space-x-2 py-4 mt-6">
{enableSelect && ( {enableSelect && table.getFilteredRowModel().rows.length >= 1 && (
<div className="flex-1 text-sm text-muted-foreground"> <div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected. {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div> </div>
)} )}
{enablePagination && ( {enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
<TablePagination <TablePagination
table={table} table={table}
maxVisiblePages={paginationOptions?.pageVisible} maxVisiblePages={paginationOptions?.pageVisible}
pageSizeOptions={paginationOptions.pageSize} pageSizeOptions={(() => {
const rowCount = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCount);
const nextSize = allSizes.find((size) => size > rowCount);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()}
className={paginationOptions.className} className={paginationOptions.className}
/> />
)} )}
</div> </div>
</div>
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
"use server"; "use server";
import { userAction } from "@/safe-actions"; import { userAction } from "@/safe-actions";
import { z } from "zod"; import { z } from "zod";
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema"; import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
@@ -8,9 +8,9 @@ import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.schema"; import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
import { PasswordInput } from "@/components/wrappers/auth/PaswordInput/password-input"; import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
import { updateEmailSettingsAction } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/email-form.action"; import { updateEmailSettingsAction } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -1,4 +1,4 @@
import { EmailForm } from "@/components/wrappers/dashboard/admin/AdminEmailTab/EmailForm/EmailForm"; import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form";
import { Send } from "lucide-react"; import { Send } from "lucide-react";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading"; import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@@ -2,15 +2,18 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {Info, ShieldCheck} from "lucide-react"; import {Info, ShieldCheck} from "lucide-react";
import {Switch} from "@/components/ui/switch"; import {Switch} from "@/components/ui/switch";
import {Label} from "@/components/ui/label"; import {Label} from "@/components/ui/label";
import { StorageS3Form } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/StorageS3Form"; import {StorageS3Form} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
import {useState} from "react"; import {useState} from "react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {checkConnexionToS3} from "@/features/upload/public/upload.action"; import {checkConnexionToS3} from "@/features/upload/public/upload.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import { updateStorageSettingsAction } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action"; import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
import {Setting} from "@/db/schema/00_setting"; import {Setting} from "@/db/schema/00_setting";
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
export type SettingsStorageTabProps = { export type SettingsStorageTabProps = {
settings: Setting; settings: Setting;
@@ -48,6 +51,16 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
await updateMutation.mutateAsync(); await updateMutation.mutateAsync();
}; };
const extractS3FormValues = (settings: Setting): S3FormType | undefined => {
if (!settings.s3EndPointUrl) return undefined;
return {
s3EndPointUrl: settings.s3EndPointUrl,
s3AccessKeyId: settings.s3AccessKeyId!,
s3SecretAccessKey: settings.s3SecretAccessKey!,
S3BucketName: settings.S3BucketName!,
};
};
return ( return (
<div className="flex flex-col h-full py-4"> <div className="flex flex-col h-full py-4">
<h1>Settings for Portabase storage</h1> <h1>Settings for Portabase storage</h1>
@@ -55,7 +68,8 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
<Info className="h-4 w-4"/> <Info className="h-4 w-4"/>
<AlertTitle>Informations</AlertTitle> <AlertTitle>Informations</AlertTitle>
<AlertDescription> <AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local. For exemple you cannot choose to store images in one place Actually you can only store you data in one place : s3 compatible or in local. For exemple you
cannot choose to store images in one place
and .dump files in another. and .dump files in another.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -86,7 +100,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
</div> </div>
{isSwitched && ( {isSwitched && (
<div className="mt-5"> <div className="mt-5">
<StorageS3Form defaultValues={props.settings.s3EndPointUrl ? props.settings : null} /> <StorageS3Form defaultValues={extractS3FormValues(props.settings)}/>
</div> </div>
)} )}
</div> </div>
@@ -3,7 +3,7 @@
import { userAction } from "@/safe-actions"; import { userAction } from "@/safe-actions";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema"; import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.schema"; import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/AdminStorageTab/StorageS3Form/s3-form.action"; import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
export type S3FormProps = { export type S3FormProps = {
defaultValues?: S3FormType; defaultValues?: S3FormType;
@@ -1,22 +1,36 @@
"use client"; "use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SettingsEmailTab } from "@/components/wrappers/dashboard/admin/AdminEmailTab/SettingsEmailTab"; import { SettingsEmailTab } from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
import { SettingsStorageTab } from "@/components/wrappers/dashboard/admin/AdminStorageTab/SettingsStorageTab"; import { SettingsStorageTab } from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
import { AdminUsersTable } from "@/components/wrappers/dashboard/admin/admin-user-table"; import { AdminUsersTable } from "@/components/wrappers/dashboard/admin/admin-user-table";
import { User } from "@/db/schema/01_user"; import { User } from "@/db/schema/01_user";
import { Setting } from "@/db/schema/00_setting"; import { Setting } from "@/db/schema/00_setting";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export type AdminTabsProps = { export type AdminTabsProps = {
users: User[]; users: User[];
settings: Setting; settings: Setting;
}; };
export const AdminTabs = (props: AdminTabsProps) => { export const AdminTabs = ({ users, settings }: AdminTabsProps) => {
const { users, settings } = props; const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "users";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return ( return (
<Tabs defaultValue="users"> <Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
<TabsList className="w-full"> <TabsList className="w-full">
<TabsTrigger className="w-full" value="users"> <TabsTrigger className="w-full" value="users">
Users Users
@@ -13,7 +13,7 @@ export const AdminUsersTable = (props: AdminUsersTableProps) => {
<div className="flex gap-4 h-fit justify-between"> <div className="flex gap-4 h-fit justify-between">
<h1>List of Portabase's users</h1> <h1>List of Portabase's users</h1>
</div> </div>
<div className="mt-5"> <div className="mt-5 h-full">
<DataTable columns={usersColumnsAdmin} data={users} /> <DataTable columns={usersColumnsAdmin} data={users} />
</div> </div>
</div> </div>
@@ -1,16 +1,16 @@
"use client" "use client"
import {ColumnDef} from "@tanstack/react-table"; import {ColumnDef} from "@tanstack/react-table";
import {Badge} from "@/components/ui/badge"; import {Badge} from "@/components/ui/badge";
import {updateUserAction} from "@/components/wrappers/dashboard/profile/UserForm/user-form.action"; import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner"; import {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {useState} from "react"; import {useState} from "react";
import {Trash2} from "lucide-react"; import {Trash2} from "lucide-react";
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/ButtonDeleteAccount/delete-account.action"; import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {User} from "@/db/schema/01_user"; import {User} from "@/db/schema/01_user";
import {useSession} from "@/lib/auth/auth-client"; import {authClient, useSession} from "@/lib/auth/auth-client";
import {formatFrenchDate} from "@/utils/date-formatting"; import {formatFrenchDate} from "@/utils/date-formatting";
export const usersColumnsAdmin: ColumnDef<User>[] = [ export const usersColumnsAdmin: ColumnDef<User>[] = [
@@ -20,6 +20,13 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
cell: ({row}) => { cell: ({row}) => {
const [role, setRole] = useState<string>(row.getValue("role")); const [role, setRole] = useState<string>(row.getValue("role"));
const {data: session, isPending, error} = authClient.useSession();
const isCurrentUser = session?.user.email === row.original.email;
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}), mutationFn: () => updateUserAction({id: row.original.id, data: {role: role}}),
onSuccess: () => { onSuccess: () => {
@@ -36,8 +43,21 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
await updateMutation.mutateAsync(); await updateMutation.mutateAsync();
}; };
if (isPending) return null;
if (error || !session) {
return null;
}
return ( return (
<Badge className="cursor-pointer" onClick={() => handleUpdateRole()} variant="outline"> <Badge
className={isCurrentUser ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
onClick={isCurrentUser ? undefined : () => handleUpdateRole()}
variant="outline"
>
{role} {role}
</Badge> </Badge>
); );
@@ -73,23 +93,18 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
}, },
}); });
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!session || session?.user.email === row.original.email ? null :
<ButtonWithLoading <ButtonWithLoading
disabled={!session || session?.user.email === row.original.email}
variant="outline" variant="outline"
text="" text=""
icon={<Trash2 color="red" size={15}/>} icon={<Trash2 color="red" size={15}/>}
onClick={async () => { onClick={async () => {
await mutation.mutateAsync(); await mutation.mutateAsync();
}} }}
size="icon" size="sm"
/> />
}
</div> </div>
); );
}, },
@@ -1,44 +0,0 @@
"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 * as drizzleDb from "@/db";
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 [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();
return {
data: createdAgent,
};
});
export const updateAgentAction = userAction
.schema(
z.object({
id: z.string(),
data: AgentSchema,
})
)
.action(async ({ parsedInput }) => {
await verifySlugUniqueness(parsedInput.data.slug, parsedInput.id);
const [updatedAgent] = await db.update(drizzleDb.schemas.agent).set(parsedInput.data).where(eq(drizzleDb.schemas.agent.id, parsedInput.id)).returning();
return {
data: updatedAgent,
};
});
@@ -1,7 +1,7 @@
"use client"; "use client";
import {generateEdgeKey} from "@/utils/edge_key"; import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url"; import {getServerUrl} from "@/utils/get-server-url";
import {PasswordInput} from "@/components/wrappers/auth/PaswordInput/password-input"; import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
import {useState} from "react"; import {useState} from "react";
import {CopyButton} from "@/components/wrappers/common/button/copy-button"; import {CopyButton} from "@/components/wrappers/common/button/copy-button";
import {Agent} from "@/db/schema/07_agent"; import {Agent} from "@/db/schema/07_agent";
@@ -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 className="flex-1 text-left">
<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>
@@ -0,0 +1,48 @@
"use server";
import {ActionError, userAction} from "@/safe-actions";
import {AgentSchema} from "@/components/wrappers/dashboard/agent/agent-form/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 [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}) => {
const slug = slugify(parsedInput.name);
await verifySlugUniqueness(slug);
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
return {
data: createdAgent,
};
});
export const updateAgentAction = userAction
.schema(
z.object({
id: z.string(),
data: AgentSchema,
})
)
.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,
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({ 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(),
}); });
@@ -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 {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"; import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip"; import {TooltipProvider} from "@/components/ui/tooltip";
import { AgentSchema, AgentType } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.schema"; import {AgentSchema, AgentType} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
import {toast} from "sonner"; import {toast} from "sonner";
import { createAgentAction, updateAgentAction } from "@/components/wrappers/dashboard/agent/AgentForm/agent-form.action"; import {createAgentAction, updateAgentAction} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.action";
export type agentFormProps = { export type agentFormProps = {
defaultValues?: AgentType; defaultValues?: AgentType;
@@ -29,7 +37,6 @@ 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)
@@ -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();
}, },
@@ -76,29 +83,6 @@ export const AgentForm = (props: agentFormProps) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
defaultValue=""
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
//value={field.value ?? ""}
placeholder="agent-1"
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormDescription>The slug is used in the url of the agent</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
defaultValue="" defaultValue=""
@@ -107,7 +91,8 @@ export const AgentForm = (props: agentFormProps) => {
<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/>
@@ -7,7 +7,7 @@ import { generateEdgeKey } from "@/utils/edge_key";
import { PropsWithChildren } from "react"; import { PropsWithChildren } from "react";
import { CopyButton } from "@/components/wrappers/common/button/copy-button"; import { CopyButton } from "@/components/wrappers/common/button/copy-button";
import { getServerUrl } from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
import { CodeSnippet } from "@/components/wrappers/code-snippet/CodeSnippet"; import { CodeSnippet } from "@/components/wrappers/code-snippet/code-snippet";
import {Agent} from "@/db/schema/07_agent"; import {Agent} from "@/db/schema/07_agent";
export type agentRegistrationDialogProps = PropsWithChildren<{ export type agentRegistrationDialogProps = PropsWithChildren<{
@@ -0,0 +1,42 @@
"use client";
import { Trash2 } from "lucide-react";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-delete-agent/delete-agent.action";
export type ButtonDeleteAgentProps = {
text?: string;
agentId: string;
};
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: () => deleteAgentAction(props.agentId),
onSuccess: async (result: any) => {
if (result.data?.success) {
toast.success(result.data.actionSuccess.message);
router.push("/dashboard/agents");
} else {
toast.error(result.data.actionError.message || "Unknown error occurred.");
}
},
});
return (
<ButtonWithConfirm
text={props.text ? props.text : ""}
onClick={() => {
mutation.mutate();
}}
variant={"destructive"}
isPending={mutation.isPending}
className="gap-2"
icon={<Trash2 />}
/>
);
};
@@ -0,0 +1,53 @@
"use server";
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Agent} from "@/db/schema/07_agent";
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
try {
// const deletedAgent: Agent[] = await db.delete(drizzleDb.schemas.agent).where(eq(drizzleDb.schemas.agent.id, parsedInput)).returning();
const uuid = uuidv4();
const updatedAgent = await db
.update(drizzleDb.schemas.agent)
.set({
isArchived: true,
slug: uuid,
})
.where(eq(drizzleDb.schemas.agent.id, parsedInput))
.returning();
if (!updatedAgent[0]) {
throw new Error("Agent not found or update failed");
}
return {
success: true,
value: updatedAgent[0],
actionSuccess: {
message: "Agent has been successfully deleted.",
messageParams: {projectId: parsedInput},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to delete agent.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectId: parsedInput},
},
};
}
});
@@ -1,8 +1,8 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { SidebarMenuButton } from "@/components/ui/sidebar"; import { SidebarMenuButton } from "@/components/ui/sidebar";
import { ChevronUp } from "lucide-react"; import { ChevronUp } from "lucide-react";
import { LoggedInDropdown } from "@/components/wrappers/dashboard/loggedInDropdown/LoggedInDropdown";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import {LoggedInDropdown} from "@/components/wrappers/dashboard/common/logged-in/logged-in-dropdown";
export const LoggedInButton = async () => { export const LoggedInButton = async () => {
const user = await currentUser(); const user = await currentUser();
@@ -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 {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
export function AppSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLogo/>
</SidebarMenuItem>
<SidebarMenuItem>
<OrganizationCombobox/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenuCustomMain/>
</SidebarContent>
<SidebarMenu className="mb-2">
<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,65 @@
"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 groupContentApplication: SidebarGroupItem["group_content"] = [
{ title: "Dashboard", url: "/home", icon: Home },
];
const groupContent: SidebarGroupItem["group_content"] = [
{ 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: groupContentApplication,
},
{
label: "Organization",
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>
);
};
@@ -10,7 +10,7 @@ export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
return ( return (
<> <>
{state === "expanded" && ( {state === "expanded" && (
<div className="text-center"> <div className="text-center mb-2">
<h1 className="text-[10px]">Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}</h1> <h1 className="text-[10px]">Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}</h1>
</div> </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>
);
};
@@ -2,7 +2,7 @@
import { Clock9 } from "lucide-react"; import { Clock9 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { CronInput } from "@/components/wrappers/dashboard/database/CronButton/CronInput"; import { CronInput } from "@/components/wrappers/dashboard/database/cron-button/cron-input";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
@@ -10,7 +10,7 @@ import { useState } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/CronButton/cron.action"; import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {Database} from "@/db/schema/06_database"; import {Database} from "@/db/schema/06_database";
export type CronButtonProps = { export type CronButtonProps = {
@@ -1,5 +1,5 @@
import { AdvancedCronSelect } from "./AdvancedCronSelect"; import { AdvancedCronSelect } from "./advanced-cron-select";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/CronButton/cron.action"; import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { DatabaseSchema, DatabaseType } from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema"; import { DatabaseSchema, DatabaseType } from "@/components/wrappers/dashboard/database/database-form/form-database.schema";
import { updateDatabaseAction } from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.action"; import { updateDatabaseAction } from "@/components/wrappers/dashboard/database/database-form/form-database.action";
import { toast } from "sonner"; import { toast } from "sonner";
export type DatabaseFormProps = { export type DatabaseFormProps = {
@@ -4,7 +4,7 @@ import { z } from "zod";
import { userAction } from "@/safe-actions"; import { userAction } from "@/safe-actions";
import { db } from "@/db"; import { db } from "@/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { DatabaseSchema } from "@/components/wrappers/dashboard/database/DatabaseForm/form-database.schema"; import { DatabaseSchema } from "@/components/wrappers/dashboard/database/database-form/form-database.schema";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
export const updateDatabaseAction = userAction export const updateDatabaseAction = userAction
@@ -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.push("/");
router.replace(`/dashboard/home`); router.replace(`/dashboard/home`);
// router.refresh(); } 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>
@@ -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 (
@@ -4,6 +4,8 @@ import {ComboBox} from "@/components/wrappers/common/combobox";
import {useSidebar} from "@/components/ui/sidebar"; import {useSidebar} from "@/components/ui/sidebar";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {authClient} from "@/lib/auth/auth-client"; import {authClient} from "@/lib/auth/auth-client";
import {auth} from "@/lib/auth/auth";
export function OrganizationCombobox() { export function OrganizationCombobox() {
const router = useRouter(); const router = useRouter();
@@ -24,11 +26,15 @@ export function OrganizationCombobox() {
}; };
}); });
const onValueChange = async (slug: string) => { const onValueChange = async (slug: string) => {
await authClient.organization.setActive({ await authClient.organization.setActive({
organizationSlug: slug, organizationSlug: slug,
}); });
router.refresh(); router.refresh();
}; };
const handleReset = () => { const handleReset = () => {
@@ -7,7 +7,7 @@ export const OrganizationFormSchema = z.object({
.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'),
members: z.array(z.string()), users: z.array(z.string()),
}); });
@@ -1,42 +1,65 @@
"use client"; "use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card"; import {Card, CardContent, CardHeader} from "@/components/ui/card";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input"; import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form"; import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query"; 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 {
import { updateOrganizationAction } from "@/components/wrappers/dashboard/organization/organization.action"; OrganizationFormSchema,
OrganizationFormType
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
import {
deleteOrganizationAction,
updateOrganizationAction
} from "@/components/wrappers/dashboard/organization/organization.action";
import {toast} from "sonner"; import {toast} from "sonner";
import {Member, Organization} from "better-auth/plugins"; import {User as BetterAuthUser} from "better-auth";
import {User} from "@/db/schema/01_user";
export type organizationFormProps = { export type organizationFormProps = {
defaultValues?: Organization; defaultValues?: OrganizationWithMembers;
members: Member[]; users: User[];
currentUser: BetterAuthUser;
}; };
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 = (users: User[]) => {
return members.map((member) => ({
value: member.id, return users
label: `${member.user.name} | ${member.user.email}`, .filter((user) => user.id !== props.currentUser.id)
.map((user) => ({
value: user.id,
label: `${user.name} | ${user.email}`,
})); }));
}; };
const formatDefaultUsers = (members: OrganizationFormType["members"]): string[] => { const formatDefaultUsers = (members: MemberWithUser[]): string[] => {
console.log(members); return members
return members.map((member) => member.userId); .filter((member) => member.userId !== props.currentUser.id)
.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({
@@ -45,20 +68,29 @@ export const OrganizationForm = (props: organizationFormProps) => {
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: OrganizationFormType) => { mutationFn: (values: OrganizationFormType) => updateOrganizationAction({
console.log(values); data: values,
// const organization = await updateOrganizationAction({ data: values, organizationId: props.defaultValues.id }); organizationId: props.defaultValues?.id ?? ""
// console.log(organization); }),
// if (organization.data.success) { onSuccess: async (result) => {
// // toast.success(organization.data.actionSuccess.message); if (result?.data?.success) {
// // router.push(`/dashboard/${organization.data.value.slug}/settings`); toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
// // router.refresh(); router.push("/dashboard/settings");
// } else { } else {
// // toast.success(organization.data.actionError.message); // @ts-ignore
// } const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
toast.error(errorMsg);
}
}, },
onError: (error: any) => {
console.error("Mutation network error:", error);
toast.error(error?.message || "A network error occurred.");
},
}); });
return ( return (
<Card> <Card>
<CardHeader></CardHeader> <CardHeader></CardHeader>
@@ -110,13 +142,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.users)}
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}

Some files were not shown because too many files have changed in this diff Show More