Compare commits

...
21 Commits
Author SHA1 Message Date
charlesgauthereau c4ebc876bf Ready for 1.1.3-rc.3 release. 2025-11-04 09:20:24 +01:00
charlesgauthereau a435a92d41 Ready for 1.1.3-rc.3 release. 2025-11-04 08:48:46 +01:00
charlesgauthereau bcbd6bbea1 Work on passing all env variables on server side. 2025-11-03 18:54:16 +01:00
charlesgauthereau 82559b49bd Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 14:02:50 +01:00
charlesgauthereau 8c1b869004 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 13:31:05 +01:00
charlesgauthereau c4a0692985 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 13:20:48 +01:00
charlesgauthereau 750f1cb145 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 12:49:42 +01:00
charlesgauthereau b7d3487c53 Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 12:46:58 +01:00
charlesgauthereau 529f157c6e Ready for 1.1.3-rc.2 and migrating on nextjs 16 2025-11-03 12:32:00 +01:00
charlesgauthereau c62be9854a Ready for 1.1.3-rc.2 2025-11-03 11:37:54 +01:00
charlesgauthereau 9c1aa66b41 Ready for 1.1.3-rc.2 2025-11-03 11:37:14 +01:00
charlesgauthereau 9894e2582d Ready for 1.1.3-rc.2 2025-11-03 10:59:26 +01:00
charlesgauthereau 8a8ef81fce Ready for 1.1.3-rc.2 2025-11-03 10:38:10 +01:00
charlesgauthereau 2fdd9f1296 Adding mysql support. 2025-11-01 23:16:32 +01:00
charlesgauthereau 5251e01011 Refactoring. 2025-11-01 19:38:05 +01:00
charlesgauthereau 84dab35f4b Refactoring in roles. 2025-11-01 14:50:28 +01:00
charlesgauthereau ae766dab2f Refactoring in roles. 2025-11-01 14:50:08 +01:00
charlesgauthereau 027928c29f working on edge key with public server key. 2025-10-31 17:55:16 +01:00
charlesgauthereau e3239639f3 For 1.1.3-rc-2 release. 2025-10-27 18:49:24 +01:00
charlesgauthereau 7d6038e5f1 Working on Advanced Encryption Standard (AES), between agent backup and server. 2025-10-26 11:54:25 +01:00
charlesgauthereau 2a90d4933d Ready for rc-1.1.3 2025-10-12 20:33:59 +02:00
141 changed files with 8506 additions and 2422 deletions
+7 -3
View File
@@ -5,9 +5,9 @@ NODE_ENV=production
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Projet # Projet
NEXT_PUBLIC_PROJECT_NAME="Portabase" PROJECT_NAME="Portabase"
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager" PROJECT_DESCRIPTION="Portabase is a powerful database manager"
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io PROJECT_URL=http://app.portabase.io
PROJECT_SECRET= PROJECT_SECRET=
# SMTP (email) # SMTP (email)
@@ -20,6 +20,7 @@ SMTP_FROM=
# Google # Google
AUTH_GOOGLE_ID= AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET= AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3 # S3
S3_ENDPOINT=http://app.s3.portabase.io S3_ENDPOINT=http://app.s3.portabase.io
@@ -31,3 +32,6 @@ S3_USE_SSL=true
# Storage Type (s3, local) # Storage Type (s3, local)
STORAGE_TYPE=local STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
-19
View File
@@ -48,22 +48,3 @@ jobs:
push: true push: true
tags: ${{ steps.set-tags.outputs.tags }} tags: ${{ steps.set-tags.outputs.tags }}
target: prod target: prod
# Do not delete
# - name: Build and push Docker image
# id: push
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ./docker/dockerfile/Dockerfile
# push: true
# tags: ${{ steps.meta.outputs.tags }}
# labels: ${{ steps.meta.outputs.labels }}
# target: prod
# - name: Generate artifact attestation
# uses: actions/attest-build-provenance@v2
# with:
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
# subject-digest: ${{ steps.push.outputs.digest }}
# push-to-registry: true
+1 -1
View File
@@ -1,5 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
# dependencies # dependencies
/node_modules /node_modules
/.pnp /.pnp
Binary file not shown.
+3 -1
View File
@@ -20,7 +20,6 @@
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43) ![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
</p> </p>
</div> </div>
@@ -178,6 +177,9 @@ S3_USE_SSL=true
# Storage Backend: 'local' or 's3' # Storage Backend: 'local' or 's3'
STORAGE_TYPE=local STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
``` ```
### Semantic Versioning ### Semantic Versioning
+9 -38
View File
@@ -1,49 +1,20 @@
"use client" import React from "react";
import React, {useEffect, useState} from "react"; import {redirect} from "next/navigation";
import {LayoutAdmin} from "@/components/layout"; import {currentUser} from "@/lib/auth/current-user";
import Image from "next/image"; import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
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 user = await currentUser();
const { resolvedTheme } = useTheme(); if (user && !user.banned && user.role !== "pending") {
const [mounted, setMounted] = useState(false); redirect("/dashboard/home");
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 (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 "> <div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md"> <div className="mx-auto w-full max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2"> <AuthLogoSection/>
<img
className="p-12 text-black dark:text-white"
src={imageTheme}
// loading="eager"
alt="Logo"
// width={1024}
// height={1024}
/>
<span
className="text-sm text-muted-foreground -ml-12 -mb-12">
v{env.NEXT_PUBLIC_PROJECT_VERSION}
</span>
</div>
<div>{children}</div> <div>{children}</div>
</div> </div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground"> <footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
+11 -23
View File
@@ -1,32 +1,20 @@
"use client" import {notFound} from "next/navigation";
import {env} from "@/env.mjs";
import {useEffect, useRef, useState} from "react";
import {useSearchParams} from "next/navigation";
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form"; import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
import {toast} from "sonner"; import {Metadata} from "next";
export default function SignInPage(props: { export const metadata: Metadata = {
searchParams: Promise<{ callbackUrl: string | undefined }> title: "Login",
}) { };
const [urlParams, setUrlParams] = useState<URLSearchParams>(); export default async function SignInPage() {
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
useEffect(() => { if (!authGoogleEnabled) {
const urlParams = new URLSearchParams(window.location.search); notFound()
setUrlParams(urlParams);
const error = urlParams.get("error");
console.log(urlParams.get("redirect"));
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
} }
}, []);
return ( return (
<div className="mx-auto grid w-full gap-6"> <div className="mx-auto grid w-full gap-6">
<LoginForm/> <LoginForm authGoogleEnabled={authGoogleEnabled}/>
</div> </div>
) )
} }
+5
View File
@@ -1,5 +1,10 @@
import {PageParams} from "@/types/next"; import {PageParams} from "@/types/next";
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form"; import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Register",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
return ( return (
@@ -0,0 +1,47 @@
import {notFound} from "next/navigation";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {PageParams} from "@/types/next";
import {Page} from "@/features/layout/page";
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organization/organization-management";
import {buildOrganizationWithMembers} from "@/utils/common";
import {isUUID} from "@/utils/text";
import {user} from "@/db/schema/02_user";
import {invitation} from "@/db/schema/05_invitation";
import {member} from "@/db/schema/04_member";
import {organization} from "@/db/schema/03_organization";
import {user as drizzleUser} from "@/db/schema/02_user";
export default async function RoutePage(props: PageParams<{ organizationId: string }>) {
const {organizationId} = await props.params;
if (!organizationId) {
return notFound();
}
if (!isUUID(organizationId)) {
return notFound();
}
const users = await db.select().from(drizzleUser);
const organizationData = await db
.select({organization, member, user, invitation})
.from(organization)
.leftJoin(member, eq(drizzleDb.schemas.organization.id, member.organizationId))
.leftJoin(invitation, eq(drizzleDb.schemas.invitation.id, invitation.organizationId))
.leftJoin(user, eq(drizzleDb.schemas.member.userId, user.id))
.where(eq(organization.id, organizationId));
const formattedData = buildOrganizationWithMembers(organizationData);
if (!formattedData) return notFound();
return (
<Page>
<OrganizationManagement organization={formattedData} users={users}/>
</Page>
);
}
@@ -3,8 +3,11 @@ 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 {db} from "@/db"; import {db} from "@/db";
import {isNull} from "drizzle-orm"; import {isNull} from "drizzle-orm";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Admin",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({ const users = await db.query.user.findMany({
@@ -14,17 +17,28 @@ export default async function RoutePage(props: PageParams<{}>) {
} }
}); });
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
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"),
}); });
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle>Administration Panel</PageTitle> <PageTitle>Administration Panel</PageTitle>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<AdminTabs settings={settings!} users={users}/> <AdminTabs
organizations={organizations}
settings={settings!}
users={users}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -8,17 +8,15 @@ import {Card, CardContent, CardHeader, CardTitle} 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/project-card/project-database-card"; import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key"; 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 {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"; import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
import {Server} from "lucide-react"; import {Server} from "lucide-react";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
export default async function RoutePage(props: PageParams<{ agentId: string }>) { export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -31,11 +29,11 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
} }
}) })
if (!agent) { if (!agent) {
notFound() notFound()
} }
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return ( return (
<Page> <Page>
@@ -48,7 +46,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</Link> </Link>
</PageTitle> </PageTitle>
<PageActions className="justify-between"> <PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} /> <ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions> </PageActions>
</div> </div>
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
@@ -58,7 +56,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle> <CardTitle className="text-sm font-medium">Databases</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{agent.databases.length}</div> <div className="text-2xl font-bold">{agent.databases.length}</div>
@@ -69,7 +67,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last contact</CardTitle> <CardTitle className="text-sm font-medium">Last contact</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" /> <Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div> <div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
@@ -83,7 +81,9 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
Edge Key Edge Key
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<AgentCardKey agent={agent}/> <AgentCardKey
edgeKey={edgeKey}
/>
</CardContent> </CardContent>
</Card> </Card>
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/> <CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
@@ -1,6 +1,11 @@
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/agent-form/agent-form"; import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Create Agent",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
return ( return (
@@ -9,7 +14,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Create new agent</PageTitle> <PageTitle>Create new agent</PageTitle>
</PageHeader> </PageHeader>
<PageContent> <PageContent>
<AgentForm /> <AgentForm/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -1,18 +1,19 @@
import { PageParams } from "@/types/next"; import {PageParams} from "@/types/next";
import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card"; 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 * as drizzleDb from "@/db";
import {eq, not} from "drizzle-orm";
import {and, eq, not} from "drizzle-orm";
import {Plus} from "lucide-react";
import {cn} from "@/lib/utils";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
export const dynamic = "force-dynamic"; import {Metadata} from "next";
export const metadata: Metadata = {
title: "Agents",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
@@ -39,7 +40,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</PageHeader> </PageHeader>
<PageContent> <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}/>
) : ( ) : (
<EmptyStatePlaceholder <EmptyStatePlaceholder
url={"/dashboard/agents/new"} url={"/dashboard/agents/new"}
@@ -11,7 +11,7 @@ import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm"; import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services"; import {getOrganizationProjectDatabases} from "@/lib/services";
import {getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet"; import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
@@ -22,8 +22,9 @@ export default async function RoutePage(props: PageParams<{
const {projectId, databaseId} = await props.params; const {projectId, databaseId} = await props.params;
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) { if (!organization || !activeMember) {
notFound(); notFound();
} }
@@ -83,6 +84,9 @@ export default async function RoutePage(props: PageParams<{
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
@@ -90,6 +94,7 @@ export default async function RoutePage(props: PageParams<{
<div className=" w-full md:w-fit"> <div className=" w-full md:w-fit">
{capitalizeFirstLetter(dbItem.name)} {capitalizeFirstLetter(dbItem.name)}
</div> </div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full"> <div className="flex items-center gap-2 md:justify-between w-full">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Do not delete*/} {/* Do not delete*/}
@@ -101,6 +106,7 @@ export default async function RoutePage(props: PageParams<{
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/> <BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div> </div>
</div> </div>
)}
</PageTitle> </PageTitle>
</div> </div>
@@ -110,7 +116,7 @@ export default async function RoutePage(props: PageParams<{
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups} <DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/> totalBackups={totalBackups}/>
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore} <DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
backups={backups} backups={backups}
restorations={restorations}/> restorations={restorations}/>
</PageContent> </PageContent>
@@ -12,7 +12,7 @@ import {notFound, redirect} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
@@ -24,6 +24,8 @@ export default async function RoutePage(props: PageParams<{
} = await props.params; } = await props.params;
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) { if (!organization) {
notFound(); notFound();
} }
@@ -48,23 +50,27 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects"); redirect("/dashboard/projects");
} }
const isMember = activeMember?.role === "member";
return ( return (
<Page> <Page>
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center"> <PageTitle className="flex items-center">
{capitalizeFirstLetter(proj.name)} {capitalizeFirstLetter(proj.name)}
<Link className={buttonVariants({variant: "outline"})} href={`/dashboard/projects/${proj.id}/edit`}> {!isMember && (
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/> <GearIcon className="w-7 h-7"/>
</Link> </Link>
)}
</PageTitle> </PageTitle>
{!isMember && (
<PageActions className="justify-between"> <PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/> <ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions> </PageActions>
)}
</div> </div>
<PageDescription>The list of associated databases</PageDescription> <PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? ( {proj.databases.length > 0 ? (
<CardsWithPagination <CardsWithPagination
@@ -8,11 +8,12 @@ import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ }>) {
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
if (!organization ) { if (!organization) {
notFound(); notFound();
} }
@@ -7,12 +7,18 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card"; 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 {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {Metadata} from "next";
export default async function RoutePage(props: PageParams<{ }>) { export const metadata: Metadata = {
title: "Projects",
};
export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) { if (!organization) {
notFound(); notFound();
@@ -28,12 +34,14 @@ export default async function RoutePage(props: PageParams<{ }>) {
databases: true, databases: true,
}, },
}); });
const isMember = activeMember?.role === "member";
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle>Projects</PageTitle> <PageTitle>Projects</PageTitle>
{projects.length > 0 && ( {(projects.length > 0 && !isMember) && (
<PageActions> <PageActions>
<Link href={`/dashboard/projects/new`}> <Link href={`/dashboard/projects/new`}>
<Button>+ Create Project</Button> <Button>+ Create Project</Button>
@@ -44,13 +52,17 @@ export default async function RoutePage(props: PageParams<{ }>) {
<PageContent> <PageContent>
{projects.length > 0 ? ( {projects.length > 0 ? (
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard} <CardsWithPagination
cardsPerPage={4} numberOfColumns={1}/> organizationSlug={organization.slug}
) : ( data={projects}
<EmptyStatePlaceholder cardItem={ProjectCard}
url={"/dashboard/projects/new"} cardsPerPage={4}
text={"Create new Project"} numberOfColumns={1}
/> />
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
) : (
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
)} )}
</PageContent> </PageContent>
</Page> </Page>
@@ -18,7 +18,6 @@ export default async function RoutePage(props: PageParams<{
where: (fields) => isNull(fields.deletedAt) where: (fields) => isNull(fields.deletedAt)
}); });
if (!user || !users || !organization || organization.slug == "default") { if (!user || !users || !organization || organization.slug == "default") {
notFound(); notFound();
} }
@@ -10,7 +10,11 @@ import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-
import { import {
SettingsOrganizationMembersTable SettingsOrganizationMembersTable
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table"; } from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Settings",
};
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({});
@@ -22,10 +26,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
} }
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
const isOwner = activeMember?.role === "owner";
if (isMember) {
notFound();
}
return ( return (
<Page> <Page>
@@ -37,14 +38,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
)} )}
</PageTitle> </PageTitle>
<PageActions> <PageActions>
{!isMember && organization.slug !== "default" && ( {isOwner && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/> <DeleteOrganizationButton organizationSlug={organization.slug}/>
)} )}
</PageActions> </PageActions>
</PageHeader> </PageHeader>
{/*<PageDescription>*/}
{/* Manage your organization settings.*/}
{/*</PageDescription>*/}
<PageContent> <PageContent>
<SettingsOrganizationMembersTable organization={organization}/> <SettingsOrganizationMembersTable organization={organization}/>
</PageContent> </PageContent>
@@ -8,7 +8,12 @@ import {db} from "@/db";
import {and, 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"; import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Statistics",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const organization = await getOrganization({}); const organization = await getOrganization({});
@@ -45,79 +50,6 @@ export default async function RoutePage(props: PageParams<{}>) {
}); });
// const tomorrow = new Date();
// tomorrow.setDate(tomorrow.getDate() + 1);
//
// const before = new Date();
// before.setDate(before.getDate() - 1);
//
// const backupsEvolution = [
// {
// id: '22e84aa4-228c-45b3-82ec-846a639cd509',
// createdAt: new Date()
// },
// {
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
// createdAt: new Date()
// },
// {
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
// createdAt: new Date()
// },
// {
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
// createdAt: new Date()
// },
// {
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
// createdAt: new Date(before)
// },
// {
// id: '6a6106fe-7f45-48eb-a56f-0a1e734126a1',
// createdAt: new Date(before)
// },
// {
// id: 'a529d790-502e-4609-ad37-9b1c00c73477',
// createdAt: new Date()
// },
// {
// id: 'a8c105a8-3e29-423e-b7dd-d3218092cde1',
// createdAt: new Date()
// },
// {
// id: 'd33aaf4f-8525-4490-addb-12e3c8650d6d',
// createdAt: new Date()
// },
// {
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
// createdAt: new Date(tomorrow)
// },
// {
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
// createdAt: new Date(tomorrow)
// },
// {
// id: 'e88a588a-2353-4470-9976-8c3eb2ffc88d',
// createdAt: new Date()
// },
// {
// id: 'ee11441e-4b41-4c1b-9d91-929565b4204a',
// createdAt: new Date()
// },
//
// // Entries with tomorrow's date
// {
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
// createdAt: new Date(tomorrow)
// },
// {
// id: 'c0a8323d-9241-4896-9e64-01e905c24e51',
// createdAt: new Date(tomorrow)
// }
// ];
const backupsRate = await db const backupsRate = await db
.select({ .select({
createdAt: drizzleDb.schemas.backup.createdAt, createdAt: drizzleDb.schemas.backup.createdAt,
@@ -130,7 +62,6 @@ export default async function RoutePage(props: PageParams<{}>) {
.orderBy(drizzleDb.schemas.backup.createdAt); .orderBy(drizzleDb.schemas.backup.createdAt);
const restorationsCountResult = await db const restorationsCountResult = await db
.select({ .select({
count: count(), count: count(),
@@ -139,60 +70,84 @@ export default async function RoutePage(props: PageParams<{}>) {
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds)); .where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
const restorationsCount = restorationsCountResult[0]?.count ?? 0; const restorationsCount = restorationsCountResult[0]?.count ?? 0;
const projectsCount = projects.length; const projectsCount = projects.length;
const backupsEvolutionCount = backupsEvolution.length; const backupsEvolutionCount = backupsEvolution.length;
const sortedBackupsEvolution = backupsEvolution.sort( const sortedBackupsEvolution = backupsEvolution.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
); );
const Placeholder = ({text}: { text: string }) => (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
);
return ( return (
<Page> <Page>
<PageHeader> <PageHeader>
<PageTitle>Statistics</PageTitle> <PageTitle>Statistics Overview</PageTitle>
</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="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="w-full flex-1"> <Card className="w-full">
<CardHeader className="flex items-center gap-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<Folder className="w-5 h-5 text-muted-foreground" /> <CardTitle className="text-sm font-medium">Projects</CardTitle>
<CardTitle>Projects</CardTitle> <Building2 className="h-4 w-4 text-muted-foreground"/>
</CardHeader> </CardHeader>
<CardContent className="text-3xl font-bold">{projectsCount}</CardContent> <CardContent>
<div className="text-2xl font-bold">{projectsCount}</div>
<p className="text-xs text-muted-foreground">Active projects in this organization</p>
</CardContent>
</Card> </Card>
<Card className="w-full flex-1">
<CardHeader className="flex items-center gap-2"> <Card className="w-full">
<DatabaseBackup className="w-5 h-5 text-muted-foreground" /> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle>Backups</CardTitle> <CardTitle className="text-sm font-medium">Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</CardHeader> </CardHeader>
<CardContent className="text-3xl font-bold">{backupsEvolutionCount}</CardContent> <CardContent>
<div className="text-2xl font-bold">{backupsEvolutionCount}</div>
<p className="text-xs text-muted-foreground">Total backups executed across all databases</p>
</CardContent>
</Card> </Card>
<Card className="w-full flex-1">
<CardHeader className="flex items-center gap-2"> <Card className="w-full">
<RefreshCcw className="w-5 h-5 text-muted-foreground" /> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle>Restorations</CardTitle> <CardTitle className="text-sm font-medium">Restorations</CardTitle>
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
</CardHeader> </CardHeader>
<CardContent className="text-3xl font-bold">{restorationsCount}</CardContent> <CardContent>
<div className="text-2xl font-bold">{restorationsCount}</div>
<p className="text-xs text-muted-foreground">Total restoration operations performed</p>
</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">
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
<CardTitle>Evolution of the number of backups</CardTitle> <CardTitle>Evolution of the number of backups</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{sortedBackupsEvolution.length > 0 ? (
<EvolutionLineChart data={sortedBackupsEvolution}/> <EvolutionLineChart data={sortedBackupsEvolution}/>
) : (
<Placeholder text="No backup data available"/>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card className="w-full"> <Card className="w-full">
<CardHeader> <CardHeader>
<CardTitle>Success rate of backups</CardTitle> <CardTitle>Success rate of backups</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{backupsRate.length > 0 ? (
<PercentageLineChart data={backupsRate}/> <PercentageLineChart data={backupsRate}/>
) : (
<Placeholder text="No backup rate data available"/>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+5
View File
@@ -8,6 +8,11 @@ import {db} from "@/db";
import {asc, inArray} from "drizzle-orm"; import {asc, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth"; import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Home",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
+6 -7
View File
@@ -7,6 +7,11 @@ import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/butto
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload"; 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";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Profile",
};
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser(); const user = await currentUser();
@@ -39,11 +44,8 @@ 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">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</PageActions>*/}
</div> </div>
<PageContent > <PageContent>
<UserForm <UserForm
userId={user.id} userId={user.id}
sessions={sessions} accounts={accounts} sessions={sessions} accounts={accounts}
@@ -53,9 +55,6 @@ export default async function RoutePage(props: PageParams<{}>) {
role: user.role ?? undefined, role: user.role ?? undefined,
}} }}
/> />
{/*<div className="mt-4 sm:hidden ">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</div>*/}
</PageContent> </PageContent>
</Page> </Page>
); );
+49
View File
@@ -1,2 +1,51 @@
import fs from "node:fs";
import forge from "node-forge";
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string ): Promise<File> {
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
// Decrypt AES key with RSA-OAEP
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
md: forge.md.sha256.create(),
mgf1: {md: forge.md.sha256.create()},
});
// Read encrypted file content
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
const iv = forge.util.hexToBytes(ivHex);
// AES decryption
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
decipher.start({iv});
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
const success = decipher.finish();
if (!success) {
throw new Error("Decryption failed");
}
const decryptedBytes = decipher.output.getBytes();
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
// Return a File so you can use file.arrayBuffer() later
return new File(
[decryptedBuffer],
file.name.replace(/\.enc$/, fileExtension),
{type: "application/octet-stream"}
);
}
export function getFileExtension(dbType: string) {
switch (dbType) {
case "postgresql":
return ".dump";
case "mysql":
return ".sql";
default:
return ".dump";
}
}
+14 -4
View File
@@ -3,12 +3,13 @@ import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action"; import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route"; import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {Backup} from "@/db/schema/07_database"; import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {env} from "@/env.mjs"; import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers";
export async function POST( export async function POST(
request: Request, request: Request,
@@ -27,6 +28,8 @@ export async function POST(
const agentId = (await params).agentId; const agentId = (await params).agentId;
const formData = await request.formData(); const formData = await request.formData();
const aesKeyHex = formData.get("aes_key") as string;
const ivHex = formData.get("iv") as string;
const generatedId = formData.get("generatedId") as string | null; const generatedId = formData.get("generatedId") as string | null;
const method = formData.get("method") as string | null; const method = formData.get("method") as string | null;
@@ -102,16 +105,22 @@ export async function POST(
if (status === "success") { if (status === "success") {
const file = formData.get("file") as File | null; const file = formData.get("file") as File | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
if (!file) { if (!file) {
return NextResponse.json( return NextResponse.json(
{error: "File is required for successful backup"}, {error: "File is required for successful backup"},
{status: 400} {status: 400}
); );
} }
const fileExtension = getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4(); const uuid = uuidv4();
const fileName = `${uuid}.dump`; const fileName = `${uuid}${fileExtension}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await decryptedFile.arrayBuffer());
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) { if (!settings) {
@@ -174,3 +183,4 @@ export async function POST(
); );
} }
} }
-6
View File
@@ -5,7 +5,6 @@ import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string
status: string status: string
@@ -13,7 +12,6 @@ export type BodyResultRestore = {
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } {params}: { params: Promise<{ agentId: string }> }
@@ -27,7 +25,6 @@ export async function POST(
console.log(body) console.log(body)
if (!isUuidv4(body.generatedId)) { if (!isUuidv4(body.generatedId)) {
return NextResponse.json( return NextResponse.json(
{error: "generatedId is not a valid uuid"}, {error: "generatedId is not a valid uuid"},
@@ -71,10 +68,7 @@ export async function POST(
eventEmitter.emit('modification', {update: true}); eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200}) return Response.json(response, {status: 200})
} catch (error) { } catch (error) {
console.error('Error in POST handler:', error); console.error('Error in POST handler:', error);
return NextResponse.json( return NextResponse.json(
+1 -2
View File
@@ -124,13 +124,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
try { try {
if (settings.storage == "local") { if (settings.storage == "local") {
data = await getFileUrlPresignedLocal(fileName!) data = await getFileUrlPresignedLocal({fileName: fileName!})
} else if (settings.storage == "s3") { } else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`); data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
} }
if (data?.data?.success) { if (data?.data?.success) {
urlBackup = data.data.value ?? ""; urlBackup = data.data.value ?? "";
} else { } else {
+1 -1
View File
@@ -20,7 +20,7 @@ export type Body = {
// Function to test the get file url presigned local // Function to test the get file url presigned local
export async function GET(request: Request) { export async function GET(request: Request) {
const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump") const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
return Response.json({ return Response.json({
message: url message: url
}) })
+9
View File
@@ -0,0 +1,9 @@
import {NextResponse} from "next/server";
export async function GET() {
return NextResponse.json({
PROJECT_URL: process.env.PROJECT_URL,
PROJECT_NAME: process.env.PROJECT_NAME,
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
});
}
+22 -12
View File
@@ -1,9 +1,19 @@
import { EventEmitter } from 'events'; import {EventEmitter} from 'events';
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {NextResponse} from "next/server";
export const eventEmitter = new EventEmitter(); export const eventEmitter = new EventEmitter();
export async function GET(request: Request) { export async function GET(request: Request) {
console.log('GET request received');
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.json({error: "Unauthorized"}, {status: 403});
}
return new Response( return new Response(
new ReadableStream({ new ReadableStream({
@@ -37,13 +47,13 @@ export async function GET(request: Request) {
); );
} }
export async function POST(request: Request) { // export async function POST(request: Request) {
console.log('POST request received'); // console.log('POST request received');
const data = await request.json(); // const data = await request.json();
console.log('Data received:', data); // console.log('Data received:', data);
//
// Emit the event to all connected clients // // Emit the event to all connected clients
eventEmitter.emit('modification', data); // eventEmitter.emit('modification', data);
//
return new Response('Event sent', { status: 200 }); // return new Response('Event sent', {status: 200});
} // }
+9 -9
View File
@@ -12,16 +12,16 @@ export async function GET(
const expires = searchParams.get('expires'); const expires = searchParams.get('expires');
const fileName = (await params).fileName const fileName = (await params).fileName
const privateLocalDir = "private/uploads/files/"; const uploadsDir = "private/uploads/files/";
const filePath = path.join(privateLocalDir, fileName); const uploadPath = path.join(uploadsDir, fileName);
const crypto = require('crypto'); const crypto = require('crypto');
if (!fs.existsSync(filePath)) { let filePath = null;
return NextResponse.json( if (fs.existsSync(uploadPath)) {
{error: 'File not found'}, filePath = uploadPath;
{status: 404} } else {
); return NextResponse.json({error: "File not found"}, {status: 404})
} }
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex'); const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
@@ -31,8 +31,8 @@ export async function GET(
{status: 403} {status: 403}
); );
} }
//@ts-ignore
const expiresAt = parseInt(expires, 10); const expiresAt = parseInt(expires!, 10);
if (Date.now() > expiresAt) { if (Date.now() > expiresAt) {
return NextResponse.json( return NextResponse.json(
{error: 'Signed token expired'}, {error: 'Signed token expired'},
+96 -31
View File
@@ -1,38 +1,103 @@
import {NextResponse} from "next/server";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
import {env} from "@/env.mjs";
import * as stream from "node:stream";
import path from "path"; import path from "path";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import fs from "fs/promises"; import fs from "fs/promises";
import { NextResponse } from "next/server";
export async function GET( function nodeStreamToWebStream(nodeStream: stream.Readable) {
request: Request, return new ReadableStream({
{params}: { params: Promise<{ fileName: string }> } start(controller) {
) { nodeStream.on("data", chunk => controller.enqueue(chunk));
nodeStream.on("end", () => controller.close());
try { nodeStream.on("error", err => controller.error(err));
const fileName = (await params).fileName; },
cancel() {
nodeStream.destroy();
console.log("fileName", fileName);
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
// Check if the file exists
try {
await fs.access(filePath); // Ensures the file exists
} catch {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
// Read the file
const fileContent = await fs.readFile(filePath); // Returns a Buffer
return new NextResponse(fileContent, {
headers: {
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Type": "application/octet-stream", // Adjust MIME type as needed
}, },
}); });
} catch (error) { }
console.error("Error reading file:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); const privateS3ImageDir = "images/";
export async function GET(
req: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
const fileName = (await params).fileName;
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
const session = await auth.api.getSession({headers: await headers()});
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) throw new Error("System settings not found.");
const storageType = settings.storage; // "local" or "s3"
const ext = fileName.split(".").pop()?.toLowerCase();
const contentType =
ext === "png"
? "image/png"
: ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "gif"
? "image/gif"
: ext === "webp"
? "image/webp"
: "application/octet-stream";
try {
if (storageType === "local") {
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
try {
await fs.access(filePath);
const file = await fs.readFile(filePath);
return new NextResponse(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch {
// if not found locally, fallback to S3
}
}
const exists = await checkFileExistsInBucket({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
const nodeStream = await getObjectFromClient({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
const webStream = nodeStreamToWebStream(nodeStream);
return new NextResponse(webStream, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch (err) {
console.error("Error streaming image:", err);
return NextResponse.json({error: "Error fetching file"}, {status: 500});
} }
} }
-23
View File
@@ -1,23 +0,0 @@
import { NextResponse } from "next/server";
export type BodyInit = {
initialize: boolean;
};
export async function POST(request: Request) {
try {
const body: BodyInit = await request.json();
console.log(body);
return NextResponse.json(
{
message: "Initialization successfully done!",
},
{ status: 200 }
);
} catch (error) {
console.error("Error in POST initialization:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+7 -4
View File
@@ -1,16 +1,19 @@
import React from "react"; import React from "react";
import type {Metadata} from "next"; import type {Metadata} from "next";
import {Inter} from "next/font/google";
import "./globals.css"; import "./globals.css";
import {Providers} from "./providers"; import {Providers} from "./providers";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer"; import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
import {inter} from "@/fonts/fonts";
const inter = Inter({subsets: ["latin"]}); const title = process.env.PROJECT_NAME ?? "App Title";
export const metadata: Metadata = { export const metadata: Metadata = {
title: process.env.NEXT_PUBLIC_PROJECT_NAME ?? "App Title", title: {
description: process.env.NEXT_PUBLIC_PROJECT_DESCRIPTION ?? undefined, default: title,
template: `%s - ${title}`
},
description: process.env.PROJECT_DESCRIPTION ?? undefined,
}; };
export default function RootLayout({ export default function RootLayout({
+2
View File
@@ -1,3 +1,4 @@
import BackButton from "@/components/wrappers/common/button/back-button";
export default async function NotFound() { export default async function NotFound() {
return( return(
@@ -7,6 +8,7 @@ export default async function NotFound() {
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1> <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> <p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
</div> </div>
<BackButton>Go home</BackButton>
</div> </div>
</div> </div>
) )
+17 -20
View File
@@ -3,22 +3,20 @@ name: portabase-prod
services: services:
app: app:
build: # build:
context: . # context: .
dockerfile: docker/dockerfile/Dockerfile # dockerfile: docker/dockerfile/Dockerfile
target: prod # target: prod
image: solucetechnologies/portabase:1.1.3-rc.3
ports: ports:
- '8887:80' - '8887:80'
env_file: env_file:
- .env - .env
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
container_name: portabase-app-prod container_name: portabase-app-prod
db: db:
image: postgres:16-alpine image: postgres:16-alpine
ports: ports:
@@ -35,20 +33,19 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# s3:
s3: # 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}
volumes: volumes:
postgres-data: postgres-data:
minio_data: # minio_data:
-52
View File
@@ -1,25 +1,6 @@
name: portabase-dev name: portabase-dev
services: services:
# app:
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: dev
# ports:
# - "8887:8887"
# environment:
# - TIME_ZONE="Europe/Paris"
# - NODE_ENV=development
# depends_on:
# db:
# condition: service_healthy
# volumes:
# - .:/app
# - /app/node_modules
# container_name: portabase-app
#
db: db:
image: postgres:16-alpine image: postgres:16-alpine
ports: ports:
@@ -36,38 +17,5 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
# db-pgadmin:
# image: dpage/pgadmin4
# environment:
# PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
# PGADMIN_DEFAULT_PASSWORD: "changeme"
# PGADMIN_CONFIG_SERVER_MODE: "False"
# POSTGRES_USER: "devuser"
# POSTGRES_PASSWORD: "changeme"
# volumes:
# - pgadmin-data:/var/lib/pgadmin
# ports:
# - "8080:80"
# restart: unless-stopped
# depends_on:
# - db
s3:
container_name: s3-portabase-dev
image: docker.io/bitnami/minio:latest
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
environment:
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
- MINIO_BROWSER=on
volumes: volumes:
postgres-data: postgres-data:
minio_data:
# pgadmin-data:
-3
View File
@@ -94,9 +94,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs src/db ./src/db COPY --chown=nextjs:nodejs src/db ./src/db
USER root USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
+1 -7
View File
@@ -1,11 +1,5 @@
#!/bin/bash #!/bin/bash
#
#npx drizzle-kit generate
#npx drizzle-kit migrate
#
#npm run dev
#
#exec "$@"
set -euo pipefail set -euo pipefail
echo "▶ Running Drizzle codegen..." echo "▶ Running Drizzle codegen..."
@@ -1,14 +1,5 @@
#!/bin/bash #!/bin/bash
#echo " ____ __ __ "
#echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
#echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
#echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
#echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
#echo " "
#echo " Community Edition v1.1.1 "
#echo " "
node server.js node server.js
exec "$@" exec "$@"
+16
View File
@@ -0,0 +1,16 @@
import {defineConfig, globalIgnores} from 'eslint/config'
import nextVitals from 'eslint-config-next/core-web-vitals'
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
'.next/**',
'out/**',
'build/**',
'next-env.d.ts',
]),
])
export default eslintConfig
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" /> import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-3
View File
@@ -41,9 +41,6 @@ const nextConfig: NextConfig = {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
eslint: {
ignoreDuringBuilds: true,
},
async headers() { async headers() {
return [ return [
{ {
+8 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.1.2", "version": "1.1.3",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -65,16 +65,17 @@
"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.5.2", "next": "16.0.0",
"next-safe-action": "^7.10.8", "next-safe-action": "^7.10.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.3.1",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1", "npm-check-updates": "^18.0.1",
"pg": "^8.16.0", "pg": "^8.16.0",
"react": "19.1.0", "react": "^19.2.0",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "19.1.0", "react-dom": "^19.2.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-email": "^4.0.13", "react-email": "^4.0.13",
"react-hook-form": "^7.56.3", "react-hook-form": "^7.56.3",
@@ -95,14 +96,15 @@
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18", "@types/node": "^22.15.18",
"@types/node-forge": "^1",
"@types/pg": "^8.15.2", "@types/pg": "^8.15.2",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2", "@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2", "@zenstackhq/tanstack-query": "^2.14.2",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.1",
"eslint": "^9.26.0", "eslint": "^9.39.0",
"eslint-config-next": "15.3.2", "eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-plugin-tailwindcss": "^3.18.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
+14 -28
View File
@@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from "next/server"; import {NextRequest, NextResponse} from "next/server";
import { loggingMiddleware } from "@/middleware/loggingMiddleware"; 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 proxy(request: NextRequest) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
const redirectUrl = encodeURIComponent(request.nextUrl.pathname) const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
@@ -13,40 +12,33 @@ export async function middleware(request: NextRequest) {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: await headers(),
}); });
if (!session) { if (!session) {
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url)); return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
} }
if (session.user.banned) { if (session.user.banned) {
signOut(); await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL("/login?error=banned", request.url)); return NextResponse.redirect(new URL("/login?error=banned", request.url));
} }
if (session.user.role === "pending") { if (session.user.role === "pending") {
signOut(); await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url)); return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
} }
if (url.pathname === "/dashboard") { if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url)); return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
} }
return NextResponse.next(); return NextResponse.next();
} }
// Exclude `/api/auth` and its subpaths
if (url.pathname.startsWith("/api/auth")) { if (url.pathname.startsWith("/api/auth")) {
return NextResponse.next(); return NextResponse.next();
} }
if (url.pathname.startsWith("/api")) { if (url.pathname.startsWith("/api")) {
const routeExists = checkRouteExists(url.pathname); const routeExists = checkRouteExists(url.pathname);
// If the route does not exist, return a 404 JSON response
if (!routeExists) { if (!routeExists) {
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), { return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
status: 404, status: 404,
headers: { "Content-Type": "application/json" }, headers: {"Content-Type": "application/json"},
}); });
} }
} }
@@ -56,31 +48,25 @@ export async function middleware(request: NextRequest) {
errorHandler(err); errorHandler(err);
} }
} }
// Function to check if the route exists (supports dynamic routes)
function checkRouteExists(pathname: string) { function checkRouteExists(pathname: string) {
// Define static and dynamic routes with patterns
const routePatterns = [ const routePatterns = [
//do not delete /^\/api\/agent\/[^/]+\/status\/?$/,
// /^\/api\/auth\/\d+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
// /^\/api\/auth\/\w+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
// /^\/api\/agent\/healthcheck\/\w+$/, // Dynamic route with an alphanumeric parameter (e.g., /api/user/username)
/^\/api\/agent\/[^/]+\/status\/?$/, // Dynamic route for /api/agent/[id]/status
/^\/api\/agent\/[^/]+\/backup\/?$/, /^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/, /^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/, /^\/api\/files\/[^/]+\/?$/,
/^\/api\/images\/[^/]+\/?$/, /^\/api\/images\/[^/]+\/?$/,
/^\/api\/events\/?$/, /^\/api\/events\/?$/,
/^\/api\/init\/?$/, /^\/api\/init\/?$/,
/^\/api\/config\/?$/,
]; ];
return routePatterns.some((pattern) => pattern.test(pathname)); return routePatterns.some((pattern) => pattern.test(pathname));
} }
export const config = { export const config = {
runtime: "nodejs",
matcher: [ matcher: [
// '/api/agent/:path*',
"/api/:path*", "/api/:path*",
"/dashboard/:path*", "/dashboard/:path*",
"/dashboard", "/dashboard",
], ]
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

+191
View File
@@ -0,0 +1,191 @@
"use client"
import type React from "react"
import { useState, useRef, useEffect, forwardRef } from "react"
import { Search, X } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export interface IEntry {
value: string
label: string
}
interface SearchInputProps {
value?: IEntry
onChange?: (value: IEntry) => void
onSelect?: (value: IEntry) => void
name?: string
placeholder?: string
entries?: IEntry[]
disabled?: boolean
className?: string
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
(
{
value: controlledValue,
onChange,
onSelect,
name,
placeholder = "Search entries...",
entries = [],
disabled = false,
className,
...props
},
ref,
) => {
const [internalValue, setInternalValue] = useState<IEntry | null>(null)
const [isOpen, setIsOpen] = useState(false)
const [filteredEntries, setFilteredEntries] = useState<IEntry[]>([])
const [selectedIndex, setSelectedIndex] = useState(-1)
const internalRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLUListElement>(null)
const query = controlledValue?.label ?? internalValue?.label ?? ""
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
// Filter entries based on query
useEffect(() => {
if (query.trim()) {
const filtered = entries.filter((entry) =>
entry.label.toLowerCase().includes(query.toLowerCase()),
)
setFilteredEntries(filtered)
setSelectedIndex(-1)
} else {
setFilteredEntries([])
}
}, [query, entries])
const handleValueChange = (newValue: IEntry | null) => {
if (controlledValue === undefined) {
setInternalValue(newValue)
}
if (newValue) {
onChange?.(newValue)
}
}
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || filteredEntries.length === 0) return
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setSelectedIndex((prev) =>
prev < filteredEntries.length - 1 ? prev + 1 : 0,
)
break
case "ArrowUp":
e.preventDefault()
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : filteredEntries.length - 1,
)
break
case "Enter":
e.preventDefault()
if (selectedIndex >= 0) {
handleSelect(filteredEntries[selectedIndex])
}
break
case "Escape":
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.blur()
break
}
}
const handleSelect = (entry: IEntry) => {
handleValueChange(entry)
onSelect?.(entry)
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.blur()
}
const clearSearch = () => {
handleValueChange(null)
setIsOpen(false)
setSelectedIndex(-1)
inputRef.current?.focus()
}
return (
<div className={cn("relative w-full", className)}>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
{...props}
ref={inputRef}
type="text"
name={name}
placeholder={placeholder}
value={query}
disabled={disabled}
onChange={(e) => {
const newLabel = e.target.value
handleValueChange({ value: newLabel, label: newLabel })
}}
onFocus={() => !disabled && setIsOpen(true)}
onBlur={() => {
// Delay closing to allow clicking on entries
setTimeout(() => setIsOpen(false), 150)
}}
onKeyDown={handleKeyDown}
className="pl-10 pr-10"
/>
{query && !disabled && (
<Button
variant="ghost"
size="sm"
onClick={clearSearch}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 hover:bg-muted"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{/* Results dropdown */}
{isOpen && !disabled && filteredEntries.length > 0 && (
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
<ul ref={listRef} className="max-h-60 overflow-auto py-1" role="listbox">
{filteredEntries.map((entry, index) => (
<li
key={entry.value}
role="option"
aria-selected={index === selectedIndex}
className={cn(
"px-3 py-2 text-sm cursor-pointer transition-colors",
"hover:bg-accent hover:text-accent-foreground",
index === selectedIndex && "bg-accent text-accent-foreground",
)}
onClick={() => handleSelect(entry)}
>
{entry.label}
</li>
))}
</ul>
</div>
)}
{/* No results message */}
{isOpen && !disabled && query && filteredEntries.length === 0 && (
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
<div className="px-3 py-2 text-sm text-muted-foreground">
No results found for "{query}"
</div>
</div>
)}
</div>
)
},
)
SearchInput.displayName = "SearchInput"
@@ -0,0 +1,31 @@
"use client"
import {env} from "@/env.mjs";
import React, {useEffect, useState} from "react";
import {useTheme} from "next-themes";
export const AuthLogoSection = () => {
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="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
<img
className="p-12 text-black dark:text-white"
src={imageTheme}
alt="Logo"
/>
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
</div>
)
}
@@ -1,10 +1,11 @@
"use client"; "use client";
import { Button } from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import { signIn } from "@/lib/auth/auth-client"; import {signIn} from "@/lib/auth/auth-client";
import { JSX } from "react"; import {JSX} from "react";
export type AuthButtonProps = { export type AuthButtonProps = {
providers: SocialProviderType[]; providers: SocialProviderType[];
callBackURL?: string;
}; };
export type SocialProviderType = { export type SocialProviderType = {
@@ -41,7 +42,7 @@ export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
e.preventDefault(); e.preventDefault();
void signIn.social({ void signIn.social({
provider: provider.id, provider: provider.id,
callbackURL: "/dashboard/profile", callbackURL: props.callBackURL ?? "/dashboard/profile",
}); });
}} }}
> >
@@ -1,23 +1,172 @@
// "use client";
//
// import {Card, CardContent, CardHeader} from "@/components/ui/card";
// import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
// import {Input} from "@/components/ui/input";
// import {Form} from "@/components/ui/form";
// import {Button} from "@/components/ui/button";
// import {toast} from "sonner";
// import {useMutation} from "@tanstack/react-query";
// import {TooltipProvider} from "@/components/ui/tooltip";
// import Link from "next/link";
// import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
// import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
// import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
// import {signIn} from "@/lib/auth/auth-client";
// import {useRouter} from "next/navigation";
// import {Icon} from "@iconify/react";
// import {useEffect, useState} from "react";
//
// export type loginFormProps = {
// defaultValues?: LoginType;
// authGoogleEnabled: boolean;
//
// };
//
// export const LoginForm = (props: loginFormProps) => {
// const router = useRouter();
//
// const form = useZodForm({
// schema: LoginSchema,
// });
//
// const [urlParams, setUrlParams] = useState<URLSearchParams>();
//
// useEffect(() => {
// const urlParams = new URLSearchParams(window.location.search);
// console.log(urlParams);
// setUrlParams(urlParams);
// const error = urlParams.get("error");
// console.log(urlParams.get("redirect"));
// if (error?.includes("pending")) {
// toast.error("Your account is not active.");
// urlParams.delete("error");
// window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
// }
// }, []);
//
//
// const mutation = useMutation({
// mutationFn: async (values: LoginType) => {
// const {error} = await signIn.email(
// {
// password: values.password,
// email: values.email,
// callbackURL: urlParams?.get("redirect") ?? "/dashboard/profile",
// }, {
// onSuccess: () => {
// toast.success("Login success");
// },
// });
// if (error) {
// toast.error(error.message);
// }
// },
// });
//
// const availableProviders: SocialProviderType[] = [];
//
// if (props.authGoogleEnabled) {
// availableProviders.push(
// {
// id: "google",
// name: "Google",
// icon: <Icon icon={"logos:google-icon"} width="25" height="25"/>,
// },
// )
// }
//
//
// return (
// <TooltipProvider>
// <Card>
// <CardHeader>
// <div className="grid gap-2 text-center mb-2">
// <h1 className="text-3xl font-bold">Login</h1>
// <p className="text-balance text-muted-foreground">Enter your informations below to login</p>
// </div>
// </CardHeader>
// <CardContent>
// <Form
// form={form}
// className="flex flex-col gap-4"
// onSubmit={async (values) => {
// await mutation.mutateAsync(values);
// }}
// >
// <FormField
// control={form.control}
// name="email"
// defaultValue=""
// render={({field}) => (
// <FormItem>
// <FormLabel>Email</FormLabel>
// <FormControl>
// <Input autoComplete="email webauthn"
// placeholder="exemple@portabase.io" {...field} />
// </FormControl>
// <FormMessage/>
// </FormItem>
// )}
// />
// <FormField
// control={form.control}
// name="password"
// defaultValue=""
// render={({field}) => (
// <FormItem>
// <div className="flex items-center">
// <FormLabel>Password</FormLabel>
// {/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline">
// Forgot your password?
// </Link>*/}
// </div>
// <FormControl>
// <PasswordInput autoComplete="current-password webauthn"
// placeholder="Your password" {...field} />
// </FormControl>
// <FormMessage/>
// </FormItem>
// )}
// />
// <Button>Sign in</Button>
// <div className="mt-4 text-center text-sm">
// Don&apos;t have an account?{" "}
// <Link href={"/register"} className="underline">
// Sign up
// </Link>
// </div>
// </Form>
// <SocialAuthButton
// callBackURL={urlParams?.get("redirect") ?? "/dashboard/profile"}
// providers={availableProviders}/>
// </CardContent>
// </Card>
// </TooltipProvider>
// );
// };
"use client"; "use client";
import { Card, CardContent, CardHeader } from "@/components/ui/card"; import {Card, CardContent, CardHeader} from "@/components/ui/card";
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form"; import {FormControl, 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 { toast } from "sonner"; 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/password-input/password-input"; import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/login-form/login-form.schema"; import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/button-auth/social-auth-button"; 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";
import {useEffect, useState} from "react";
export type loginFormProps = { export type loginFormProps = {
defaultValues?: LoginType; defaultValues?: LoginType;
authGoogleEnabled: boolean;
}; };
export const LoginForm = (props: loginFormProps) => { export const LoginForm = (props: loginFormProps) => {
@@ -27,27 +176,57 @@ export const LoginForm = (props: loginFormProps) => {
schema: LoginSchema, schema: LoginSchema,
}); });
const [urlParams] = useState(() =>
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
);
useEffect(() => {
const error = urlParams.get("error");
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, [urlParams]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: LoginType) => { mutationFn: async (values: LoginType) => {
const { error } = await signIn.email(values, { try {
onSuccess: () => { const callbackURL =
toast.success("Login success"); urlParams.get("redirect")?.startsWith("/")
router.push("/dashboard/profile"); ? urlParams.get("redirect")
}, : "/dashboard/profile";
const {error} = await signIn.email({
email: values.email,
password: values.password,
callbackURL: callbackURL ?? "/dashboard/profile",
}); });
if (error) { if (error) {
toast.error(error.message); toast.error(error.message);
} else {
toast.success("Login success");
} }
} catch (err) {
console.error(err);
toast.error("Unexpected client error during login");
}
},
onError: (err: any) => {
toast.error(err.message || "Client error");
}, },
}); });
const availableProviders: SocialProviderType[] = [ const availableProviders: SocialProviderType[] = [];
{
if (props.authGoogleEnabled) {
availableProviders.push({
id: "google", id: "google",
name: "Google", name: "Google",
icon: <Icon icon={"logos:google-icon"} width="25" height="25" />, icon: <Icon icon="logos:google-icon" width="25" height="25"/>,
}, });
]; }
return ( return (
<TooltipProvider> <TooltipProvider>
@@ -55,7 +234,9 @@ export const LoginForm = (props: loginFormProps) => {
<CardHeader> <CardHeader>
<div className="grid gap-2 text-center mb-2"> <div className="grid gap-2 text-center mb-2">
<h1 className="text-3xl font-bold">Login</h1> <h1 className="text-3xl font-bold">Login</h1>
<p className="text-balance text-muted-foreground">Enter your informations below to login</p> <p className="text-balance text-muted-foreground">
Enter your information below to login
</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -70,13 +251,17 @@ export const LoginForm = (props: loginFormProps) => {
control={form.control} control={form.control}
name="email" name="email"
defaultValue="" defaultValue=""
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input autoComplete="email webauthn" placeholder="exemple@portabase.io" {...field} /> <Input
autoComplete="email"
placeholder="example@portabase.io"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage/>
</FormItem> </FormItem>
)} )}
/> />
@@ -84,30 +269,38 @@ export const LoginForm = (props: loginFormProps) => {
control={form.control} control={form.control}
name="password" name="password"
defaultValue="" defaultValue=""
render={({ field }) => ( render={({field}) => (
<FormItem> <FormItem>
<div className="flex items-center"> <div className="flex items-center">
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
{/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline"> {/* Optional forgot password link */}
Forgot your password?
</Link>*/}
</div> </div>
<FormControl> <FormControl>
<PasswordInput autoComplete="current-password webauthn" placeholder="Your password" {...field} /> <PasswordInput
autoComplete="current-password"
placeholder="Your password"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage/>
</FormItem> </FormItem>
)} )}
/> />
<Button>Sign in</Button> <Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Signing in..." : "Sign in"}
</Button>
<div className="mt-4 text-center text-sm"> <div className="mt-4 text-center text-sm">
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href={"/register"} className="underline"> <Link href="/register" className="underline">
Sign up Sign up
</Link> </Link>
</div> </div>
</Form> </Form>
<SocialAuthButton providers={availableProviders} />
<SocialAuthButton
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
providers={availableProviders}
/>
</CardContent> </CardContent>
</Card> </Card>
</TooltipProvider> </TooltipProvider>
@@ -30,8 +30,8 @@ export const RegisterForm = (props: registerFormProps) => {
await signUp.email(values, { await signUp.email(values, {
onSuccess: () => { onSuccess: () => {
toast.success(`Success`); toast.success(`Success`);
router.push(`/login`);
router.refresh(); router.refresh();
router.push(`/login`);
}, },
onError: (error) => { onError: (error) => {
console.log(error); console.log(error);
@@ -0,0 +1,18 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
type BackButtonProps = React.ComponentProps<typeof Button> & {
children: React.ReactNode;
};
export default function BackButton({ children, ...props }: BackButtonProps) {
const router = useRouter();
return (
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
{children}
</Button>
);
}
@@ -1,8 +1,66 @@
"use client"; // "use client";
//
// import { Button } from "@/components/ui/button";
// import { ButtonHTMLAttributes } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
// export type sizeButton = {
// default: string;
// icon: string;
// sm: string;
// lg: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick: () => void;
// isPending?: boolean;
// size: keyof sizeButton;
// };
//
// export const ButtonWithLoading = ({
// icon,
// text,
// variant,
// className,
// onClick,
// isPending,
// size,
// ...props // catch all remaining props
// }: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
// return (
// <Button
// onClick={() => {
// onClick();
// }}
// variant={variant ? variant : "default"}
// className={className}
// {...props} // forward the remaining props to the Button component
// size={size || "default"}
// >
// {isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {text}
// <>{icon ? icon : null}</>
// </Button>
// );
// };
import { Button } from "@/components/ui/button"; 'use client'
import { ButtonHTMLAttributes } from "react";
import { ButtonHTMLAttributes, ReactNode } from "react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export type VariantButton = { export type VariantButton = {
secondary: string; secondary: string;
@@ -12,46 +70,46 @@ export type VariantButton = {
link: string; link: string;
destructive: string; destructive: string;
}; };
export type sizeButton = {
export type SizeButton = {
default: string; default: string;
icon: string; icon: string;
sm: string; sm: string;
lg: string; lg: string;
}; };
export type ButtonWithConfirmProps = { export type ButtonWithLoadingProps = {
icon?: any; children?: string | ReactNode;
text: string; icon?: ReactNode;
variant?: keyof VariantButton; variant?: keyof VariantButton;
className?: string; className?: string;
onClick: () => void; onClick?: () => void;
isPending?: boolean; isPending?: boolean;
size: keyof sizeButton; size?: keyof SizeButton;
}; } & ButtonHTMLAttributes<HTMLButtonElement>;
export const ButtonWithLoading = ({ export const ButtonWithLoading = ({
icon, icon,
text, children,
variant, variant = "default",
className, className,
onClick, onClick,
isPending, isPending,
size, size = "default",
...props // catch all remaining props ...rest
}: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => { }: ButtonWithLoadingProps) => {
return ( return (
<Button <Button
onClick={() => { onClick={() => onClick?.()}
onClick(); variant={variant}
}}
variant={variant ? variant : "default"}
className={className} className={className}
{...props} // forward the remaining props to the Button component size={size}
size={size || "default"} {...rest}
> >
{isPending && <Loader2 className="animate-spin mr-4" size={16} />} {isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{text} {children && children}
<>{icon ? icon : null}</> <>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button> </Button>
); );
}; };
@@ -3,13 +3,13 @@ import {cn} from "@/lib/utils";
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
type EmptyStatePlaceholderProps = { type EmptyStatePlaceholderProps = {
url: string; url?: string;
text: string; text: string;
} }
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => { export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
return ( return (
<>{url ?
<Link <Link
href={url} href={url}
className={cn( className={cn(
@@ -20,5 +20,12 @@ export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) =
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/> <Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
<span className="text-sm lg:text-base font-medium">{text}</span> <span className="text-sm lg:text-base font-medium">{text}</span>
</Link> </Link>
:
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-lg text-muted-foreground">{text}</p>
</div>
}
</>
) )
} }
@@ -1,20 +1,25 @@
"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/admin-email-tab/settings-email-tab"; import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab"; import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
import {User, UserWithAccounts} from "@/db/schema/02_user"; import {User, UserWithAccounts} from "@/db/schema/02_user";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation"; import {useRouter, useSearchParams} from "next/navigation";
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-tab/admin-user-table"; import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/admin-user-table";
import {
AdminOrganizationsTable
} from "@/components/wrappers/dashboard/admin/tabs/admin-organizations-tab/admin-organizations-table";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type AdminTabsProps = { export type AdminTabsProps = {
users: UserWithAccounts[]; users: UserWithAccounts[];
settings: Setting; settings: Setting;
organizations: OrganizationWithMembers[];
}; };
export const AdminTabs = ({users, settings}: AdminTabsProps) => { export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -35,6 +40,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
<TabsTrigger className="w-full" value="users"> <TabsTrigger className="w-full" value="users">
Users Users
</TabsTrigger> </TabsTrigger>
<TabsTrigger className="w-full" value="organizations">
Organizations
</TabsTrigger>
<TabsTrigger className="w-full" value="email"> <TabsTrigger className="w-full" value="email">
Email Email
</TabsTrigger> </TabsTrigger>
@@ -42,10 +50,12 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
Storage Storage
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="users"> <TabsContent value="users">
<AdminUsersTable users={users}/> <AdminUsersTable users={users}/>
</TabsContent> </TabsContent>
<TabsContent value="organizations">
<AdminOrganizationsTable organizations={organizations}/>
</TabsContent>
<TabsContent value="email"> <TabsContent value="email">
<SettingsEmailTab settings={settings}/> <SettingsEmailTab settings={settings}/>
</TabsContent> </TabsContent>
@@ -0,0 +1,38 @@
"use client"
import {useState} from "react";
import {Plus} from "lucide-react";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
} from "@/components/ui/dialog";
import {Button} from "@/components/ui/button";
import {AdminOrganizationForm} from "@/components/wrappers/dashboard/admin/organization/admin-organization-form";
type AdminOrganizationAddModalProps = {}
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus/> add
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>add organization</DialogTitle>
<DialogDescription>
your description
</DialogDescription>
<AdminOrganizationForm onSuccess={() => setOpen(false)}/>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,85 @@
"use client";
import { useRouter } from "next/navigation";
import { ErrorContext } from "@better-fetch/fetch";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organization/organization.schema";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth/auth-client";
import { slugify } from "@/utils/slugify";
type AdminOrganizationFormProps = {
onSuccess?: () => void;
};
export const AdminOrganizationForm = ({ onSuccess }: AdminOrganizationFormProps) => {
const router = useRouter();
const form = useZodForm({ schema: OrganizationSchema });
const mutationCreateOrganisation = useMutation({
mutationFn: async ({ name }: OrganizationSchema) => {
const slug = slugify(name);
await authClient.organization.checkSlug(
{
slug: slug,
},
{
onSuccess: async () => {
await authClient.organization.create(
{
name: name,
slug: slug,
},
{
onSuccess: () => {
toast.success("Organization created successfully.");
router.refresh();
onSuccess?.();
},
onError: (error) => {
toast.error(error.error.message);
},
}
);
},
onError: (error: ErrorContext) => {
toast.error(error.error.message);
onSuccess?.();
},
}
);
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationCreateOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Name of your organization" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>Validate</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,27 @@
"use client";
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organization/admin-organization-add-modal";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationSectionProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationSection = ({ organizations }: AdminOrganizationSectionProps) => {
return (
<Card>
<CardHeader>
<CardTitle>Add a new organization</CardTitle>
<CardAction>
<AdminOrganizationAddModal />
</CardAction>
</CardHeader>
<CardContent>
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
);
};
@@ -0,0 +1,12 @@
"use client"
import { DataTable } from "@/components/wrappers/common/table/data-table";
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organization/table-colums";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
type AdminOrganizationListProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationList = ({ organizations }: AdminOrganizationListProps) => {
return <DataTable columns={organizationsListColumns()} data={organizations} enablePagination={true} enableSelect={false} />;
};
@@ -0,0 +1,71 @@
"use client"
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Trash2} from "lucide-react";
import {toast} from "sonner";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {authClient} from "@/lib/auth/auth-client";
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
export type ButtonDeleteFleetProps = {
text?: string;
organisationId: string
};
export const ButtonDeleteOrganization = (props: ButtonDeleteFleetProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const mutationDeleteOrganisation = useMutation({
mutationFn: () => deleteOrganizationAction({id: props.organisationId}),
onSuccess: async (result) => {
if (result?.data?.success) {
await authClient.organization.setActive({
organizationSlug: "default",
});
toast.success("Organization deleted!");
router.refresh()
refetch()
} else {
toast.error("An error occurred.");
}
},
onError: (error: any) => {
console.error("network error:", error);
toast.error(error?.message || "A network error occurred.");
},
});
return (
<ButtonWithConfirm
title={props.text ? props.text : ""}
description={"Are you sure you want to delete this organization?"}
button={{
main: {
variant: "outline",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: async () => {
await mutationDeleteOrganisation.mutateAsync()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutationDeleteOrganisation.isPending}
/>
);
};
@@ -0,0 +1,44 @@
"use server";
import { ServerActionResult } from "@/types/action-type";
import { z } from "zod";
import { auth } from "@/lib/auth/auth";
import { MemberRoleType } from "@/types/common";
import { Member } from "better-auth/plugins/organization";
import {userAction} from "@/lib/safe-actions/actions";
export const addMemberOrganizationAction = userAction
.schema(
z.object({
userId: z.string(),
organizationId: z.string(),
role: z.string(),
})
)
.action(async ({ parsedInput }): Promise<ServerActionResult<Member | null>> => {
try {
const data = await auth.api.addMember({
body: {
userId: parsedInput.userId,
role: parsedInput.role as MemberRoleType,
organizationId: parsedInput.organizationId,
},
});
return {
success: true,
value: data,
actionSuccess: {
message: "Member added successfully",
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "An error occurred while addinng member",
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,90 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {
AddMemberSchema,
AddMemberSchemaType
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
import {SearchInput} from "@/components/ui/search-input";
import {
addMemberOrganizationAction
} from "@/components/wrappers/dashboard/admin/organization/details/add-member.action";
import {toast} from "sonner";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
type OrganizationAddMemberFormProps = {
onSuccessAction?: () => void;
users: User[];
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}: OrganizationAddMemberFormProps) => {
const organizationMemberUserIds = organization.members.map((member) => member.user.id);
const filteredUsers = users
.filter((user) => !organizationMemberUserIds.includes(user.id))
.map((user) => ({value: user.id, label: `${user.name} | ${user.email}`}));
const router = useRouter();
const form = useZodForm({schema: AddMemberSchema});
const mutationAddMemberOrganisation = useMutation({
mutationFn: async (data: AddMemberSchemaType) => {
console.log(data);
const result = await addMemberOrganizationAction({
userId: data.userId,
organizationId: organization.id,
role: "member",
});
console.log(result);
toast.success("Member successfully added!");
router.refresh();
onSuccessAction?.();
},
onError: (error) => {
toast.error(error.message);
onSuccessAction?.();
},
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationAddMemberOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="userId"
render={({field}) => (
<FormItem>
<FormLabel>User</FormLabel>
<FormControl>
<SearchInput
name="userId"
placeholder="Enter a user email"
entries={filteredUsers}
onSelect={(entySelected: any) => {
console.log("Form selection:", entySelected);
field.onChange(entySelected.value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading
isPending={mutationAddMemberOrganisation.isPending}>Confirm</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,35 @@
"use client";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { OrganizationAddMemberForm } from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-form";
import { useState } from "react";
import { UserPlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {User} from "@/db/schema/02_user";
type OrganizationAddMemberModalProps = {
users: User[];
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationAddMemberModal = ({ users, organization }: OrganizationAddMemberModalProps) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="w-4 h-4 mr-2" />
Add member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add member to your organization</DialogTitle>
<DialogDescription>Select a user to add to your organization</DialogDescription>
</DialogHeader>
<OrganizationAddMemberForm users={users} organization={organization} onSuccessAction={() => setOpen(!open)} />
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,67 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useRouter } from "next/navigation";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { authClient } from "@/lib/auth/auth-client";
import { toast } from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
type OrganizationDeleteMemberModalProps = {
open: boolean;
member: MemberWithUser;
onOpenChangeAction: (open: boolean) => void;
};
export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction }: OrganizationDeleteMemberModalProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: async () => {
await authClient.organization.removeMember(
{
memberIdOrEmail: member.id,
organizationId: member.organizationId,
},
{
onSuccess: async (response) => {
console.log(response);
toast.success("Member successfully deleted!");
onOpenChangeAction(false);
router.refresh();
},
onError: async (error) => {
console.log(error);
toast.error("An error occurred while deleting member!");
onOpenChangeAction(false);
},
}
);
},
});
return (
<AlertDialog open={open} onOpenChange={onOpenChangeAction}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete {member.user.name } ?</AlertDialogTitle>
<AlertDialogDescription>This action is irreversible: it will permanently delete this members data.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>Validate</ButtonWithLoading>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -0,0 +1,96 @@
"use client";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
import {Badge} from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {Button} from "@/components/ui/button";
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
import {
OrganizationDeleteMemberModal
} from "@/components/wrappers/dashboard/admin/organization/details/organization-delete-member-modal";
import {useState} from "react";
import {authClient} from "@/lib/auth/auth-client";
import {
OrganizationMemberChangeRoleModal
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-change-role";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
type OrganizationMemberCardProps = {
member: MemberWithUser;
organization: OrganizationWithMembersAndUsers;
};
export const OrganizationMemberCard = ({member, organization}: OrganizationMemberCardProps) => {
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
const {data: session, isPending, error} = authClient.useSession();
if (isPending || error) return null;
const isCurrentUser = session?.user?.id === member.user.id;
const isOwner = member?.role === "owner";
return (
<div key={member.id}
className="flex flex-col md:flex-row md:items-center justify-between p-4 border rounded-lg">
<OrganizationDeleteMemberModal member={member} open={isModalDeleteOpen}
onOpenChangeAction={setIsModalDeleteOpen}/>
<OrganizationMemberChangeRoleModal member={member} open={isModalRoleOpen}
onOpenChangeAction={setIsModalRoleOpen}/>
<div className="flex items-center space-x-4">
<Avatar>
<AvatarImage src={member.user.image || ""} alt={member.user.name}/>
<AvatarFallback>
{member.user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium">{member.user.name}</div>
<div className="text-sm text-muted-foreground">{member.user.email}</div>
<div
className="text-xs text-muted-foreground">Joined {new Date(member.createdAt).toLocaleDateString()}</div>
</div>
</div>
<div className="flex items-center space-x-2 mt-4 md:mt-0">
<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="w-4 h-4"/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setIsModalRoleOpen(true)}>
<Settings className="w-4 h-4 mr-2"/>
Change role
</DropdownMenuItem>
<DropdownMenuSeparator/>
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2"/>
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
const getRoleBadgeVariant = (role: string) => {
switch (role.toLowerCase()) {
case "owner":
return "default";
case "admin":
return "secondary";
default:
return "outline";
}
};
@@ -0,0 +1,99 @@
"use client";
import {useState} from "react";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import {authClient} from "@/lib/auth/auth-client";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MemberRoleType} from "@/types/common";
import {MemberWithUser} from "@/db/schema/03_organization";
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {
updateMemberRoleAdminAction
} from "@/components/wrappers/dashboard/admin/organization/details/role-member.action";
type OrganizationMemberChangeRoleModalProps = {
open: boolean;
member: MemberWithUser;
onOpenChangeAction: (open: boolean) => void;
};
export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChangeRoleModalProps) => {
const {member, open, onOpenChangeAction} = props;
const router = useRouter();
const [role, setRole] = useState<MemberRoleType>(member.role as MemberRoleType);
const mutation = useMutation({
mutationFn: () =>
updateMemberRoleAdminAction({
memberId: member.id,
organizationId: member.organizationId,
role: RoleSchemaMember.parse(role),
}),
onSuccess: () => {
toast.success("Member successfully updated");
onOpenChangeAction(false);
router.refresh();
},
onError: (error) => {
console.log(error);
toast.error("An error occurred while updating member");
onOpenChangeAction(false);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChangeAction}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change the users role</DialogTitle>
<DialogDescription>Modify the role of this user within your organization.</DialogDescription>
</DialogHeader>
<Select defaultValue={member.role ?? ""} onValueChange={(role) => setRole(role as MemberRoleType)}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Sélectionnez un rôle"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
</SelectContent>
</Select>
<DialogFooter>
<div className="flex gap-4 justify-end">
<ButtonWithLoading
variant="outline"
onClick={() => {
onOpenChangeAction(false);
}}
>
Cancel
</ButtonWithLoading>
<ButtonWithLoading
isPending={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
}}
>
Validate
</ButtonWithLoading>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,50 @@
"use server";
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Member} from "better-auth/plugins";
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
import {db as dbClient} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm";
import {withUpdatedAt} from "@/db/utils";
export const updateMemberRoleAdminAction = userAction.schema(
z.object({
memberId: z.string(),
organizationId: z.string(),
role: RoleSchemaMember,
})
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
try {
const [updatedMember] = await dbClient
.update(drizzleDb.schemas.member)
.set(withUpdatedAt({
role: parsedInput.role as string,
}))
.where(and(eq(drizzleDb.schemas.member.id, parsedInput.memberId), eq(drizzleDb.schemas.member.organizationId, parsedInput.organizationId)))
.returning();
return {
success: true,
value: updatedMember,
actionSuccess: {
message: "Member has been successfully updated.",
messageParams: {},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to update member role.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {},
},
};
}
});
@@ -0,0 +1,91 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
import {
UpdateOrganizationSchema,
UpdateOrganizationSchemaType
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Input} from "@/components/ui/input";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
type UpdateOrganizationFormProps = {
onSuccessAction?: () => void;
defaultValues: OrganizationWithMembersAndUsers;
};
export const UpdateOrganizationForm = ({onSuccessAction, defaultValues}: UpdateOrganizationFormProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const isDefaultOrganization = defaultValues.slug == "default";
const form = useZodForm({
schema: UpdateOrganizationSchema,
defaultValues: defaultValues,
disabled: isDefaultOrganization,
});
const mutationUpdateOrganisation = useMutation({
mutationFn: ({name}: UpdateOrganizationSchemaType) => updateOrganizationAction({
data: {
name: name,
users: [],
slug: defaultValues.slug
},
organizationId: defaultValues.id,
}),
onSuccess: async (result) => {
if (result?.data?.success) {
toast.success("Organization updated successfully.");
router.refresh();
refetch()
} else {
// @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 (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutationUpdateOrganisation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="" {...field} value={field.value ?? ""}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<div className="flex gap-4 justify-end">
<ButtonWithLoading disabled={isDefaultOrganization} isPending={mutationUpdateOrganisation.isPending}>Validate</ButtonWithLoading>
</div>
</Form>
);
};
@@ -0,0 +1,116 @@
"use client";
import {Building2, Shield, Users} from "lucide-react";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
import {
UpdateOrganizationForm
} from "@/components/wrappers/dashboard/admin/organization/details/update-organization-form";
import {
OrganizationMemberCard
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-card";
import {useRouter, useSearchParams} from "next/navigation";
import {useEffect, useState} from "react";
import {capitalizeFirstLetter} from "@/utils/text";
import {User} from "@/db/schema/02_user";
import {
OrganizationAddMemberModal
} from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-modal";
import {cn} from "@/lib/utils";
type OrganizationManagementProps = {
organization: OrganizationWithMembersAndUsers;
users: User[];
};
export const OrganizationManagement = ({organization, users}: OrganizationManagementProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "members");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "members";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<div className=" space-y-8">
<div className="flex flex-col md:flex-row md:items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center justify-center w-12 h-12 dark:bg-gray-700 bg-gray-100 rounded-lg">
<Building2 className="w-6 h-6 "/>
</div>
<div>
<h1 className="text-2xl font-bold">{capitalizeFirstLetter(organization.name)}</h1>
</div>
</div>
<div className="flex items-center space-x-2 mt-3 md:mt-0">
<OrganizationAddMemberModal organization={organization} users={users}/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{organization.members.length}</div>
<p className="text-xs text-muted-foreground">Number of members</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Administrators</CardTitle>
<Shield className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div
className="text-2xl font-bold">{organization.members.filter((m) => m.role === "admin" || m.role === "owner").length}</div>
<p className="text-xs text-muted-foreground">With admin roles</p>
</CardContent>
</Card>
</div>
<Tabs className="space-y-6" value={tab} onValueChange={handleChangeTab}>
<TabsList>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="members" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Organization members</CardTitle>
<CardDescription>Manage who has access to your organization and their
roles.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{organization.members.map((member: MemberWithUser) => (
<OrganizationMemberCard key={member.id} member={member}
organization={organization}/>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
<CardDescription>Organization configuration settings.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<UpdateOrganizationForm defaultValues={organization}/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
};
@@ -0,0 +1,26 @@
import {z} from "zod";
export const AddMemberSchema = z.object({
userId: z.string().min(1, "Invalid field"),
});
export const UpdateOrganizationSchema = z.object({
name: z.string().min(5),
});
export const OrganizationSchema = z.object({
name: z.string(),
});
export const OrganizationInvitationSchema = z.object({
email: z.string(),
invitedByUsername: z.string(),
invitedByEmail: z.string(),
teamName: z.string(),
inviteLink: z.string()
});
export type OrganizationInvitationType = z.infer<typeof OrganizationInvitationSchema>;
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
export type UpdateOrganizationSchemaType = z.infer<typeof UpdateOrganizationSchema>;
export type AddMemberSchemaType = z.infer<typeof AddMemberSchema>;
@@ -0,0 +1,43 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organization/button-delete-organization";
import Link from "next/link";
import {Settings} from "lucide-react";
import {buttonVariants} from "@/components/ui/button";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export function organizationsListColumns(): ColumnDef<OrganizationWithMembers>[] {
return [
{
accessorKey: "name",
header: "Name",
},
{
accessorKey: "members",
header: "Members",
cell: ({row}) => {
const membersCount = row.original.members?.length;
return <div className="flex items-center gap-3">{membersCount}</div>;
},
},
{
header: "Actions",
id: "actions",
cell: ({row}) => {
const isDefaultOrganization = row.original.slug == "default";
return (
<div className="flex items-center gap-3">
{!isDefaultOrganization && (
<ButtonDeleteOrganization organisationId={row.original.id}/>
)}
<Link className={buttonVariants({variant: "outline"})}
href={`admin/organization/${row.original.id}`}>
<Settings/>
</Link>
</div>
);
},
},
];
}
@@ -1,6 +1,6 @@
"use server"; "use server";
import { z } from "zod"; import { z } from "zod";
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema"; import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/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";
@@ -19,11 +19,11 @@ import {TooltipProvider} from "@/components/ui/tooltip";
import { import {
EmailFormSchema, EmailFormSchema,
EmailFormType EmailFormType
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema"; } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input"; import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
import { import {
updateEmailSettingsAction updateEmailSettingsAction
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action"; } from "@/components/wrappers/dashboard/admin/tabs/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,13 +1,13 @@
import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form"; import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/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";
import { sendEmail } from "@/utils/email-helper"; import {sendEmail} from "@/utils/email-helper";
import TestEmailSettings from "../../../../../../emails/TestEmailSettings"; import {render} from "@react-email/render";
import { render } from "@react-email/render"; import {toast} from "sonner";
import { toast } from "sonner";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema"; import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
import TestEmailSettings from "../../../../../../../emails/TestEmailSettings";
export type SettingsEmailTabProps = { export type SettingsEmailTabProps = {
settings: Setting; settings: Setting;
@@ -47,14 +47,13 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
onClick={async () => { onClick={async () => {
await handleSendMailTest(); await handleSendMailTest();
}} }}
icon={<Send />} icon={<Send/>}
text="Send email test"
size="default" size="default"
/> >Send email test</ButtonWithLoading>
)} )}
</div> </div>
<div className="mt-5"> <div className="mt-5">
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } /> <EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,26 @@
"use client"
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
export type AdminOrganizationsTableProps = {
organizations: OrganizationWithMembers[];
};
export const AdminOrganizationsTable = (props: AdminOrganizationsTableProps) => {
const {organizations} = props;
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card>
<CardHeader>
<CardTitle>Active organizations</CardTitle>
<CardDescription>Manage all system organizations</CardDescription>
</CardHeader>
<CardContent>
<AdminOrganizationList organizations={organizations} />
</CardContent>
</Card>
</div>
);
};
@@ -0,0 +1,41 @@
"use client"
import {ColumnDef} from "@tanstack/react-table";
import {Badge} from "@/components/ui/badge";
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {useState} from "react";
import {Trash2} from "lucide-react";
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {UserWithAccounts} from "@/db/schema/02_user";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {formatFrenchDate} from "@/utils/date-formatting";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
import {Organization} from "@/db/schema/03_organization";
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
{
accessorKey: "name",
header: "Name",
},
// {
// header: "Action",
// id: "actions",
// cell: ({row}) => {
// const router = useRouter();
// const {data: session, isPending} = useSession();
// const isSuperAdmin = session?.user.role == "superadmin";
//
// return (
// <ButtonDeleteUser
// disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
// userId={row.original.id}/>
// );
// },
// },
];
@@ -0,0 +1,49 @@
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {Button} from "@/components/ui/button";
import {Download} from "lucide-react";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {toast} from "sonner";
export type AdminSettingsTabProps = {};
export const AdminSettingsTab = (props: AdminSettingsTabProps) => {
const handleDownloadKey = async () => {
let url: string = "";
const data = await getFileUrlPresignedLocal({dir: "private/keys/", fileName: "server_public.pem"})
if (data?.data?.success) {
url = data.data.value ?? "";
} else {
// @ts-ignore
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
toast.error(errorMessage);
}
window.open(url, "_self");
};
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card>
<CardHeader>
<CardTitle>Instance settings</CardTitle>
<CardDescription>Manage portabase settings</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Download Public Key</p>
<p className="text-xs text-muted-foreground">
Used for encrypting communications with this instance.
</p>
</div>
<Button onClick={handleDownloadKey} variant="outline" size="sm">
<Download className="h-4 w-4 mr-2"/>
Download
</Button>
</div>
</CardContent>
</Card>
</div>
);
};
@@ -2,7 +2,7 @@ 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/admin-storage-tab/storage-s3/storage-s3-form"; import {StorageS3Form} from "@/components/wrappers/dashboard/admin/tabs/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";
@@ -11,9 +11,9 @@ import {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import { import {
updateStorageSettingsAction updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action"; } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema"; import {S3FormType} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
export type SettingsStorageTabProps = { export type SettingsStorageTabProps = {
settings: Setting; settings: Setting;
@@ -70,7 +70,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
<AlertDescription> <AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local. For exemple you 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 cannot choose to store images in one place
and .dump files in another. and backups files in another.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<div className="flex flex-col h-full py-4 "> <div className="flex flex-col h-full py-4 ">
@@ -93,9 +93,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
onClick={async () => { onClick={async () => {
await mutation.mutateAsync(); await mutation.mutateAsync();
}} }}
icon={<ShieldCheck/>} icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
text="Test connexion"
/>
</div> </div>
</div> </div>
{isSwitched && ( {isSwitched && (
@@ -2,7 +2,7 @@
import { z } from "zod"; import { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema"; import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/tabs/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";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
@@ -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/admin-storage-tab/storage-s3/s3-form.schema"; import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/tabs/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/admin-storage-tab/storage-s3/s3-form.action"; import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input"; import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
export type S3FormProps = { export type S3FormProps = {
@@ -66,7 +66,6 @@ export const accountsColumns: ColumnDef<{
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonWithLoading <ButtonWithLoading
variant="outline" variant="outline"
text=""
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1} disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
icon={<Unlink color="red" size={15}/>} icon={<Unlink color="red" size={15}/>}
onClick={async () => { onClick={async () => {
@@ -1,7 +1,7 @@
import {User, UserWithAccounts} from "@/db/schema/02_user"; import {UserWithAccounts} from "@/db/schema/02_user";
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
import {DataTable} from "@/components/wrappers/common/table/data-table"; import {DataTable} from "@/components/wrappers/common/table/data-table";
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/admin-user-tab/columns-users"; import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/columns-users";
export type AdminUsersTableProps = { export type AdminUsersTableProps = {
users: UserWithAccounts[]; users: UserWithAccounts[];
@@ -18,7 +18,10 @@ export const AdminUsersTable = (props: AdminUsersTableProps) => {
<CardDescription>Manage your users</CardDescription> <CardDescription>Manage your users</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DataTable columns={usersColumnsAdmin} data={users}/> <DataTable
enableSelect={false}
columns={usersColumnsAdmin}
data={users}/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -0,0 +1,61 @@
"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 {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
export type ButtonDeleteUserProps = {
userId: string;
disabled?: boolean;
};
export const ButtonDeleteUser = (props: ButtonDeleteUserProps) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: () => deleteUserAction(props.userId),
onSuccess: async () => {
toast.success("User deleted successfully.");
router.refresh();
},
});
return (
<div className="flex items-center gap-2">
<ButtonWithConfirm
title={""}
description="Are you sure you want to remove this user? This action cannot be undone."
button={{
main: {
disabled: !!props.disabled,
text: "",
variant: "outline",
size: "sm",
icon: <Trash2 color="red" size={15}/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate()
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
isPending={mutation.isPending}
/>
</div>
);
};
@@ -13,6 +13,7 @@ import {UserWithAccounts} from "@/db/schema/02_user";
import {authClient, 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";
import {providerSwitch} from "@/components/wrappers/common/provider-switch"; import {providerSwitch} from "@/components/wrappers/common/provider-switch";
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
{ {
@@ -72,7 +73,7 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
accessorKey: "accounts", accessorKey: "accounts",
header: "Provider ID", header: "Provider ID",
cell: ({row}) => { cell: ({row}) => {
return( return (
<div> <div>
{row.original.accounts.map((item) => ( {row.original.accounts.map((item) => (
<div key={item.id}> <div key={item.id}>
@@ -98,27 +99,10 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
const {data: session, isPending} = useSession(); const {data: session, isPending} = useSession();
const isSuperAdmin = session?.user.role == "superadmin"; const isSuperAdmin = session?.user.role == "superadmin";
const mutation = useMutation({
mutationFn: () => deleteUserAction(row.original.id),
onSuccess: async () => {
toast.success("User deleted successfully.");
router.refresh();
},
});
return ( return (
<div className="flex items-center gap-2"> <ButtonDeleteUser
<ButtonWithLoading
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email} disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
variant="outline" userId={row.original.id}/>
text=""
icon={<Trash2 color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
}}
size="sm"
/>
</div>
); );
}, },
}, },
@@ -19,10 +19,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
return timeAgo(row.original.expiresAt); return timeAgo(row.original.expiresAt);
}, },
}, },
{
accessorKey: "ipAddress",
header: "IP Address",
},
{ {
id: "device", id: "device",
header: "Device", header: "Device",
@@ -73,7 +69,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
<ButtonWithLoading <ButtonWithLoading
variant="outline" variant="outline"
disabled={session?.session.id === row.original.id} disabled={session?.session.id === row.original.id}
text=""
icon={<Unlink color="red" size={15} />} icon={<Unlink color="red" size={15} />}
onClick={async () => { onClick={async () => {
await mutation.mutateAsync(); await mutation.mutateAsync();
@@ -1,25 +1,20 @@
"use client"; "use client";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {PasswordInput} from "@/components/wrappers/auth/password-input/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/08_agent";
export type AgentCardKeyProps = { export type AgentCardKeyProps = {
agent: Agent; edgeKey: string;
}; };
export const AgentCardKey = (props: AgentCardKeyProps) => { export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => {
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id); const [code, setCode] = useState<string>(`${edgeKey}`);
const [code, setCode] = useState<string>(`${edge_key}`);
return ( return (
<> <>
<PasswordInput <PasswordInput
value={code} value={code}
onChange={() => { onChange={() => {
setCode(edge_key); setCode(edgeKey);
}} }}
/> />
<CopyButton className="mt-5" value={code}/> <CopyButton className="mt-5" value={code}/>
@@ -14,7 +14,9 @@ export const AgentCard = (props: agentCardProps) => {
const { data: agent } = props; const { data: agent } = props;
return ( return (
<Link href={`/dashboard/agents/${agent.id}`} className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md"> <Link href={`/dashboard/agents/${agent.id}`}
className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
>
<Card className="flex flex-row justify-between"> <Card className="flex flex-row justify-between">
<div className="flex-1 text-left"> <div className="flex-1 text-left">
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader> <CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
@@ -52,11 +52,14 @@ export const AgentForm = (props: agentFormProps) => {
return; return;
} }
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`); toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
router.push(`/dashboard/agents/${data.id}`);
router.refresh(); router.refresh();
router.push(`/dashboard/agents/${data.id}`);
}, },
}); });
return ( return (
<TooltipProvider> <TooltipProvider>
<Card> <Card>
@@ -38,12 +38,11 @@ export const BackupButton = (props: BackupButtonProps) => {
<ButtonWithLoading <ButtonWithLoading
icon={<DatabaseZap/>} icon={<DatabaseZap/>}
disabled={props.disable} disabled={props.disable}
text={isMobile ? "" : "Backup"}
isPending={mutation.isPending} isPending={mutation.isPending}
size={"default"} size={"default"}
onClick={async () => { onClick={async () => {
await HandleAction(); await HandleAction();
}} }}
/> >{isMobile ? "" : "Backup"}</ButtonWithLoading>
); );
}; };
@@ -18,7 +18,7 @@ export const LoggedInDropdown = (props: LoggedInDropdownProps) => {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{props.children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{props.children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" className="w-[--radix-popper-anchor-width]"> <DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
redirect("/dashboard/profile"); redirect("/dashboard/profile");
@@ -10,15 +10,16 @@ import {SidebarMenuCustomMain} from "@/components/wrappers/dashboard/common/side
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit"; import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox"; import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button"; import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
import {env} from "@/env.mjs";
export function AppSidebar() { export function AppSidebar() {
const projectName = env.PROJECT_NAME;
return ( return (
<Sidebar collapsible="icon"> <Sidebar collapsible="icon">
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarLogo/> <SidebarLogo projectName={projectName ?? "Portabase"}/>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> <SidebarMenuItem>
<OrganizationCombobox/> <OrganizationCombobox/>
@@ -32,10 +33,10 @@ export function AppSidebar() {
<SidebarMenu className="mb-2"> <SidebarMenu className="mb-2">
<SidebarMenuItem> <SidebarMenuItem>
<LoggedInButton /> <LoggedInButton/>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
<SideBarFooterCredit /> <SideBarFooterCredit/>
</Sidebar> </Sidebar>
); );
} }
@@ -2,12 +2,13 @@
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import Link from "next/link"; import Link from "next/link";
import { env } from "@/env.mjs";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export const SidebarLogo = () => { export const SidebarLogo = ({projectName}: {
projectName: string;
}) => {
const { state, isMobile } = useSidebar(); const { state, isMobile } = useSidebar();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@@ -28,7 +29,7 @@ export const SidebarLogo = () => {
<Image <Image
loading="eager" loading="eager"
src={"/images/logo.png"} src={"/images/logo.png"}
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`} alt={`Logo ${projectName}`}
className="h-10 w-10 object-contain" className="h-10 w-10 object-contain"
height={10} height={10}
width={10} width={10}
@@ -38,7 +39,7 @@ export const SidebarLogo = () => {
<Image <Image
loading="eager" loading="eager"
src={imageTheme} src={imageTheme}
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`} alt={`Logo ${projectName}`}
className="object-contain" className="object-contain"
width={190} width={190}
height={90} height={90}
@@ -29,11 +29,12 @@ export const SidebarMenuCustomMain = () => {
const groupContent: SidebarGroupItem["group_content"] = [ const groupContent: SidebarGroupItem["group_content"] = [
{ title: "Projects", url: "/projects", icon: Layers, details:true }, { title: "Projects", url: "/projects", icon: Layers, details:true },
{ title: "Statistics", url: "/statistics", icon: ChartArea }, { title: "Statistics", url: "/statistics", icon: ChartArea },
{ title: "Settings", url: "/settings", icon: Settings, details:true }
]; ];
if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) { // if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true }); // groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
} // }
const items: SidebarGroupItem[] = [ const items: SidebarGroupItem[] = [
{ {
@@ -41,7 +41,6 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
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."); toast.success(result.data.actionSuccess?.message || "Organization Created.");
// router.push("/");
router.replace(`/dashboard/home`); router.replace(`/dashboard/home`);
} else { } else {
// @ts-ignore // @ts-ignore
@@ -82,26 +81,6 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
</FormItem> </FormItem>
)} )}
/> />
{/*<FormField*/}
{/* control={form.control}*/}
{/* name="slug"*/}
{/* defaultValue=""*/}
{/* render={({field}) => (*/}
{/* <FormItem>*/}
{/* <FormLabel>Slug</FormLabel>*/}
{/* <FormControl>*/}
{/* <Input*/}
{/* {...field}*/}
{/* onChange={(e) => {*/}
{/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
{/* field.onChange(value);*/}
{/* }}*/}
{/* />*/}
{/* </FormControl>*/}
{/* <FormMessage/>*/}
{/* </FormItem>*/}
{/* )}*/}
{/*/>*/}
<DialogFooter> <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>
@@ -2,7 +2,6 @@
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm"; import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action"; import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
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"; import {authClient} from "@/lib/auth/auth-client";
@@ -17,17 +16,17 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
const {data: organizations, refetch} = authClient.useListOrganizations(); const {data: organizations, refetch} = authClient.useListOrganizations();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => deleteOrganizationAction(props.organizationSlug), mutationFn: () => deleteOrganizationAction({slug: props.organizationSlug}),
onSuccess: async (result) => { onSuccess: async (result) => {
if (result?.data?.success) { if (result?.data?.success) {
await authClient.organization.setActive({ await authClient.organization.setActive({
organizationSlug: "default", organizationSlug: "default",
}); });
router.push("/");
toast.success(result.data.actionSuccess?.message || "Organization deleted."); toast.success(result.data.actionSuccess?.message || "Organization deleted.");
router.refresh() router.refresh()
refetch() refetch()
router.push("/");
} else { } else {
// @ts-ignore // @ts-ignore
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization."; const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
@@ -39,5 +39,6 @@ export function OrganizationCombobox() {
return <>{state === "expanded" && return <>{state === "expanded" &&
<ComboBox sideBar values={values} defaultValue={activeOrganization?.slug} onValueChange={onValueChange} <ComboBox sideBar values={values} defaultValue={activeOrganization?.slug} onValueChange={onValueChange}
reload={handleReset}/>}</>; reload={handleReset}/>}
</>;
} }
@@ -86,8 +86,6 @@ export const OrganizationForm = (props: organizationFormProps) => {
console.error("Mutation network error:", error); console.error("Mutation network error:", error);
toast.error(error?.message || "A network error occurred."); toast.error(error?.message || "A network error occurred.");
}, },
}); });
@@ -8,12 +8,11 @@ import {
OrganizationFormSchema OrganizationFormSchema
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema"; } from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
import {db} from "@/db"; import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import {and, eq, inArray, or} from "drizzle-orm";
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth"; import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
import {slugify} from "@/utils/slugify"; import {slugify} from "@/utils/slugify";
import {Organization} from "@/db/schema/03_organization"; import {Organization} from "@/db/schema/03_organization";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {headers} from "next/headers";
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => { export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
try { try {
@@ -84,7 +83,6 @@ export const updateOrganizationAction = userAction
} }
}); });
if (!organization) { if (!organization) {
return { return {
success: false, success: false,
@@ -113,61 +111,11 @@ export const updateOrganizationAction = userAction
}); });
} }
//
// await db
// .insert(drizzleDb.schemas.member)
// .values(
// usersToAdd.map((userId) => ({
// userId,
// organizationId: organization.id,
// role: "member",
// }))
// )
// .execute();
} }
if (usersToRemove.length > 0) { if (usersToRemove.length > 0) {
await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute(); await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute();
// TODO : Do not delete, go permission error with better auth
// for (const userToRemove of usersToRemove) {
//
// const memberToRemove = await db.query.member.findFirst({
// where: and(eq(drizzleDb.schemas.member.userId, userToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id)),
// with: {
// user: true
// }
// })
// console.log(memberToRemove)
//
// if (memberToRemove) {
// console.log("ici")
// await auth.api.removeMember({
// body: {
// memberIdOrEmail: memberToRemove.user.email,
// organizationId: organization.id,
// },
// headers: await headers()
// });
// }
//
// }
} }
// const updatedOrganization = await auth.api.updateOrganization({
// body: {
// data: {
// name: parsedInput.data.name,
// slug: parsedInput.data.slug,
// },
// organizationId: organization.id,
// },
// headers: await headers(),
// });
const updatedOrganization = await db const updatedOrganization = await db
.update(drizzleDb.schemas.organization) .update(drizzleDb.schemas.organization)
.set({ .set({
@@ -200,11 +148,24 @@ export const updateOrganizationAction = userAction
} }
}); });
export const deleteOrganizationAction = userAction.schema(z.string()).action( export const deleteOrganizationAction = userAction.schema(
z.object({
id: z.string().optional(),
slug: z.string().optional(),
})
).action(
async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => { async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
try { try {
const conditions = [];
if (parsedInput.id) {
conditions.push(eq(drizzleDb.schemas.organization.id, parsedInput.id));
}
if (parsedInput.slug) {
conditions.push(eq(drizzleDb.schemas.organization.slug, parsedInput.slug));
}
const org = await db.query.organization.findFirst({ const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, parsedInput), where: or(...conditions),
}); });
if (!org) { if (!org) {
@@ -221,8 +182,6 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
let deletedOrganization: Organization; let deletedOrganization: Organization;
try { try {
// TODO : Improve with better auth, always getting 403 error
// deletedOrganization = await deleteOrganization(org.id) as Organization;
[deletedOrganization] = await db [deletedOrganization] = await db
.delete(drizzleDb.schemas.organization) .delete(drizzleDb.schemas.organization)
.where(eq(drizzleDb.schemas.organization.id, org.id)) .where(eq(drizzleDb.schemas.organization.id, org.id))
@@ -12,8 +12,8 @@ import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/us
import { toast } from "sonner"; import { toast } from "sonner";
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action"; import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {DataTable} from "@/components/wrappers/common/table/data-table"; import {DataTable} from "@/components/wrappers/common/table/data-table";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/sessions/table-columns"; import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/accounts/table-columns"; import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
import {Session} from "better-auth"; import {Session} from "better-auth";
export type UserFormProps = { export type UserFormProps = {
@@ -54,8 +54,8 @@ export const UserForm = (props: UserFormProps) => {
} }
toast.success(`Profile updated successfully.`); toast.success(`Profile updated successfully.`);
router.push(`/dashboard/profile`);
router.refresh(); router.refresh();
router.push(`/dashboard/profile`);
}, },
}); });

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