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
+8 -4
View File
@@ -5,9 +5,9 @@ NODE_ENV=production
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
# Projet
NEXT_PUBLIC_PROJECT_NAME="Portabase"
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
PROJECT_NAME="Portabase"
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
PROJECT_URL=http://app.portabase.io
PROJECT_SECRET=
# SMTP (email)
@@ -20,6 +20,7 @@ SMTP_FROM=
# Google
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GOOGLE_METHOD=
# S3
S3_ENDPOINT=http://app.s3.portabase.io
@@ -30,4 +31,7 @@ S3_PORT=9000
S3_USE_SSL=true
# Storage Type (s3, local)
STORAGE_TYPE=local
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
-19
View File
@@ -48,22 +48,3 @@ jobs:
push: true
tags: ${{ steps.set-tags.outputs.tags }}
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.
.idea
# dependencies
/node_modules
/.pnp
Binary file not shown.
+3 -1
View File
@@ -19,7 +19,6 @@
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=enhancement&template=feature-request---.md">Request Feature</a>
![portabase-ezgif com-video-to-gif-converter](https://github.com/user-attachments/assets/e3929e58-fefd-44cc-ab26-6f8e1a860c43)
</p>
</div>
@@ -178,6 +177,9 @@ S3_USE_SSL=true
# Storage Backend: 'local' or 's3'
STORAGE_TYPE=local
# Retention
RETENTION_CRON="* * * * *"
```
### Semantic Versioning
+9 -38
View File
@@ -1,49 +1,20 @@
"use client"
import React, {useEffect, useState} from "react";
import {LayoutAdmin} from "@/components/layout";
import Image from "next/image";
import {env} from "@/env.mjs";
import {useTheme} from "next-themes";
import {useSession} from "@/lib/auth/auth-client";
import {useRouter} from "next/navigation";
import React from "react";
import {redirect} from "next/navigation";
import {currentUser} from "@/lib/auth/current-user";
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
export default async function Layout({children}: { children: React.ReactNode }) {
export default function Layout({children}: { children: React.ReactNode }) {
const user = await currentUser();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const router = useRouter();
const { data: session } = useSession();
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
router.replace("/dashboard/home");
if (user && !user.banned && user.role !== "pending") {
redirect("/dashboard/home");
}
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
return (
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
<div className="mx-auto w-full max-w-md">
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
<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>
<AuthLogoSection/>
<div>{children}</div>
</div>
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
+12 -24
View File
@@ -1,32 +1,20 @@
"use client"
import {useEffect, useRef, useState} from "react";
import {useSearchParams} from "next/navigation";
import {notFound} from "next/navigation";
import {env} from "@/env.mjs";
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
import {toast} from "sonner";
import {Metadata} from "next";
export default function SignInPage(props: {
searchParams: Promise<{ callbackUrl: string | undefined }>
}) {
const [urlParams, setUrlParams] = useState<URLSearchParams>();
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
setUrlParams(urlParams);
const error = urlParams.get("error");
console.log(urlParams.get("redirect"));
if (error?.includes("pending")) {
toast.error("Your account is not active.");
urlParams.delete("error");
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
}
}, []);
export const metadata: Metadata = {
title: "Login",
};
export default async function SignInPage() {
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
if (!authGoogleEnabled) {
notFound()
}
return (
<div className="mx-auto grid w-full gap-6">
<LoginForm/>
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
</div>
)
}
+5
View File
@@ -1,5 +1,10 @@
import {PageParams} from "@/types/next";
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<{}>) {
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 {db} from "@/db";
import {isNull} from "drizzle-orm";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Admin",
};
export default async function RoutePage(props: PageParams<{}>) {
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({
where: (fields, {eq}) => eq(fields.name, "system"),
});
return (
<Page>
<PageHeader>
<PageTitle>Administration Panel</PageTitle>
</PageHeader>
<PageContent>
<AdminTabs settings={settings!} users={users}/>
<AdminTabs
organizations={organizations}
settings={settings!}
users={users}/>
</PageContent>
</Page>
);
@@ -8,17 +8,15 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
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 { db } from "@/db";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq} from "drizzle-orm";
import {eq} from "drizzle-orm";
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 {capitalizeFirstLetter} from "@/utils/text";
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 }>) {
@@ -31,11 +29,11 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
}
})
if (!agent) {
notFound()
}
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
@@ -48,7 +46,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
</Link>
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
</PageActions>
</div>
<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">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<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>
<CardContent>
<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">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<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>
<CardContent>
<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
</CardHeader>
<CardContent>
<AgentCardKey agent={agent}/>
<AgentCardKey
edgeKey={edgeKey}
/>
</CardContent>
</Card>
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
@@ -1,6 +1,11 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
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<{}>) {
return (
@@ -9,7 +14,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Create new agent</PageTitle>
</PageHeader>
<PageContent>
<AgentForm />
<AgentForm/>
</PageContent>
</Page>
);
@@ -1,18 +1,19 @@
import { PageParams } from "@/types/next";
import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { Button } from "@/components/ui/button";
import {PageParams} from "@/types/next";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {Button} from "@/components/ui/button";
import Link from "next/link";
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { notFound } from "next/navigation";
import { db } from "@/db";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {notFound} from "next/navigation";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {and, eq, not} from "drizzle-orm";
import {Plus} from "lucide-react";
import {cn} from "@/lib/utils";
import {eq, not} from "drizzle-orm";
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<{}>) {
@@ -39,7 +40,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</PageHeader>
<PageContent>
{agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<EmptyStatePlaceholder
url={"/dashboard/agents/new"}
@@ -11,7 +11,7 @@ import {db} from "@/db";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
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 {capitalizeFirstLetter} from "@/utils/text";
@@ -22,8 +22,9 @@ export default async function RoutePage(props: PageParams<{
const {projectId, databaseId} = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
if (!organization || !activeMember) {
notFound();
}
@@ -83,6 +84,9 @@ export default async function RoutePage(props: PageParams<{
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
@@ -90,17 +94,19 @@ export default async function RoutePage(props: PageParams<{
<div className=" w-full md:w-fit">
{capitalizeFirstLetter(dbItem.name)}
</div>
<div className="flex items-center gap-2 md:justify-between w-full">
<div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full">
<div className="flex items-center gap-2">
{/* Do not delete*/}
{/*<EditButton/>*/}
<RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
)}
</PageTitle>
</div>
@@ -108,9 +114,9 @@ export default async function RoutePage(props: PageParams<{
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
)}
<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}/>
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
</PageContent>
@@ -12,7 +12,7 @@ import {notFound, redirect} from "next/navigation";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
@@ -24,6 +24,8 @@ export default async function RoutePage(props: PageParams<{
} = await props.params;
const organization = await getOrganization({});
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
@@ -48,23 +50,27 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects");
}
const isMember = activeMember?.role === "member";
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{capitalizeFirstLetter(proj.name)}
<Link className={buttonVariants({variant: "outline"})} href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
{!isMember && (
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7"/>
</Link>
)}
</PageTitle>
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
{!isMember && (
<PageActions className="justify-between">
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
)}
</div>
<PageDescription>The list of associated databases</PageDescription>
<PageContent className="flex flex-col w-full h-full">
{proj.databases.length > 0 ? (
<CardsWithPagination
@@ -8,11 +8,12 @@ import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
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({});
if (!organization ) {
if (!organization) {
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 {db} from "@/db";
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 {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 activeMember = await getActiveMember()
if (!organization) {
notFound();
@@ -28,12 +34,14 @@ export default async function RoutePage(props: PageParams<{ }>) {
databases: true,
},
});
const isMember = activeMember?.role === "member";
return (
<Page>
<PageHeader>
<PageTitle>Projects</PageTitle>
{projects.length > 0 && (
{(projects.length > 0 && !isMember) && (
<PageActions>
<Link href={`/dashboard/projects/new`}>
<Button>+ Create Project</Button>
@@ -44,13 +52,17 @@ export default async function RoutePage(props: PageParams<{ }>) {
<PageContent>
{projects.length > 0 ? (
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
cardsPerPage={4} numberOfColumns={1}/>
) : (
<EmptyStatePlaceholder
url={"/dashboard/projects/new"}
text={"Create new Project"}
<CardsWithPagination
organizationSlug={organization.slug}
data={projects}
cardItem={ProjectCard}
cardsPerPage={4}
numberOfColumns={1}
/>
) : isMember ? (
<EmptyStatePlaceholder text="No project available"/>
) : (
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
)}
</PageContent>
</Page>
@@ -18,7 +18,6 @@ export default async function RoutePage(props: PageParams<{
where: (fields) => isNull(fields.deletedAt)
});
if (!user || !users || !organization || organization.slug == "default") {
notFound();
}
@@ -10,7 +10,11 @@ import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-
import {
SettingsOrganizationMembersTable
} 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 }>) {
const organization = await getOrganization({});
@@ -22,10 +26,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
}
const isMember = activeMember?.role === "member";
if (isMember) {
notFound();
}
const isOwner = activeMember?.role === "owner";
return (
<Page>
@@ -37,14 +38,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
)}
</PageTitle>
<PageActions>
{!isMember && organization.slug !== "default" && (
{isOwner && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
{/*<PageDescription>*/}
{/* Manage your organization settings.*/}
{/*</PageDescription>*/}
<PageContent>
<SettingsOrganizationMembersTable organization={organization}/>
</PageContent>
@@ -8,7 +8,12 @@ import {db} from "@/db";
import {and, asc, count, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
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<{}>) {
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
.select({
createdAt: drizzleDb.schemas.backup.createdAt,
@@ -130,7 +62,6 @@ export default async function RoutePage(props: PageParams<{}>) {
.orderBy(drizzleDb.schemas.backup.createdAt);
const restorationsCountResult = await db
.select({
count: count(),
@@ -139,60 +70,84 @@ export default async function RoutePage(props: PageParams<{}>) {
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
const projectsCount = projects.length;
const backupsEvolutionCount = backupsEvolution.length;
const sortedBackupsEvolution = backupsEvolution.sort(
(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 (
<Page>
<PageHeader>
<PageTitle>Statistics</PageTitle>
<PageTitle>Statistics Overview</PageTitle>
</PageHeader>
<PageContent className="flex flex-col gap-y-4">
<div className="flex flex-col md:flex-row gap-4">
<Card className="w-full flex-1">
<CardHeader className="flex items-center gap-2">
<Folder className="w-5 h-5 text-muted-foreground" />
<CardTitle>Projects</CardTitle>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground"/>
</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 className="w-full flex-1">
<CardHeader className="flex items-center gap-2">
<DatabaseBackup className="w-5 h-5 text-muted-foreground" />
<CardTitle>Backups</CardTitle>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</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 className="w-full flex-1">
<CardHeader className="flex items-center gap-2">
<RefreshCcw className="w-5 h-5 text-muted-foreground" />
<CardTitle>Restorations</CardTitle>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Restorations</CardTitle>
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
</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>
</div>
<div className="flex flex-col md:flex-row gap-4">
<Card className="w-full">
<CardHeader>
<CardTitle>Evolution of the number of backups</CardTitle>
</CardHeader>
<CardContent>
<EvolutionLineChart data={sortedBackupsEvolution}/>
{sortedBackupsEvolution.length > 0 ? (
<EvolutionLineChart data={sortedBackupsEvolution}/>
) : (
<Placeholder text="No backup data available"/>
)}
</CardContent>
</Card>
<Card className="w-full">
<CardHeader>
<CardTitle>Success rate of backups</CardTitle>
</CardHeader>
<CardContent>
<PercentageLineChart data={backupsRate}/>
{backupsRate.length > 0 ? (
<PercentageLineChart data={backupsRate}/>
) : (
<Placeholder text="No backup rate data available"/>
)}
</CardContent>
</Card>
</div>
+5
View File
@@ -8,6 +8,11 @@ import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Home",
};
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 {currentUser} from "@/lib/auth/current-user";
import {getAccounts, getSessions} from "@/lib/auth/auth";
import {Metadata} from "next";
export const metadata: Metadata = {
title: "Profile",
};
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
@@ -39,11 +44,8 @@ export default async function RoutePage(props: PageParams<{}>) {
{user.name}
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
</PageTitle>
{/*<PageActions className="mt-2 hidden sm:block">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</PageActions>*/}
</div>
<PageContent >
<PageContent>
<UserForm
userId={user.id}
sessions={sessions} accounts={accounts}
@@ -53,9 +55,6 @@ export default async function RoutePage(props: PageParams<{}>) {
role: user.role ?? undefined,
}}
/>
{/*<div className="mt-4 sm:hidden ">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</div>*/}
</PageContent>
</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";
}
}
+15 -5
View File
@@ -3,12 +3,13 @@ import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {Backup} from "@/db/schema/07_database";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {env} from "@/env.mjs";
import {withUpdatedAt} from "@/db/utils";
import {decryptedDump, getFileExtension} from "./helpers";
export async function POST(
request: Request,
@@ -27,6 +28,8 @@ export async function POST(
const agentId = (await params).agentId;
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 method = formData.get("method") as string | null;
@@ -102,16 +105,22 @@ export async function POST(
if (status === "success") {
const file = formData.get("file") as File | null;
if (!aesKeyHex || !ivHex) {
return NextResponse.json({error: "Missing fields"}, {status: 400});
}
if (!file) {
return NextResponse.json(
{error: "File is required for successful backup"},
{status: 400}
);
}
const fileExtension = getFileExtension(database.dbms)
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
const uuid = uuidv4();
const fileName = `${uuid}.dump`;
const buffer = Buffer.from(await file.arrayBuffer());
const fileName = `${uuid}${fileExtension}`;
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);
if (!settings) {
@@ -173,4 +182,5 @@ export async function POST(
{status: 500}
);
}
}
}
-6
View File
@@ -5,7 +5,6 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
export type BodyResultRestore = {
generatedId: string
status: string
@@ -13,7 +12,6 @@ export type BodyResultRestore = {
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
export async function POST(
request: Request,
{params}: { params: Promise<{ agentId: string }> }
@@ -27,7 +25,6 @@ export async function POST(
console.log(body)
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
@@ -71,10 +68,7 @@ export async function POST(
eventEmitter.emit('modification', {update: true});
return Response.json(response, {status: 200})
} catch (error) {
console.error('Error in POST handler:', error);
return NextResponse.json(
+1 -2
View File
@@ -124,13 +124,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
try {
if (settings.storage == "local") {
data = await getFileUrlPresignedLocal(fileName!)
data = await getFileUrlPresignedLocal({fileName: fileName!})
} else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
}
if (data?.data?.success) {
urlBackup = data.data.value ?? "";
} else {
+1 -1
View File
@@ -20,7 +20,7 @@ export type Body = {
// Function to test the get file url presigned local
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({
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 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(
new ReadableStream({
@@ -37,13 +47,13 @@ export async function GET(request: Request) {
);
}
export async function POST(request: Request) {
console.log('POST request received');
const data = await request.json();
console.log('Data received:', data);
// Emit the event to all connected clients
eventEmitter.emit('modification', data);
return new Response('Event sent', { status: 200 });
}
// export async function POST(request: Request) {
// console.log('POST request received');
// const data = await request.json();
// console.log('Data received:', data);
//
// // Emit the event to all connected clients
// eventEmitter.emit('modification', data);
//
// 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 fileName = (await params).fileName
const privateLocalDir = "private/uploads/files/";
const filePath = path.join(privateLocalDir, fileName);
const uploadsDir = "private/uploads/files/";
const uploadPath = path.join(uploadsDir, fileName);
const crypto = require('crypto');
if (!fs.existsSync(filePath)) {
return NextResponse.json(
{error: 'File not found'},
{status: 404}
);
let filePath = null;
if (fs.existsSync(uploadPath)) {
filePath = uploadPath;
} else {
return NextResponse.json({error: "File not found"}, {status: 404})
}
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
@@ -31,8 +31,8 @@ export async function GET(
{status: 403}
);
}
//@ts-ignore
const expiresAt = parseInt(expires, 10);
const expiresAt = parseInt(expires!, 10);
if (Date.now() > expiresAt) {
return NextResponse.json(
{error: 'Signed token expired'},
+86 -21
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 {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import fs from "fs/promises";
import { NextResponse } from "next/server";
function nodeStreamToWebStream(nodeStream: stream.Readable) {
return new ReadableStream({
start(controller) {
nodeStream.on("data", chunk => controller.enqueue(chunk));
nodeStream.on("end", () => controller.close());
nodeStream.on("error", err => controller.error(err));
},
cancel() {
nodeStream.destroy();
},
});
}
const privateS3ImageDir = "images/";
export async function GET(
request: Request,
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 {
const fileName = (await params).fileName;
if (storageType === "local") {
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
try {
await fs.access(filePath);
const file = await fs.readFile(filePath);
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 });
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
}
}
// Read the file
const fileContent = await fs.readFile(filePath); // Returns a Buffer
const exists = await checkFileExistsInBucket({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
return new NextResponse(fileContent, {
const nodeStream = await getObjectFromClient({
bucketName: env.S3_BUCKET_NAME!,
fileName: `${privateS3ImageDir}${fileName}`,
});
const webStream = nodeStreamToWebStream(nodeStream);
return new NextResponse(webStream, {
headers: {
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Type": "application/octet-stream", // Adjust MIME type as needed
"Content-Type": contentType,
"Cache-Control": "no-store",
"Content-Disposition": `inline; filename="${fileName}"`,
},
});
} catch (error) {
console.error("Error reading file:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
} 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 type {Metadata} from "next";
import {Inter} from "next/font/google";
import "./globals.css";
import {Providers} from "./providers";
import {cn} from "@/lib/utils";
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 = {
title: process.env.NEXT_PUBLIC_PROJECT_NAME ?? "App Title",
description: process.env.NEXT_PUBLIC_PROJECT_DESCRIPTION ?? undefined,
title: {
default: title,
template: `%s - ${title}`
},
description: process.env.PROJECT_DESCRIPTION ?? undefined,
};
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() {
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>
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
</div>
<BackButton>Go home</BackButton>
</div>
</div>
)
+17 -20
View File
@@ -3,22 +3,20 @@ name: portabase-prod
services:
app:
build:
context: .
dockerfile: docker/dockerfile/Dockerfile
target: prod
# build:
# context: .
# dockerfile: docker/dockerfile/Dockerfile
# target: prod
image: solucetechnologies/portabase:1.1.3-rc.3
ports:
- '8887:80'
env_file:
- .env
depends_on:
db:
condition: service_healthy
container_name: portabase-app-prod
db:
image: postgres:16-alpine
ports:
@@ -35,20 +33,19 @@ services:
timeout: 5s
retries: 5
s3:
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}
# s3:
# 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}
volumes:
postgres-data:
minio_data:
# minio_data:
-52
View File
@@ -1,25 +1,6 @@
name: portabase-dev
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:
image: postgres:16-alpine
ports:
@@ -36,38 +17,5 @@ services:
timeout: 5s
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:
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 --chown=nextjs:nodejs src/db ./src/db
USER root
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
+1 -7
View File
@@ -1,11 +1,5 @@
#!/bin/bash
#
#npx drizzle-kit generate
#npx drizzle-kit migrate
#
#npm run dev
#
#exec "$@"
set -euo pipefail
echo "▶ Running Drizzle codegen..."
@@ -1,14 +1,5 @@
#!/bin/bash
#echo " ____ __ __ "
#echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
#echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
#echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
#echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
#echo " "
#echo " Community Edition v1.1.1 "
#echo " "
node server.js
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/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-3
View File
@@ -41,9 +41,6 @@ const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
async headers() {
return [
{
+115 -113
View File
@@ -1,115 +1,117 @@
{
"name": "portabase",
"version": "1.1.2",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"email": "email dev",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.17",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"bcrypt": "^6.0.0",
"better-auth": "1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.6",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.510.0",
"minio": "^8.0.5",
"next": "15.5.2",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"react": "19.1.0",
"react-day-picker": "9.7.0",
"react-dom": "19.1.0",
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"recharts": "^2.15.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.18.2",
"zod": "^3.24.4"
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"drizzle-kit": "^0.31.1",
"eslint": "^9.26.0",
"eslint-config-next": "15.3.2",
"eslint-plugin-tailwindcss": "^3.18.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
"name": "portabase",
"version": "1.1.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
"build": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"email": "email dev",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:drop": "drizzle-kit drop",
"auth:generate": "npx @better-auth/cli generate --config ./src/lib/auth/auth.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-accordion": "^1.2.10",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-aspect-ratio": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.9",
"@radix-ui/react-checkbox": "^1.3.1",
"@radix-ui/react-collapsible": "^1.1.10",
"@radix-ui/react-context-menu": "^2.2.14",
"@radix-ui/react-dialog": "^1.1.13",
"@radix-ui/react-dropdown-menu": "^2.1.14",
"@radix-ui/react-hover-card": "^1.1.13",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-menubar": "^1.1.14",
"@radix-ui/react-navigation-menu": "^1.2.12",
"@radix-ui/react-popover": "^1.1.13",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-radio-group": "^1.3.6",
"@radix-ui/react-scroll-area": "^1.2.8",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-toggle": "^1.1.8",
"@radix-ui/react-toggle-group": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.6",
"@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.4",
"@tanstack/react-query": "^5.76.1",
"@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.17",
"@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.0",
"bcrypt": "^6.0.0",
"better-auth": "1.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dockerode": "^4.0.6",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.510.0",
"minio": "^8.0.5",
"next": "16.0.0",
"next-safe-action": "^7.10.8",
"next-themes": "^0.4.6",
"node-cron": "^4.2.1",
"node-forge": "^1.3.1",
"nodemailer": "^7.0.3",
"npm-check-updates": "^18.0.1",
"pg": "^8.16.0",
"react": "^19.2.0",
"react-day-picker": "9.7.0",
"react-dom": "^19.2.0",
"react-dropzone": "^14.3.8",
"react-email": "^4.0.13",
"react-hook-form": "^7.56.3",
"react-resizable-panels": "^3.0.2",
"react-twc": "^1.4.2",
"recharts": "^2.15.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.3",
"tailwind-merge": "^3.3.0",
"uuid": "^11.1.0",
"vaul": "^1.1.2",
"ws": "^8.18.2",
"zod": "^3.24.4"
},
"devDependencies": {
"@iconify/react": "^6.0.0",
"@tailwindcss/postcss": "^4.1.7",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.15.18",
"@types/node-forge": "^1",
"@types/pg": "^8.15.2",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@zenstackhq/openapi": "^2.14.2",
"@zenstackhq/tanstack-query": "^2.14.2",
"drizzle-kit": "^0.31.1",
"eslint": "^9.39.0",
"eslint-config-next": "^16.0.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.7",
"tsx": "^4.19.4",
"tw-animate-css": "^1.2.9",
"typescript": "^5.8.3",
"zenstack": "2.14.2"
},
"packageManager": "yarn@4.9.1"
}
+14 -28
View File
@@ -1,11 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import { signOut } from "@/lib/auth/auth-client";
import {NextRequest, NextResponse} from "next/server";
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
import {errorHandler} from "@/middleware/errorHandler";
import {auth} from "@/lib/auth/auth";
import {headers} from "next/headers";
export async function middleware(request: NextRequest) {
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
@@ -13,40 +12,33 @@ export async function middleware(request: NextRequest) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
}
if (session.user.banned) {
signOut();
await auth.api.signOut({headers: await headers()});
return NextResponse.redirect(new URL("/login?error=banned", request.url));
}
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));
}
if (url.pathname === "/dashboard") {
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
}
return NextResponse.next();
}
// Exclude `/api/auth` and its subpaths
if (url.pathname.startsWith("/api/auth")) {
return NextResponse.next();
}
if (url.pathname.startsWith("/api")) {
const routeExists = checkRouteExists(url.pathname);
// If the route does not exist, return a 404 JSON response
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,
headers: { "Content-Type": "application/json" },
headers: {"Content-Type": "application/json"},
});
}
}
@@ -56,31 +48,25 @@ export async function middleware(request: NextRequest) {
errorHandler(err);
}
}
// Function to check if the route exists (supports dynamic routes)
function checkRouteExists(pathname: string) {
// Define static and dynamic routes with patterns
const routePatterns = [
//do not delete
// /^\/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\/[^/]+\/status\/?$/,
/^\/api\/agent\/[^/]+\/backup\/?$/,
/^\/api\/agent\/[^/]+\/restore\/?$/,
/^\/api\/files\/[^/]+\/?$/,
/^\/api\/images\/[^/]+\/?$/,
/^\/api\/events\/?$/,
/^\/api\/init\/?$/,
/^\/api\/config\/?$/,
];
return routePatterns.some((pattern) => pattern.test(pathname));
}
export const config = {
runtime: "nodejs",
matcher: [
// '/api/agent/:path*',
"/api/:path*",
"/dashboard/:path*",
"/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";
import { Button } from "@/components/ui/button";
import { signIn } from "@/lib/auth/auth-client";
import { JSX } from "react";
import {Button} from "@/components/ui/button";
import {signIn} from "@/lib/auth/auth-client";
import {JSX} from "react";
export type AuthButtonProps = {
providers: SocialProviderType[];
callBackURL?: string;
};
export type SocialProviderType = {
@@ -41,7 +42,7 @@ export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
e.preventDefault();
void signIn.social({
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";
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 {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 {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) => {
@@ -27,27 +176,57 @@ export const LoginForm = (props: loginFormProps) => {
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({
mutationFn: async (values: LoginType) => {
const { error } = await signIn.email(values, {
onSuccess: () => {
try {
const callbackURL =
urlParams.get("redirect")?.startsWith("/")
? urlParams.get("redirect")
: "/dashboard/profile";
const {error} = await signIn.email({
email: values.email,
password: values.password,
callbackURL: callbackURL ?? "/dashboard/profile",
});
if (error) {
toast.error(error.message);
} else {
toast.success("Login success");
router.push("/dashboard/profile");
},
});
if (error) {
toast.error(error.message);
}
} 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",
name: "Google",
icon: <Icon icon={"logos:google-icon"} width="25" height="25" />,
},
];
icon: <Icon icon="logos:google-icon" width="25" height="25"/>,
});
}
return (
<TooltipProvider>
@@ -55,7 +234,9 @@ export const LoginForm = (props: loginFormProps) => {
<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>
<p className="text-balance text-muted-foreground">
Enter your information below to login
</p>
</div>
</CardHeader>
<CardContent>
@@ -70,13 +251,17 @@ export const LoginForm = (props: loginFormProps) => {
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input autoComplete="email webauthn" placeholder="exemple@portabase.io" {...field} />
<Input
autoComplete="email"
placeholder="example@portabase.io"
{...field}
/>
</FormControl>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -84,30 +269,38 @@ export const LoginForm = (props: loginFormProps) => {
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
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>*/}
{/* Optional forgot password link */}
</div>
<FormControl>
<PasswordInput autoComplete="current-password webauthn" placeholder="Your password" {...field} />
<PasswordInput
autoComplete="current-password"
placeholder="Your password"
{...field}
/>
</FormControl>
<FormMessage />
<FormMessage/>
</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">
Don&apos;t have an account?{" "}
<Link href={"/register"} className="underline">
<Link href="/register" className="underline">
Sign up
</Link>
</div>
</Form>
<SocialAuthButton providers={availableProviders} />
<SocialAuthButton
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
providers={availableProviders}
/>
</CardContent>
</Card>
</TooltipProvider>
@@ -30,8 +30,8 @@ export const RegisterForm = (props: registerFormProps) => {
await signUp.email(values, {
onSuccess: () => {
toast.success(`Success`);
router.push(`/login`);
router.refresh();
router.push(`/login`);
},
onError: (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";
import { ButtonHTMLAttributes } from "react";
'use client'
import { ButtonHTMLAttributes, ReactNode } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
export type VariantButton = {
secondary: string;
@@ -12,46 +70,46 @@ export type VariantButton = {
link: string;
destructive: string;
};
export type sizeButton = {
export type SizeButton = {
default: string;
icon: string;
sm: string;
lg: string;
};
export type ButtonWithConfirmProps = {
icon?: any;
text: string;
export type ButtonWithLoadingProps = {
children?: string | ReactNode;
icon?: ReactNode;
variant?: keyof VariantButton;
className?: string;
onClick: () => void;
onClick?: () => void;
isPending?: boolean;
size: keyof sizeButton;
};
size?: keyof SizeButton;
} & ButtonHTMLAttributes<HTMLButtonElement>;
export const ButtonWithLoading = ({
icon,
text,
variant,
className,
onClick,
isPending,
size,
...props // catch all remaining props
}: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
icon,
children,
variant = "default",
className,
onClick,
isPending,
size = "default",
...rest
}: ButtonWithLoadingProps) => {
return (
<Button
onClick={() => {
onClick();
}}
variant={variant ? variant : "default"}
onClick={() => onClick?.()}
variant={variant}
className={className}
{...props} // forward the remaining props to the Button component
size={size || "default"}
size={size}
{...rest}
>
{isPending && <Loader2 className="animate-spin mr-4" size={16} />}
{text}
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
{children && children}
<>{icon ? icon : null}</>
{/*{icon && <span className="ml-2">{icon}</span>}*/}
</Button>
);
};
@@ -3,22 +3,29 @@ import {cn} from "@/lib/utils";
import {Plus} from "lucide-react";
type EmptyStatePlaceholderProps = {
url: string;
url?: string;
text: string;
}
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
return (
<Link
href={url}
className={cn(
"flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
)}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
<span className="text-sm lg:text-base font-medium">{text}</span>
</Link>
<>{url ?
<Link
href={url}
className={cn(
"flex flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-2"
)}
>
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
<span className="text-sm lg:text-base font-medium">{text}</span>
</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";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
import {User, UserWithAccounts} from "@/db/schema/02_user";
import {Setting} from "@/db/schema/01_setting";
import {useEffect, useState} from "react";
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 = {
users: UserWithAccounts[];
settings: Setting;
organizations: OrganizationWithMembers[];
};
export const AdminTabs = ({users, settings}: AdminTabsProps) => {
export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -35,6 +40,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
<TabsTrigger className="w-full" value="users">
Users
</TabsTrigger>
<TabsTrigger className="w-full" value="organizations">
Organizations
</TabsTrigger>
<TabsTrigger className="w-full" value="email">
Email
</TabsTrigger>
@@ -42,10 +50,12 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
Storage
</TabsTrigger>
</TabsList>
<TabsContent value="users">
<AdminUsersTable users={users}/>
</TabsContent>
<TabsContent value="organizations">
<AdminOrganizationsTable organizations={organizations}/>
</TabsContent>
<TabsContent value="email">
<SettingsEmailTab settings={settings}/>
</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";
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 { db } from "@/db";
import * as drizzleDb from "@/db";
@@ -19,11 +19,11 @@ import {TooltipProvider} from "@/components/ui/tooltip";
import {
EmailFormSchema,
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 {
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 {useRouter} from "next/navigation";
@@ -1,13 +1,13 @@
import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form";
import { Send } from "lucide-react";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation } from "@tanstack/react-query";
import { sendEmail } from "@/utils/email-helper";
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
import { render } from "@react-email/render";
import { toast } from "sonner";
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
import {Send} from "lucide-react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {sendEmail} from "@/utils/email-helper";
import {render} from "@react-email/render";
import {toast} from "sonner";
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 = {
settings: Setting;
@@ -47,14 +47,13 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
onClick={async () => {
await handleSendMailTest();
}}
icon={<Send />}
text="Send email test"
icon={<Send/>}
size="default"
/>
>Send email test</ButtonWithLoading>
)}
</div>
<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>
);
@@ -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 {Switch} from "@/components/ui/switch";
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 {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
@@ -11,9 +11,9 @@ import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {
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 {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 = {
settings: Setting;
@@ -70,7 +70,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
<AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
cannot choose to store images in one place
and .dump files in another.
and backups files in another.
</AlertDescription>
</Alert>
<div className="flex flex-col h-full py-4 ">
@@ -93,9 +93,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
onClick={async () => {
await mutation.mutateAsync();
}}
icon={<ShieldCheck/>}
text="Test connexion"
/>
icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
</div>
</div>
{isSwitched && (
@@ -2,7 +2,7 @@
import { z } from "zod";
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 * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/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 { 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";
export type S3FormProps = {
@@ -66,7 +66,6 @@ export const accountsColumns: ColumnDef<{
<div className="flex items-center gap-2">
<ButtonWithLoading
variant="outline"
text=""
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
icon={<Unlink color="red" size={15}/>}
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 {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 = {
users: UserWithAccounts[];
@@ -18,7 +18,10 @@ export const AdminUsersTable = (props: AdminUsersTableProps) => {
<CardDescription>Manage your users</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={usersColumnsAdmin} data={users}/>
<DataTable
enableSelect={false}
columns={usersColumnsAdmin}
data={users}/>
</CardContent>
</Card>
</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 {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";
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
{
@@ -72,7 +73,7 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
accessorKey: "accounts",
header: "Provider ID",
cell: ({row}) => {
return(
return (
<div>
{row.original.accounts.map((item) => (
<div key={item.id}>
@@ -98,27 +99,10 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
const {data: session, isPending} = useSession();
const isSuperAdmin = session?.user.role == "superadmin";
const mutation = useMutation({
mutationFn: () => deleteUserAction(row.original.id),
onSuccess: async () => {
toast.success("User deleted successfully.");
router.refresh();
},
});
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
variant="outline"
text=""
icon={<Trash2 color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
}}
size="sm"
/>
</div>
<ButtonDeleteUser
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
userId={row.original.id}/>
);
},
},
@@ -19,10 +19,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
return timeAgo(row.original.expiresAt);
},
},
{
accessorKey: "ipAddress",
header: "IP Address",
},
{
id: "device",
header: "Device",
@@ -73,7 +69,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
<ButtonWithLoading
variant="outline"
disabled={session?.session.id === row.original.id}
text=""
icon={<Unlink color="red" size={15} />}
onClick={async () => {
await mutation.mutateAsync();
@@ -1,25 +1,20 @@
"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 {useState} from "react";
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
import {Agent} from "@/db/schema/08_agent";
export type AgentCardKeyProps = {
agent: Agent;
edgeKey: string;
};
export const AgentCardKey = (props: AgentCardKeyProps) => {
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
const [code, setCode] = useState<string>(`${edge_key}`);
export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => {
const [code, setCode] = useState<string>(`${edgeKey}`);
return (
<>
<PasswordInput
value={code}
onChange={() => {
setCode(edge_key);
setCode(edgeKey);
}}
/>
<CopyButton className="mt-5" value={code}/>
@@ -14,7 +14,9 @@ export const AgentCard = (props: agentCardProps) => {
const { data: agent } = props;
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">
<div className="flex-1 text-left">
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
@@ -52,11 +52,14 @@ export const AgentForm = (props: agentFormProps) => {
return;
}
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
router.push(`/dashboard/agents/${data.id}`);
router.refresh();
router.push(`/dashboard/agents/${data.id}`);
},
});
return (
<TooltipProvider>
<Card>
@@ -38,12 +38,11 @@ export const BackupButton = (props: BackupButtonProps) => {
<ButtonWithLoading
icon={<DatabaseZap/>}
disabled={props.disable}
text={isMobile ? "" : "Backup"}
isPending={mutation.isPending}
size={"default"}
onClick={async () => {
await HandleAction();
}}
/>
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
);
};
@@ -18,7 +18,7 @@ export const LoggedInDropdown = (props: LoggedInDropdownProps) => {
return (
<DropdownMenu>
<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
onClick={() => {
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 {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
import {env} from "@/env.mjs";
export function AppSidebar() {
const projectName = env.PROJECT_NAME;
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLogo/>
<SidebarLogo projectName={projectName ?? "Portabase"}/>
</SidebarMenuItem>
<SidebarMenuItem>
<OrganizationCombobox/>
@@ -32,10 +33,10 @@ export function AppSidebar() {
<SidebarMenu className="mb-2">
<SidebarMenuItem>
<LoggedInButton />
<LoggedInButton/>
</SidebarMenuItem>
</SidebarMenu>
<SideBarFooterCredit />
<SideBarFooterCredit/>
</Sidebar>
);
}
@@ -2,12 +2,13 @@
import { useSidebar } from "@/components/ui/sidebar";
import Link from "next/link";
import { env } from "@/env.mjs";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
export const SidebarLogo = () => {
export const SidebarLogo = ({projectName}: {
projectName: string;
}) => {
const { state, isMobile } = useSidebar();
const { resolvedTheme } = useTheme();
@@ -28,7 +29,7 @@ export const SidebarLogo = () => {
<Image
loading="eager"
src={"/images/logo.png"}
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
alt={`Logo ${projectName}`}
className="h-10 w-10 object-contain"
height={10}
width={10}
@@ -38,7 +39,7 @@ export const SidebarLogo = () => {
<Image
loading="eager"
src={imageTheme}
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
alt={`Logo ${projectName}`}
className="object-contain"
width={190}
height={90}
@@ -29,11 +29,12 @@ export const SidebarMenuCustomMain = () => {
const groupContent: SidebarGroupItem["group_content"] = [
{ title: "Projects", url: "/projects", icon: Layers, details:true },
{ title: "Statistics", url: "/statistics", icon: ChartArea },
{ title: "Settings", url: "/settings", icon: Settings, details:true }
];
if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
}
// if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
// groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
// }
const items: SidebarGroupItem[] = [
{
@@ -41,7 +41,6 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
onSuccess?.();
toast.success(result.data.actionSuccess?.message || "Organization Created.");
// router.push("/");
router.replace(`/dashboard/home`);
} else {
// @ts-ignore
@@ -82,26 +81,6 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
</FormItem>
)}
/>
{/*<FormField*/}
{/* control={form.control}*/}
{/* name="slug"*/}
{/* defaultValue=""*/}
{/* render={({field}) => (*/}
{/* <FormItem>*/}
{/* <FormLabel>Slug</FormLabel>*/}
{/* <FormControl>*/}
{/* <Input*/}
{/* {...field}*/}
{/* onChange={(e) => {*/}
{/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
{/* field.onChange(value);*/}
{/* }}*/}
{/* />*/}
{/* </FormControl>*/}
{/* <FormMessage/>*/}
{/* </FormItem>*/}
{/* )}*/}
{/*/>*/}
<DialogFooter>
<div className="flex items-center justify-between w-full">
<Button type="submit">Create</Button>
@@ -2,7 +2,6 @@
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
import {useMutation} from "@tanstack/react-query";
import {setCurrentOrganizationSlug} from "@/features/dashboard/organization-cookie";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {authClient} from "@/lib/auth/auth-client";
@@ -17,17 +16,17 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
const {data: organizations, refetch} = authClient.useListOrganizations();
const mutation = useMutation({
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
mutationFn: () => deleteOrganizationAction({slug: props.organizationSlug}),
onSuccess: async (result) => {
if (result?.data?.success) {
await authClient.organization.setActive({
organizationSlug: "default",
});
router.push("/");
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
router.refresh()
refetch()
router.push("/");
} else {
// @ts-ignore
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" &&
<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);
toast.error(error?.message || "A network error occurred.");
},
});
@@ -8,12 +8,11 @@ import {
OrganizationFormSchema
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
import {and, eq, inArray, or} from "drizzle-orm";
import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
import {slugify} from "@/utils/slugify";
import {Organization} from "@/db/schema/03_organization";
import * as drizzleDb from "@/db";
import {headers} from "next/headers";
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
try {
@@ -84,7 +83,6 @@ export const updateOrganizationAction = userAction
}
});
if (!organization) {
return {
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) {
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
.update(drizzleDb.schemas.organization)
.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>> => {
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({
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
where: or(...conditions),
});
if (!org) {
@@ -221,8 +182,6 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
let deletedOrganization: Organization;
try {
// TODO : Improve with better auth, always getting 403 error
// deletedOrganization = await deleteOrganization(org.id) as Organization;
[deletedOrganization] = await db
.delete(drizzleDb.schemas.organization)
.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 { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/accounts/table-columns";
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
import {Session} from "better-auth";
export type UserFormProps = {
@@ -54,8 +54,8 @@ export const UserForm = (props: UserFormProps) => {
}
toast.success(`Profile updated successfully.`);
router.push(`/dashboard/profile`);
router.refresh();
router.push(`/dashboard/profile`);
},
});

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