Compare commits

..
Author SHA1 Message Date
charlesgauthereau 2a90d4933d Ready for rc-1.1.3 2025-10-12 20:33:59 +02:00
charlesgauthereau 1fbd979d40 Ready for 1.1.2 2025-09-28 12:20:03 +02:00
charlesgauthereau 4e06d2bf97 Ready for 1.1.2-rc.3 2025-09-26 09:22:11 +02:00
charlesgauthereau 8ce3988613 Ready for 1.1.2-rc.3 2025-09-26 09:15:22 +02:00
charlesgauthereau b7f161d200 Ready for 1.1.2-rc.3 2025-09-26 09:04:18 +02:00
charlesgauthereau a3f5c4e494 Ready for 1.1.2-rc.2 2025-09-23 21:19:11 +02:00
charlesgauthereau f74f378c0b Ready for 1.1.2-rc.1 2025-09-23 20:50:20 +02:00
charlesgauthereau 385836d5fe Ready for 1.1.2-rc.1 2025-09-23 20:35:48 +02:00
charlesgauthereau 461e3ff7c9 Ready for 1.1.2-rc.1 2025-09-23 19:55:07 +02:00
charlesgauthereau 54a233b207 Ready for 1.1.2-rc.1 2025-09-23 19:50:33 +02:00
charlesgauthereau 2d2453acf7 Refactoring on some files 2025-09-23 19:42:38 +02:00
charlesgauthereau 654ff6b9a5 Finish working on backups side deletion feature, to be review, test and deply in RC for tests. 2025-09-22 20:31:29 +02:00
charlesgauthereau 34ff11f377 Working on table data in detabase backups. 2025-09-19 13:28:40 +02:00
charlesgauthereau b95e145a8b Working on retention policy and cron system. 2025-09-19 09:58:54 +02:00
charlesgauthereau bce6c9d55f Working on retention policy and cron system. 2025-09-18 09:47:08 +02:00
charlesgauthereau c3e2978919 Working on retention policy and cron system. 2025-09-14 16:46:23 +02:00
charlesgauthereau 2d559e456d Adding methods to delete backups on remote storage s3 and local. Some refactoring on models db and adding common.ts model with createdAt, updatedAt, and deletedAt. 2025-08-31 18:24:07 +02:00
charlesgauthereau 45e3b31fda Adding methods to delete backups on remote storage s3 and local. Some refactoring on models db and adding common.ts model with createdAt, updatedAt, and deletedAt. 2025-08-29 15:31:23 +02:00
charlesgauthereau c8da2579e8 Working on the retention system. 2025-08-28 10:01:17 +02:00
charlesgauthereau c7e30e1659 Working on the retention system. 2025-08-28 09:59:32 +02:00
charlesgauthereau 7be23876aa Working on the retention system. 2025-08-28 09:56:10 +02:00
charlesgauthereau 98f1b329ae Working on the retention system. 2025-08-28 09:53:32 +02:00
charlesgauthereau 19ea5543ea Working on the retention system. 2025-08-28 09:47:42 +02:00
charlesgauthereau 010396ba4d Working on the retention system. 2025-08-28 09:45:47 +02:00
charlesgauthereau f055744432 Working on the retention system. 2025-08-28 09:45:32 +02:00
charlesgauthereau 1920b29a55 Working on the retention system. 2025-08-28 09:45:22 +02:00
charlesgauthereau a0c8d1fb68 Working on the retention system. 2025-08-28 09:34:02 +02:00
charlesgauthereau 59dc3b71b8 Working on the retention system. 2025-08-28 09:32:30 +02:00
charlesgauthereau 598eb44952 Working on the retention system. 2025-08-28 09:31:57 +02:00
charlesgauthereau ac49047177 Working on the retention system. 2025-08-28 09:27:47 +02:00
charlesgauthereau 4448c9e166 Working on the retention system. 2025-08-28 09:23:04 +02:00
charlesgauthereau d70778b556 Working on the retention system. 2025-08-28 09:17:57 +02:00
charlesgauthereau 66edb77113 Working on the retention system. 2025-08-28 09:16:54 +02:00
charlesgauthereau 0f7b3eae11 Working on the retention system. 2025-08-27 21:38:58 +02:00
charlesgauthereau 2f9e33f4db Patching a bug when delete an organization, add refetch. 2025-08-27 21:16:13 +02:00
charlesgauthereau dd7a65262c Patching a bug when delete an organization, add refetch. 2025-08-27 18:07:10 +02:00
charlesgauthereau 4c0d2b1f7f Adding new version of the ButtonWithConfirm component. 2025-08-27 18:01:28 +02:00
charlesgauthereau 60728885b6 Working on the retention system. 2025-08-24 20:11:34 +02:00
charlesgauthereau 7ac99a6618 Working on the organization and permissions. 2025-08-24 19:34:18 +02:00
charlesgauthereau 4ed91c1297 Working on the organization and permissions. 2025-08-24 19:33:42 +02:00
charlesgauthereau 44be8009fd Bug on stats. 2025-08-07 16:23:06 +02:00
charlesgauthereau 96630a0ef7 Merge branch 'feature' 2025-08-07 15:46:52 +02:00
charlesgauthereau 8c9d694b40 Bug on init. 2025-08-07 15:46:41 +02:00
charlesgauthereau 1f09c1507a Merge branch 'feature'
# Conflicts:
#	src/features/dashboard/backup/columns.tsx
2025-08-07 15:06:05 +02:00
Soluce TechnologiesandGitHub 141241d555 Update README.md 2025-08-05 08:22:36 +02:00
Soluce TechnologiesandGitHub c605e8173b Update README.md 2025-08-05 08:21:18 +02:00
charles-gauthereau 37eb8f319f Merge remote-tracking branch 'origin/main' 2025-08-04 08:07:38 +02:00
charles-gauthereau 0492d244b3 columns.tsx 2025-08-04 08:07:26 +02:00
charlesgauthereau baeec9f7a0 Some fix on the s3 and local methods for the files saving, we have to improve testing. Working on table actions. 2025-08-03 12:17:10 +02:00
charlesgauthereau 449c9c2cfe Working on the s3 save dump feature. 2025-08-02 20:13:29 +02:00
charlesgauthereau d13e4b7d40 Working on statistics 2025-08-02 11:14:51 +02:00
charlesgauthereau 13241811d0 Placeholder empty state component. 2025-08-02 08:48:45 +02:00
Soluce TechnologiesandGitHub 4b52dc4a1e Update README.md 2025-08-01 09:35:51 +02:00
charlesgauthereau 63cb6cb7a3 Working on some routing errors. 2025-08-01 09:21:37 +02:00
charlesgauthereau 0511ad289c Merge branch 'main' into feature
# Conflicts:
#	README.md
2025-08-01 08:35:20 +02:00
charlesgauthereau 057a51499e Release 2025-08-01 08:35:02 +02:00
charlesgauthereau 1f01d9c372 Release 2025-08-01 08:34:17 +02:00
charlesgauthereau 0684a4887a Merge branch 'main' into feature
# Conflicts:
#	app/(customer)/dashboard/[slug]/statistics/page.tsx
2025-08-01 08:33:26 +02:00
charlesgauthereau 96be14e1ba Working on the s3 feature. 2025-07-31 17:58:40 +02:00
charlesgauthereau eec3c2737b Working on the s3 feature. 2025-07-31 17:32:03 +02:00
charlesgauthereau a6b960ffb2 Working on the s3 feature. 2025-07-29 09:53:38 +02:00
charlesgauthereau d3f465b5d4 Working on the home page. 2025-07-29 08:51:50 +02:00
charlesgauthereau 2139dfd55d For release. 2025-07-28 18:55:49 +02:00
charles-gauthereau 6562718635 Correcting some bugs in statistics. 2025-01-12 17:53:39 +01:00
141 changed files with 6887 additions and 1190 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Security Checks
on:
pull_request:
push:
branches: [ main ]
jobs:
sca-deps: # Dependency & container scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@0.20.0
with:
scan-type: 'fs'
format: 'table'
severity: 'CRITICAL,HIGH'
ignore-unfixed: true
secrets-gitleaks: # Secrets exposure
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history for gitleaks
- uses: gitleaks/gitleaks-action@v2
with:
config-path: .gitleaks.toml # Optional, if you have a custom config
+10
View File
@@ -0,0 +1,10 @@
title = "Custom gitleaks config"
[allowlist]
# Global allowlist patterns (wont be flagged)
regexes = [
"s3SecretAccessKey: env.S3_SECRET_KEY ?? null",
]
paths = [
"src/utils/init.ts"
]
+5
View File
@@ -0,0 +1,5 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks
Binary file not shown.
+8 -9
View File
@@ -12,14 +12,21 @@
<a href="https://portabase.io"><strong>Explore the Docs »</strong></a>
<br />
<br />
<a href="https://portabase.io">View Demo</a>
<a href="https://www.youtube.com/watch?v=D9uFrGxLc4s">View Demo</a>
·
<a href="https://github.com/Soluce-Technologies/portabase/issues/new?labels=bug&template=bug-report---.md">Report Bug</a>
·
<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>
## 📚 Table of Contents
- [About The Project](#about-the-project)
@@ -185,14 +192,6 @@ major.minor.patch-rc.release-tag
# Example: 1.0.0-rc.1-dev
```
### Zenstack Support
To regenerate schema:
```bash
zenstack generate
```
---
## 📄 License
-3
View File
@@ -15,13 +15,10 @@ export default function Layout({children}: { children: React.ReactNode }) {
const router = useRouter();
const { data: session } = useSession();
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
router.replace("/dashboard/home");
}
useEffect(() => {
setMounted(true);
}, []);
@@ -1,18 +1,21 @@
import { PageParams } from "@/types/next";
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
import { AdminTabs } from "@/components/wrappers/dashboard/admin/admin-tabs";
import { db } from "@/db";
import {PageParams} from "@/types/next";
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";
export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({
where: (fields) => isNull(fields.deletedAt)
where: (fields) => isNull(fields.deletedAt),
with: {
accounts: true
}
});
const settings = await db.query.setting.findFirst({
where: (fields, { eq }) => eq(fields.name, "system"),
where: (fields, {eq}) => eq(fields.name, "system"),
});
return (
@@ -21,7 +24,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<PageTitle>Administration Panel</PageTitle>
</PageHeader>
<PageContent>
<AdminTabs settings={settings!} users={users} />
<AdminTabs settings={settings!} users={users}/>
</PageContent>
</Page>
);
@@ -4,7 +4,7 @@ import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {formatDateLastContact} from "@/utils/date-formatting";
import {buttonVariants} from "@/components/ui/button";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
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";
@@ -16,6 +16,8 @@ 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";
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
@@ -30,50 +32,16 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
})
if (!agent) {
notFound()
}
//
// const databaseId = 'db-123';
//
console.log("agent",agent)
// const totalBackupsResult = await db
// .select({ count: drizzleDb.schemas.backup.id })
// .from(drizzleDb.schemas.backup)
// .where(eq(drizzleDb.schemas.backup.databaseId, databaseId))
// .execute();
//
// const totalBackups = totalBackupsResult.length;
//
// const successfulBackupsResult = await db
// .select({ count: drizzleDb.schemas.backup.id })
// .from(drizzleDb.schemas.backup)
// .where(
// and(
// eq(drizzleDb.schemas.backup.databaseId, databaseId),
// eq(drizzleDb.schemas.backup.status, "success")
// )
// )
// .execute();
//
//
// const successfulBackups = successfulBackupsResult.length;
//
// const successRate =
// totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{agent.name}
{capitalizeFirstLetter(agent.name)}
<Link className={buttonVariants({variant: "outline"})}
href={`/dashboard/agents/${agent.id}/edit`}>
<GearIcon className="w-7 h-7"/>
@@ -86,26 +54,26 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
<PageContent className="flex flex-col w-full h-full">
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Databases
</CardHeader>
<CardContent>{agent.databases.length}</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Success rate
<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" />
</CardHeader>
<CardContent>
{/*{successRate ?? "Unavailable for now."}*/}
<div className="text-2xl font-bold">{agent.databases.length}</div>
<p className="text-xs text-muted-foreground">Databases linked to this agent</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">
Last contact
<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" />
</CardHeader>
<CardContent>
{formatDateLastContact(agent.lastContact)}
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
<p className="text-xs text-muted-foreground">Last contact with agent</p>
</CardContent>
</Card>
@@ -9,6 +9,9 @@ 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 {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
export const dynamic = "force-dynamic";
export default async function RoutePage(props: PageParams<{}>) {
@@ -38,12 +41,10 @@ export default async function RoutePage(props: PageParams<{}>) {
{agents.length > 0 ? (
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
) : (
<Link
href={"/dashboard/agents/new"}
className=" flex item-center justify-center border-2 border-dashed transition-colors border-primary p-8 lg:p-12 w-full rounded-md"
>
Create new Agent
</Link>
<EmptyStatePlaceholder
url={"/dashboard/agents/new"}
text={"Create new Agent"}
/>
)}
</PageContent>
</Page>
@@ -1,21 +0,0 @@
import { PageParams } from "@/types/next";
import {Page, PageHeader, PageTitle} from "@/features/layout/page";
export default async function RoutePage(props: PageParams<{}>) {
return (
<Page>
<PageHeader>
<PageTitle>Dashboard</PageTitle>
</PageHeader>
<div className="flex flex-1 flex-col gap-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
</div>
</Page>
);
}
@@ -27,6 +27,7 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
<PageContent>
<DatabaseForm
databaseId={databaseId}
// @ts-ignore
defaultValues={{ ...dbItem, dbms: dbItem.dbms ?? "inactive", description: dbItem.description ?? undefined }}
/>
</PageContent>
@@ -1,5 +1,5 @@
import {PageParams} from "@/types/next";
import {notFound} from "next/navigation";
import {notFound, redirect} from "next/navigation";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {DatabaseTabs} from "@/components/wrappers/dashboard/projects/database/database-tabs";
@@ -8,18 +8,40 @@ import {EditButton} from "@/components/wrappers/dashboard/database/edit-button/e
import {CronButton} from "@/components/wrappers/dashboard/database/cron-button/cron-button";
import {db} from "@/db";
import {eq, and} from "drizzle-orm";
import {eq, and, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/lib/services";
import {getOrganization} from "@/lib/auth/auth";
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
import {capitalizeFirstLetter} from "@/utils/text";
export default async function RoutePage(props: PageParams<{ databaseId: string }>) {
const {databaseId} = await props.params;
export default async function RoutePage(props: PageParams<{
projectId: string;
databaseId: string
}>) {
const {projectId, databaseId} = await props.params;
const organization = await getOrganization({});
if (!organization) {
notFound();
}
const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug,
projectId: projectId
})
const dbItem = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)),
with: {
project: true,
retentionPolicy: true
}
});
if (!dbItem) {
notFound();
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
@@ -38,37 +60,59 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
const isAlreadyBackup = backups.some((b) => b.status === "waiting");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const [totalBackups, successfulBackups] = await Promise.all([
db
.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length),
db
.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(eq(drizzleDb.schemas.backup.databaseId, dbItem.id), eq(drizzleDb.schemas.backup.status, "success")))
.then((rows) => rows.length),
]);
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then(rows => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
eq(drizzleDb.schemas.backup.status, "success")
))
.then(rows => rows.length);
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
notFound();
}
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{dbItem.name}
<EditButton/>
<CronButton database={dbItem}/>
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full">
<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}/>
</div>
<div className="flex items-center gap-2">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</div>
</div>
</PageTitle>
<PageActions className="justify-between">
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
</PageActions>
</div>
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
{dbItem.description && (
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} totalBackups={totalBackups} />
<DatabaseTabs database={dbItem} isAlreadyRestore={isAlreadyRestore} backups={backups} restorations={restorations} />
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
</PageContent>
</Page>
);
@@ -6,7 +6,7 @@ import * as drizzleDb from "@/db";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {DatabaseWith} from "@/db/schema/06_database";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
const {projectId} = await props.params;
@@ -42,10 +42,9 @@ export default async function RoutePage(props: PageParams<{ projectId: string }>
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
);
// .filter((db): db is DatabaseWith => db.project !== null);
)
// .filter((db) => db.project !== null) as DatabaseWith[];
console.log("ici34",availableDatabases);
return (
@@ -57,7 +56,7 @@ export default async function RoutePage(props: PageParams<{ projectId: string }>
<ProjectForm
organization={org}
databases={availableDatabases as DatabaseWith[]}
defaultValues={{name: proj.name, slug: proj.slug, databases: proj.databases.map((db) => db.id)}}
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
projectId={proj.id}
/>
</PageContent>
@@ -1,28 +1,29 @@
import { PageParams } from "@/types/next";
import { Page, PageActions, PageContent, PageDescription, PageTitle } from "@/features/layout/page";
import { buttonVariants } from "@/components/ui/button";
import { GearIcon } from "@radix-ui/react-icons";
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
import {buttonVariants} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import { ButtonDeleteProject } from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
import { ProjectDatabaseCard } from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import { notFound } from "next/navigation";
import {
ButtonDeleteProject
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {notFound, redirect} from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text";
export default async function RoutePage(props: PageParams<{
// slug: string;
projectId: string
}>) {
const {
// slug: organizationSlug,
projectId } = await props.params;
projectId
} = await props.params;
const organization = await getOrganization({});
if (!organization) {
notFound();
}
@@ -33,26 +34,32 @@ export default async function RoutePage(props: PageParams<{
if (!org) notFound();
const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
where: (proj, {
and,
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) notFound();
if (!proj) {
redirect("/dashboard/projects");
}
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center">
{proj.name}
<Link className={buttonVariants({ variant: "outline" })} href={`/dashboard/projects/${proj.id}/edit`}>
<GearIcon className="w-7 h-7" />
{capitalizeFirstLetter(proj.name)}
<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"} />
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
</PageActions>
</div>
@@ -6,7 +6,7 @@ import {db} from "@/db";
import {eq} from "drizzle-orm";
import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
import {DatabaseWith} from "@/db/schema/06_database";
import {DatabaseWith} from "@/db/schema/07_database";
export default async function RoutePage(props: PageParams<{ }>) {
@@ -27,7 +27,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project !== null) as DatabaseWith[];
).filter((db) => db.project == null) as DatabaseWith[];
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
@@ -35,6 +35,7 @@ export default async function RoutePage(props: PageParams<{ }>) {
if (!org) notFound();
return (
<Page>
<PageHeader>
@@ -8,6 +8,7 @@ import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card
import {db} from "@/db";
import {notFound} from "next/navigation";
import {getOrganization} from "@/lib/auth/auth";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
export default async function RoutePage(props: PageParams<{ }>) {
@@ -46,12 +47,10 @@ export default async function RoutePage(props: PageParams<{ }>) {
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
cardsPerPage={4} numberOfColumns={1}/>
) : (
<Link
href={`/dashboard/projects/new`}
className=" flex item-center justify-center border-2 border-dashed transition-colors border-primary p-8 lg:p-12 w-full rounded-md"
>
Create new Project
</Link>
<EmptyStatePlaceholder
url={"/dashboard/projects/new"}
text={"Create new Project"}
/>
)}
</PageContent>
</Page>
@@ -1,5 +1,5 @@
import {PageParams} from "@/types/next";
import {Page, PageActions, PageContent, PageDescription, PageHeader, PageTitle} from "@/features/layout/page";
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {currentUser} from "@/lib/auth/current-user";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {notFound} from "next/navigation";
@@ -17,7 +17,6 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const user = await currentUser();
const activeMember = await getActiveMember()
if (!organization) {
notFound();
}
@@ -28,19 +27,18 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
notFound();
}
return (
<Page>
<PageHeader>
<PageTitle className="flex items-center">
Organization settings
{!isMember && organization.slug !== "default" && (
<EditButtonSettings />
<EditButtonSettings/>
)}
</PageTitle>
<PageActions>
{!isMember && organization.slug !== "default" && (
<DeleteOrganizationButton organizationSlug={organization.slug} />
<DeleteOrganizationButton organizationSlug={organization.slug}/>
)}
</PageActions>
</PageHeader>
@@ -48,7 +46,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
{/* Manage your organization settings.*/}
{/*</PageDescription>*/}
<PageContent>
<SettingsOrganizationMembersTable organization={organization} />
<SettingsOrganizationMembersTable organization={organization}/>
</PageContent>
</Page>
)
@@ -5,7 +5,7 @@ import {EvolutionLineChart} from "@/components/wrappers/dashboard/statistics/cha
import {PercentageLineChart} from "@/components/wrappers/dashboard/statistics/charts/percentage-line-chart";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {asc, count, eq, inArray} from "drizzle-orm";
import {and, asc, count, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {getOrganization} from "@/lib/auth/auth";
import {DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
@@ -46,6 +46,78 @@ 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,
@@ -53,7 +125,7 @@ export default async function RoutePage(props: PageParams<{}>) {
_count: count(),
})
.from(drizzleDb.schemas.backup)
.where(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]))
.where(and(inArray(drizzleDb.schemas.backup.status, ["success", "failed"]), inArray(drizzleDb.schemas.backup.databaseId, databaseIds)))
.groupBy(drizzleDb.schemas.backup.createdAt, drizzleDb.schemas.backup.status)
.orderBy(drizzleDb.schemas.backup.createdAt);
@@ -73,6 +145,10 @@ export default async function RoutePage(props: PageParams<{}>) {
const backupsEvolutionCount = backupsEvolution.length;
const sortedBackupsEvolution = backupsEvolution.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return (
<Page>
<PageHeader>
@@ -108,7 +184,7 @@ export default async function RoutePage(props: PageParams<{}>) {
<CardTitle>Evolution of the number of backups</CardTitle>
</CardHeader>
<CardContent>
<EvolutionLineChart data={backupsEvolution}/>
<EvolutionLineChart data={sortedBackupsEvolution}/>
</CardContent>
</Card>
<Card className="w-full">
+134
View File
@@ -0,0 +1,134 @@
import {PageParams} from "@/types/next";
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflow} from "lucide-react";
import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation";
import {db} from "@/db";
import {asc, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth";
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
const organizations = await listOrganizations()
if (!user || !organizations) notFound();
const organizationIds = organizations.map(project => project.id);
const agents = await db.query.agent.findMany({});
const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds),
});
const projectIds = projects.map(project => project.id);
const databasesOfAllProjects = await db.query.database.findMany({
where: inArray(drizzleDb.schemas.database.projectId, projectIds),
})
const databaseIds = databasesOfAllProjects.map((database) => database.id);
const backupsEvolution = await db.query.backup.findMany({
columns: {
id: true,
createdAt: true,
deletedAt: true,
},
orderBy: [asc(drizzleDb.schemas.backup.id)],
where: inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
});
const availableBackups = backupsEvolution.filter(backup => backup.deletedAt == null);
return (
<Page>
<PageHeader>
<PageTitle>Dashboard</PageTitle>
</PageHeader>
<PageContent className="flex flex-col gap-y-4">
<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 space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Organizations</CardTitle>
<Building2 className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{organizations.length}</div>
<p className="text-xs text-muted-foreground">Number of organizations</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<Folder className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projects.length}</div>
<p className="text-xs text-muted-foreground">Number of projects</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Databases</CardTitle>
<Database className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{databasesOfAllProjects.length}</div>
<p className="text-xs text-muted-foreground">Databases across all projects</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Agents</CardTitle>
<Workflow className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{agents.length}</div>
<p className="text-xs text-muted-foreground">Registered agents</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{backupsEvolution.length}</div>
<p className="text-xs text-muted-foreground">All backups recorded</p>
</CardContent>
</Card>
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Available Backups</CardTitle>
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{availableBackups.length}</div>
<p className="text-xs text-muted-foreground">Currently active backups</p>
</CardContent>
</Card>
</div>
{/*Do not delete*/}
{/*<div className="flex flex-1 flex-col gap-4">*/}
{/* <div className="grid auto-rows-min gap-4 md:grid-cols-3">*/}
{/* <div className="aspect-video rounded-xl bg-muted/50"/>*/}
{/* <div className="aspect-video rounded-xl bg-muted/50"/>*/}
{/* <div className="aspect-video rounded-xl bg-muted/50"/>*/}
{/* </div>*/}
{/*</div>*/}
</PageContent>
</Page>
);
}
+8 -7
View File
@@ -6,10 +6,10 @@ import {Badge} from "@/components/ui/badge";
import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/button-delete-account/button-delete-account";
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";
export default async function RoutePage(props: PageParams<{}>) {
const user = await currentUser();
console.log("my user",user);
if (!user) {
return notFound();
}
@@ -18,8 +18,8 @@ export default async function RoutePage(props: PageParams<{}>) {
return notFound();
}
// const sessions = await getSessions();
// const accounts = await getAccounts();
const sessions = await getSessions();
const accounts = await getAccounts();
return (
<Page>
@@ -43,18 +43,19 @@ export default async function RoutePage(props: PageParams<{}>) {
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</PageActions>*/}
</div>
<PageContent>
<PageContent >
<UserForm
userId={user.id}
sessions={sessions} accounts={accounts}
defaultValues={{
name: user.name,
email: user.email,
role: user.role ?? undefined,
}}
/>
<div className="mt-4 sm:hidden">
<ButtonDeleteAccount text="Delete my account"/>
</div>
{/*<div className="mt-4 sm:hidden ">*/}
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
{/*</div>*/}
</PageContent>
</Page>
);
+23 -6
View File
@@ -1,12 +1,14 @@
import {NextResponse} from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import {uploadLocalPrivate} from "@/features/upload/private/upload.action";
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
import {v4 as uuidv4} from "uuid";
import {eventEmitter} from "../../../events/route";
import {db} from "@/db";
import {Backup} from "@/db/schema/06_database";
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";
export async function POST(
request: Request,
@@ -48,6 +50,9 @@ export async function POST(
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
with: {
project: true
}
});
if (!database) {
@@ -108,7 +113,19 @@ export async function POST(
const fileName = `${uuid}.dump`;
const buffer = Buffer.from(await file.arrayBuffer());
const {success, message, filePath} = await uploadLocalPrivate(fileName, buffer);
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.");
}
let success: boolean, message: string, filePath: string;
const result =
settings.storage === "local"
? await uploadLocalPrivate(fileName, buffer)
: await uploadS3Private(`${database.project?.slug}/${fileName}`, buffer, env.S3_BUCKET_NAME!);
({success, message, filePath} = result);
if (!success) {
return NextResponse.json(
@@ -119,10 +136,10 @@ export async function POST(
await db
.update(drizzleDb.schemas.backup)
.set({
.set(withUpdatedAt({
file: fileName,
status: 'success',
})
}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true});
@@ -137,7 +154,7 @@ export async function POST(
await db
.update(drizzleDb.schemas.backup)
.set({status: 'failed'})
.set(withUpdatedAt({status: 'failed'}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
eventEmitter.emit('modification', {update: true});
-1
View File
@@ -26,7 +26,6 @@ export async function POST(
const body: BodyResultRestore = await request.json();
console.log(body)
console.log(agentId)
if (!isUuidv4(body.generatedId)) {
+73 -25
View File
@@ -1,13 +1,17 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/07_agent";
import {Database} from "@/db/schema/06_database";
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/08_agent";
import {Database} from "@/db/schema/07_database";
import * as drizzleDb from "@/db";
import {db as dbClient} from "@/db";
import {and, eq} from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
import {ServerActionResult} from "@/types/action-type";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
import {withUpdatedAt} from "@/db/utils";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
const databasesResponse = [];
@@ -35,17 +39,15 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
let backupAction: boolean = false
let restoreAction: boolean = false
let UrlBackup: string = ""
let urlBackup: string = ""
if (!existingDatabase) {
if (!isUuidv4(db.generatedId)) {
return NextResponse.json(
{ error: "generatedId is not a valid uuid" },
{ status: 500 }
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
console.log(db)
console.log(dbmsEnumSchema.parse(db.dbms))
const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database)
.values({
@@ -58,62 +60,108 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
.returning();
if (databaseCreated) {
databasesResponse.push(formatDatabase(databaseCreated, backupAction,restoreAction, UrlBackup));
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
}
} else {
const [databaseUpdated] = await dbClient
.update(drizzleDb.schemas.database)
.set({ lastContact: lastContact })
.set({lastContact: lastContact})
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
const backup = await dbClient.query.backup.findFirst({
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status,"waiting"))
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
})
const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status,"waiting"))
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting"))
})
if(backup){
if (backup) {
backupAction = true
await dbClient
.update(drizzleDb.schemas.backup)
.set({ status: "ongoing" })
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, backup.id));
}
if(restoration){
if (restoration) {
restoreAction = true
const backupToRestore = await dbClient.query.backup.findFirst({
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
with: {
database: {
with: {
project: true
}
}
}
})
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json(
{error: "Unable to find settings"},
{status: 500}
);
}
const fileName = backupToRestore?.file
UrlBackup = await getFileUrlPresignedLocal(fileName ?? "")
let data: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string>, object> | undefined
try {
if (settings.storage == "local") {
data = await getFileUrlPresignedLocal(fileName!)
} else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
}
if (data?.data?.success) {
urlBackup = data.data.value ?? "";
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
// @ts-ignore
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
console.error("Restoration failed: ", errorMessage);
continue;
}
} catch (err) {
console.error("Restoration crashed unexpectedly:", err);
await dbClient
.update(drizzleDb.schemas.restoration)
.set({status: "failed"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue;
}
await dbClient
.update(drizzleDb.schemas.restoration)
.set({ status: "ongoing" })
.set({status: "ongoing"})
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, UrlBackup));
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
}
}
return databasesResponse;
}
}
+10 -5
View File
@@ -18,7 +18,7 @@ export type Body = {
databases: databaseAgent[]
}
// 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")
return Response.json({
@@ -34,29 +34,33 @@ export async function POST(
const agentId = (await params).agentId
const body: Body = await request.json();
const lastContact = new Date();
let message: string
if (!isUuidv4(agentId)) {
message = "agentId is not a valid uuid"
console.log(message)
return NextResponse.json(
{error: "agentId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
message = "Agent not found"
console.log(message)
return NextResponse.json({error: message}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact)
await db
.update(drizzleDb.schemas.agent)
.set({ lastContact: lastContact })
.set({lastContact: lastContact})
.where(eq(drizzleDb.schemas.agent.id, agentId));
eventEmitter.emit('modification', {update: true});
@@ -69,6 +73,7 @@ export async function POST(
databases: databasesResponse
}
console.log(response)
return Response.json(response)
} catch (error) {
console.error('Error in POST handler:', error);
+9 -1
View File
@@ -2,9 +2,17 @@ import path from "path";
import fs from "fs/promises";
import { NextResponse } from "next/server";
export async function GET({ params }: { params: Promise<{ fileName: string }> }) {
export async function GET(
request: Request,
{params}: { params: Promise<{ fileName: string }> }
) {
try {
const fileName = (await params).fileName;
console.log("fileName", fileName);
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
// Check if the file exists
+2 -1
View File
@@ -1,10 +1,10 @@
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";
const inter = Inter({subsets: ["latin"]});
@@ -24,6 +24,7 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-title" content="Portabase"/>
</head>
<body className={cn(inter.className, "h-full")}>
<ConsoleSilencer/>
<Providers>{children}</Providers>
</body>
</html>
+16 -13
View File
@@ -23,7 +23,7 @@ services:
db:
image: postgres:16-alpine
ports:
- "5432:5432"
- "5433:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
@@ -52,19 +52,22 @@ services:
# depends_on:
# - db
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:
# 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:
# minio_data:
# pgadmin-data:
+8 -9
View File
@@ -1,14 +1,13 @@
#!/bin/bash
echo " ____ __ __ "
echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
echo " "
echo " Community Edition v1.0.0 "
echo " "
#echo " ____ __ __ "
#echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
#echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
#echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
#echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
#echo " "
#echo " Community Edition v1.1.1 "
#echo " "
node server.js
+1 -1
View File
@@ -1,6 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const init = await import("@/utils/init");
init.init();
await init.init();
}
}
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-6
View File
@@ -44,12 +44,6 @@ const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
// compiler: {
// removeConsole: process.env.NODE_ENV === "production",
// },
experimental: {
nodeMiddleware: true,
},
async headers() {
return [
{
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.0",
"version": "1.1.3",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
@@ -65,9 +65,10 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.510.0",
"minio": "^8.0.5",
"next": "15.4.2-canary.10",
"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",
+2
View File
@@ -13,6 +13,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://code.iconify.com",
"https://cdn.iconify.design",
"https://api.iconify.design",
],
STYLE_SRC: [
"'self'",
@@ -31,6 +32,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://cdn.iconify.design",
"https://code.iconify.com",
"https://api.iconify.design",
"http://localhost:9000",
],
FONT_SRC: [
"'self'",
+2
View File
@@ -35,6 +35,8 @@ const buttonVariants = cva(
}
)
export type ButtonVariantsProps = VariantProps<typeof buttonVariants>;
function Button({
className,
variant,
+75 -73
View File
@@ -11,119 +11,121 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l",
// "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r",
// "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
@@ -0,0 +1,95 @@
"use client"
import {
Breadcrumb,
BreadcrumbItem, BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {usePathname} from "next/navigation";
import Link from "next/link";
import {useIsMobile} from "@/hooks/use-mobile";
import {capitalizeFirstLetter, isUUID} from "@/utils/text";
export function useBreadCrumbs() {
const pathname = usePathname();
const route_history = pathname
.split("/")
.filter((x: any) => x && x.length > 0);
const breadcrumb_routes = route_history.reduce(
(acc: { name: string; path: string }[], route) => {
const prev_path = acc[acc.length - 1]?.path ?? "";
acc.push({name: route, path: `${prev_path}/${route}`});
return acc;
},
[],
);
return {breadcrumb_routes};
}
interface BreadCrumbsProps {
}
export function BreadCrumbsWrapper() {
const isMobile = useIsMobile()
return (
<>
{!isMobile ? <BreadCrumbs/> : null}
</>
)
}
const FORBIDDEN_LINKS = ["organization", "dashboard", "database"];
export function BreadCrumbs({}: BreadCrumbsProps) {
const {breadcrumb_routes} = useBreadCrumbs();
if (breadcrumb_routes.length < 2) return null;
return (
<div className="flex w-full flex-wrap px-3 md:justify-end">
<Breadcrumb>
<BreadcrumbList>
{breadcrumb_routes.map((crumb: any) => {
const label = isUUID(crumb.name) ? "details" : crumb.name;
const isLast = breadcrumb_routes.length - 1 === breadcrumb_routes.indexOf(crumb);
const isForbidden = FORBIDDEN_LINKS.includes(crumb.name.toLowerCase());
return (
<div className="flex items-center gap-2" key={crumb.path}>
<BreadcrumbItem key={crumb.path}>
{isLast ? (
<BreadcrumbPage>{capitalizeFirstLetter(label)}</BreadcrumbPage>
) : (
<> {isForbidden ?
<BreadcrumbLink asChild>
<Link
href={crumb.path}
onClick={(e) => e.preventDefault()}
className="cursor-not-allowed "
>
{capitalizeFirstLetter(label)}
</Link>
</BreadcrumbLink>
:
<BreadcrumbLink asChild>
<Link href={crumb.path}>{capitalizeFirstLetter(label)}</Link>
</BreadcrumbLink>
}
</>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator className="hidden md:block"/>}
</div>
);
})}
</BreadcrumbList>
</Breadcrumb>
</div>
);
}
@@ -1,45 +1,161 @@
"use client";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { Loader2 } from "lucide-react";
export type VariantButton = {
secondary: string;
default: string;
outline: string;
ghost: string;
link: string;
destructive: string;
};
// "use client";
//
// import { Button } from "@/components/ui/button";
// import { useState } from "react";
// import { Loader2 } from "lucide-react";
//
// export type VariantButton = {
// secondary: string;
// default: string;
// outline: string;
// ghost: string;
// link: string;
// destructive: string;
// };
//
// export type ButtonWithConfirmProps = {
// icon?: any;
// text: string;
// variant?: keyof VariantButton;
// className?: string;
// onClick?: () => void;
// isPending?: boolean;
// };
//
// export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
// const [isConfirming, setIsConfirming] = useState(false);
//
// return (
// <Button
// onClick={() => {
// if (isConfirming && props.onClick) {
// props.onClick();
// } else {
// setIsConfirming(true);
// }
// }}
// variant={props.variant ? props.variant : "default"}
// className={props.className}
// >
// {props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
// {isConfirming ? "Are you sure ?" : `${props.text}`}
// <>{props.icon ? props.icon : null}</>
// </Button>
// );
// };
"use client"
import {Button, ButtonVariantsProps} from "@/components/ui/button";
import {useState} from "react";
import {Loader2} from "lucide-react";
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover";
import {cn} from "@/lib/utils";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
export type ButtonWithConfirmProps = {
icon?: any;
text: string;
variant?: keyof VariantButton;
className?: string;
onClick?: () => void;
title: string;
description: string;
button: {
main: {
className?: string;
text?: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
disabled?: boolean;
tooltipText?: string;
};
confirm: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
cancel: {
className?: string;
text: string;
icon?: any;
variant?: ButtonVariantsProps["variant"];
size?: ButtonVariantsProps["size"];
onClick?: () => void;
};
};
isPending?: boolean;
};
export const ButtonWithConfirm = (props: ButtonWithConfirmProps) => {
const [isConfirming, setIsConfirming] = useState(false);
return (
<Button
onClick={() => {
if (isConfirming && props.onClick) {
props.onClick();
} else {
setIsConfirming(true);
}
}}
variant={props.variant ? props.variant : "default"}
className={props.className}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16} />}
{isConfirming ? "Are you sure ?" : `${props.text}`}
<>{props.icon ? props.icon : null}</>
</Button>
<Popover open={isConfirming} onOpenChange={!props.button.main.disabled ? setIsConfirming : undefined}>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<span
className={cn(
"inline-flex",
props.button.main.disabled ? "cursor-not-allowed" : ""
)}
role="button">
<Button
disabled={!!props.button.main.disabled}
variant={props.button.main.variant ?? "default"}
size={props.button.main.size ?? "default"}
className={props.button.main.className}
onClick={() => {
if (!props.button.main.disabled) setIsConfirming(true);
}}
>
{props.button.main.icon}
{props.button.main.text && <span>{props.button.main.text}</span>}
</Button>
</span>
</PopoverTrigger>
</TooltipTrigger>
{props.button.main.tooltipText && props.button.main.disabled && (
<TooltipContent>
<p>{props.button.main.tooltipText}</p>
</TooltipContent>
)}
</Tooltip>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none">{props.title}</h4>
<p className="text-sm text-muted-foreground">{props.description}</p>
</div>
<div className="grid gap-2">
<Button
onClick={() => {
if (isConfirming && props.button.confirm.onClick) {
props.button.confirm.onClick();
setIsConfirming(false);
} else {
setIsConfirming(true);
}
}}
variant={props.button.confirm.variant ? props.button.confirm.variant : "default"}
size={props.button.main.size ?? "default"}
className={cn(props.button.main.className, "w-full")}
>
{props.isPending && <Loader2 className="animate-spin mr-4" size={16}/>}
{props.button.confirm.icon ? props.button.confirm.icon : null}
<span>{props.button.confirm.text}</span>
</Button>
<Button
variant={props.button.cancel.variant ? props.button.cancel.variant : "default"}
onClick={props.button.cancel.onClick ? () => props.button.cancel.onClick!() : () => setIsConfirming(false)}
className={cn(props.button.main.className, "w-full")}
size={props.button.main.size ?? "default"}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
};
@@ -1,9 +1,7 @@
"use client";
import {useEffect, useState} from "react";
import {Check, ChevronDown} from "lucide-react";
import {cn} from "@/lib/utils";
import {Button} from "@/components/ui/button";
import {Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList} from "@/components/ui/command";
@@ -110,7 +108,6 @@ export type comboBoxFormItemProps = comboBoxProps & {
onChange: any;
};
/** Combobox to use when working with zodForm. */
export function ComboBoxFormItem(props: comboBoxFormItemProps) {
const {values: choices, searchField = false, value, name, onChange} = props;
@@ -0,0 +1,15 @@
"use client";
import { useEffect } from "react";
export function ConsoleSilencer() {
useEffect(() => {
if (process.env.NODE_ENV === "production") {
for (const method of ["log", "debug", "info", "warn", "error"] as const) {
console[method] = () => {};
}
}
}, []);
return null;
}
@@ -0,0 +1,24 @@
import Link from "next/link";
import {cn} from "@/lib/utils";
import {Plus} from "lucide-react";
type EmptyStatePlaceholderProps = {
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>
)
}
@@ -0,0 +1,28 @@
import {Icon} from "@iconify/react";
import {CircleHelp, KeyRound} from "lucide-react";
export const providerSwitch = (provider: string) => {
switch (provider) {
case "google":
return (
<div className="p-4">
<Icon icon={"logos:google"} height="24" />
</div>
);
case "credential":
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<KeyRound height="24" />
<span>Email and Password</span>
</div>
);
default:
return (
<div className="flex flex-row gap-x-2 items-center p-4">
<CircleHelp height="24" />
<span>No credentials</span>
</div>
);
}
}
@@ -1,157 +1,3 @@
// "use client";
//
// import {
// ColumnDef,
// flexRender,
// getCoreRowModel,
// useReactTable,
// getPaginationRowModel,
// getSortedRowModel,
// SortingState,
// ColumnFiltersState,
// getFilteredRowModel,
// } from "@tanstack/react-table";
//
// import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// import { Input } from "@/components/ui/input";
//
// import { useState } from "react";
// import { TablePagination } from "./table-pagination";
// import { Checkbox } from "@/components/ui/checkbox";
//
// interface DataTableProps<TData, TValue> {
// columns: ColumnDef<TData, TValue>[];
// data: TData[];
// enableFilter?: boolean;
// enableSelect?: boolean;
// enablePagination?: boolean;
// paginationOptions?: {
// pageSize: number[];
// pageVisible: number;
// className?: string;
// };
// filterOptions?: {
// title?: string;
// key: string;
// };
// emptyText?: string;
// }
//
// export function DataTable<TData, TValue>({
// columns,
// data,
// enableFilter = false,
// enablePagination = true,
// enableSelect = true,
// paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
// filterOptions = { key: "id", title: "Filter by ID" },
// emptyText = "No data.",
// }: DataTableProps<TData, TValue>) {
// const [sorting, setSorting] = useState<SortingState>([]);
// const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
// const [rowSelection, setRowSelection] = useState({});
//
// if (enableSelect && data.length > 0) {
// const selectColumnExists = columns.some((column) => column.id === "select");
//
// if (!selectColumnExists) {
// columns.unshift({
// id: "select",
// header: ({ table }) => (
// <Checkbox
// checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
// aria-label="Select all"
// />
// ),
// cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
// enableSorting: false,
// enableHiding: false,
// });
// }
// }
//
// const table = useReactTable({
// data,
// columns,
// getCoreRowModel: getCoreRowModel(),
// getPaginationRowModel: getPaginationRowModel(),
// onSortingChange: setSorting,
// getSortedRowModel: getSortedRowModel(),
// onColumnFiltersChange: setColumnFilters,
// getFilteredRowModel: getFilteredRowModel(),
// onRowSelectionChange: setRowSelection,
// state: {
// sorting,
// columnFilters,
// rowSelection,
// },
// });
//
// return (
// <div className="h-full">
// {enableFilter && (
// <div className="flex items-center py-4">
// <Input
// placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
// value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
// onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
// className="max-w-sm"
// />
// </div>
// )}
// <div className="rounded-md border w-full">
// <Table className="w-full">
// <TableHeader>
// {table.getHeaderGroups().map((headerGroup) => (
// <TableRow key={headerGroup.id}>
// {headerGroup.headers.map((header) => {
// return (
// <TableHead key={header.id}>
// {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
// </TableHead>
// );
// })}
// </TableRow>
// ))}
// </TableHeader>
// <TableBody>
// {table.getRowModel().rows?.length ? (
// table.getRowModel().rows.map((row) => (
// <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
// {row.getVisibleCells().map((cell) => (
// <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
// ))}
// </TableRow>
// ))
// ) : (
// <TableRow>
// <TableCell colSpan={columns.length} className="h-24 text-center">
// {emptyText}
// </TableCell>
// </TableRow>
// )}
// </TableBody>
// </Table>
// </div>
// <div className="flex items-center justify-end space-x-2 py-4 mt-6">
// {enableSelect && (
// <div className="flex-1 text-sm text-muted-foreground">
// {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
// </div>
// )}
// {enablePagination && (
// <TablePagination
// table={table}
// maxVisiblePages={paginationOptions?.pageVisible}
// pageSizeOptions={paginationOptions.pageSize}
// className={paginationOptions.className}
// />
// )}
// </div>
// </div>
// );
// }
"use client"
import {
ColumnDef,
@@ -168,7 +14,7 @@ import {
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
import {Input} from "@/components/ui/input";
import {useState} from "react";
import {ReactNode, useEffect, useState} from "react";
import {TablePagination} from "./table-pagination";
import {Checkbox} from "@/components/ui/checkbox";
import {Button} from "@/components/ui/button";
@@ -195,6 +41,7 @@ interface DataTableProps<TData, TValue> {
path: string;
};
highlightRow?: (row: TData) => boolean;
selectedActions?: (rows: TData[]) => ReactNode;
}
@@ -207,12 +54,18 @@ export function DataTable<TData, TValue>({
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
filterOptions = {key: "id", title: "Filter by ID"},
emptyButton,
highlightRow
highlightRow,
selectedActions,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
useEffect(() => {
setRowSelection({});
}, [data]);
if (enableSelect && data.length > 0) {
const selectColumnExists = columns.some((column) => column.id === "select");
@@ -257,14 +110,20 @@ export function DataTable<TData, TValue>({
<div
className="flex flex-col h-full"
>
{enableFilter && (
{enableFilter || selectedActions && (
<div className="flex items-center py-4">
<Input
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
{enableFilter && (
<Input
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
className="max-w-sm"
/>
)}
{/*{selectedActions && table.getSelectedRowModel().rows.length > 0 && (*/}
{selectedActions && (
selectedActions(table.getSelectedRowModel().rows.map(row => row.original))
)}
</div>
)}
<div className="flex flex-col justify-between h-full">
@@ -277,6 +136,7 @@ export function DataTable<TData, TValue>({
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
@@ -288,7 +148,8 @@ export function DataTable<TData, TValue>({
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}
className={highlightRow && highlightRow(row.original) ? "bg-gray-100 pointer-events-none" : ""}
data-state={row.getIsSelected() && "selected"}>
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
@@ -342,10 +203,7 @@ export function DataTable<TData, TValue>({
/>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {Toggle} from "@/components/ui/toggle";
import {CheckIcon, Filter, RefreshCcw} from "lucide-react";
import {Badge} from "@/components/ui/badge";
export type FilterItem = {
label: string,
value: string
}
type FiltersDropdownProps = {
items: FilterItem[];
selectedItems: FilterItem[];
onSelect: (item: FilterItem) => void;
clearFilters: () => void;
}
export const FiltersDropdown = ({items, selectedItems, onSelect, clearFilters}: FiltersDropdownProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="relative">
<Toggle variant="outline" size="sm" className="cursor-pointer w-fit">
<Filter className="h-4 w-4"/>
</Toggle>
{selectedItems.length > 0 && (
<Badge className="absolute -top-2 -right-2 h-4 w-4 rounded-full p-0 flex items-center justify-center text-[10px]">
{selectedItems.length}
</Badge>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]">
{items.map((item, index) => {
const isSelected = selectedItems.some(f => f.value === item.value);
return (
<DropdownMenuItem
key={index}
className="flex items-center justify-between cursor-pointer"
onClick={(e) => {
e.preventDefault();
onSelect(item);
}}
>
<span>{item.label}</span>
{isSelected && <CheckIcon className="h-4 w-4 text-blue-500"/>}
</DropdownMenuItem>
)
})}
<DropdownMenuItem
disabled={selectedItems.length === 0}
className="flex gap-2 cursor-pointer"
onClick={(e) => {
e.preventDefault();
clearFilters();
}}
>
<RefreshCcw className='h-4 w-4'/>
Clear filters
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -1,10 +1,10 @@
"use server";
import { userAction } from "@/safe-actions";
import { z } from "zod";
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
export const updateEmailSettingsAction = userAction
.schema(
@@ -1,18 +1,31 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button";
import { useMutation } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import {Card, CardContent} from "@/components/ui/card";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import {Button} from "@/components/ui/button";
import {useMutation} from "@tanstack/react-query";
import {TooltipProvider} from "@/components/ui/tooltip";
import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/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";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import {
EmailFormSchema,
EmailFormType
} from "@/components/wrappers/dashboard/admin/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";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
export type EmailFormProps = {
defaultValues?: EmailFormType;
@@ -27,7 +40,7 @@ export const EmailForm = (props: EmailFormProps) => {
const mutation = useMutation({
mutationFn: async (values: EmailFormType) => {
const updateEmailSettings = await updateEmailSettingsAction({ name: "system", data: values });
const updateEmailSettings = await updateEmailSettingsAction({name: "system", data: values});
const data = updateEmailSettings?.data?.data;
if (updateEmailSettings?.serverError || !data) {
toast.error(updateEmailSettings?.serverError);
@@ -53,14 +66,14 @@ export const EmailForm = (props: EmailFormProps) => {
control={form.control}
name="smtpFrom"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>From Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email from where the email will be send"}</FormDescription>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -68,14 +81,14 @@ export const EmailForm = (props: EmailFormProps) => {
control={form.control}
name="smtpHost"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Server Host *</FormLabel>
<FormControl>
<Input placeholder={"ssl0.ovh.net"} {...field} />
</FormControl>
<FormDescription>{"Your email server host"}</FormDescription>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -83,14 +96,14 @@ export const EmailForm = (props: EmailFormProps) => {
control={form.control}
name="smtpPort"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Server Port *</FormLabel>
<FormControl>
<Input placeholder={"465"} {...field} />
</FormControl>
<FormDescription>{"Your email server port (send)"}</FormDescription>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -99,7 +112,7 @@ export const EmailForm = (props: EmailFormProps) => {
control={form.control}
name="smtpPassword"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
@@ -107,7 +120,7 @@ export const EmailForm = (props: EmailFormProps) => {
</FormControl>
<FormDescription>{"Your email server password"}</FormDescription>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -115,14 +128,14 @@ export const EmailForm = (props: EmailFormProps) => {
control={form.control}
name="smtpUser"
defaultValue=""
render={({ field }) => (
render={({field}) => (
<FormItem>
<FormLabel>User Email *</FormLabel>
<FormControl>
<Input placeholder={"exemple@portabase.com"} {...field} />
</FormControl>
<FormDescription>{"The email server user"}</FormDescription>
<FormMessage />
<FormMessage/>
</FormItem>
)}
/>
@@ -6,7 +6,8 @@ import { sendEmail } from "@/utils/email-helper";
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
import { render } from "@react-email/render";
import { toast } from "sonner";
import {Setting} from "@/db/schema/00_setting";
import {Setting} from "@/db/schema/01_setting";
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
export type SettingsEmailTabProps = {
settings: Setting;
@@ -53,7 +54,7 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
)}
</div>
<div className="mt-5">
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings : null} />
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } />
</div>
</div>
);
@@ -1,16 +1,19 @@
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 { useState } from "react";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation } from "@tanstack/react-query";
import { checkConnexionToS3 } from "@/features/upload/public/upload.action";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { updateStorageSettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
import {Setting} from "@/db/schema/00_setting";
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 {useState} from "react";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {checkConnexionToS3} from "@/features/upload/public/upload.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {
updateStorageSettingsAction
} from "@/components/wrappers/dashboard/admin/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";
export type SettingsStorageTabProps = {
settings: Setting;
@@ -33,7 +36,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
const [isSwitched, setIsSwitched] = useState<boolean>(props.settings.storage !== "local");
const updateMutation = useMutation({
mutationFn: () => updateStorageSettingsAction({ name: "system", data: { storage: isSwitched ? "s3" : "local" } }),
mutationFn: () => updateStorageSettingsAction({name: "system", data: {storage: isSwitched ? "s3" : "local"}}),
onSuccess: () => {
toast.success(`Settings updated successfully.`);
router.refresh();
@@ -48,14 +51,25 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
await updateMutation.mutateAsync();
};
const extractS3FormValues = (settings: Setting): S3FormType | undefined => {
if (!settings.s3EndPointUrl) return undefined;
return {
s3EndPointUrl: settings.s3EndPointUrl,
s3AccessKeyId: settings.s3AccessKeyId!,
s3SecretAccessKey: settings.s3SecretAccessKey!,
S3BucketName: settings.S3BucketName!,
};
};
return (
<div className="flex flex-col h-full py-4">
<h1>Settings for Portabase storage</h1>
<Alert className="mt-3">
<Info className="h-4 w-4" />
<Info className="h-4 w-4"/>
<AlertTitle>Informations</AlertTitle>
<AlertDescription>
Actually you can only store you data in one place : s3 compatible or in local. For exemple you cannot choose to store images in one place
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
cannot choose to store images in one place
and .dump files in another.
</AlertDescription>
</Alert>
@@ -79,14 +93,14 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
onClick={async () => {
await mutation.mutateAsync();
}}
icon={<ShieldCheck />}
icon={<ShieldCheck/>}
text="Test connexion"
/>
</div>
</div>
{isSwitched && (
<div className="mt-5">
<StorageS3Form defaultValues={props.settings.s3EndPointUrl ? props.settings : null} />
<StorageS3Form defaultValues={extractS3FormValues(props.settings)}/>
</div>
)}
</div>
@@ -1,11 +1,11 @@
"use server";
import { userAction } from "@/safe-actions";
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 { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
export const updateS3SettingsAction = userAction
.schema(
@@ -12,6 +12,7 @@ import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/
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 {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
export type S3FormProps = {
defaultValues?: S3FormType;
@@ -86,7 +87,7 @@ export const StorageS3Form = (props: S3FormProps) => {
<FormItem>
<FormLabel>Secret Key *</FormLabel>
<FormControl>
<Input placeholder={"The secret key token"} {...field} />
<PasswordInput placeholder={"The secret key token"} {...field} />
</FormControl>
<FormDescription>{"Add your secret key"}</FormDescription>
<FormMessage />
@@ -1,20 +1,20 @@
"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 { AdminUsersTable } from "@/components/wrappers/dashboard/admin/admin-user-table";
import { User } from "@/db/schema/01_user";
import { Setting } from "@/db/schema/00_setting";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
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 {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";
export type AdminTabsProps = {
users: User[];
users: UserWithAccounts[];
settings: Setting;
};
export const AdminTabs = ({ users, settings }: AdminTabsProps) => {
export const AdminTabs = ({users, settings}: AdminTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -44,13 +44,13 @@ export const AdminTabs = ({ users, settings }: AdminTabsProps) => {
</TabsList>
<TabsContent value="users">
<AdminUsersTable users={users} />
<AdminUsersTable users={users}/>
</TabsContent>
<TabsContent value="email">
<SettingsEmailTab settings={settings} />
<SettingsEmailTab settings={settings}/>
</TabsContent>
<TabsContent value="storage">
<SettingsStorageTab settings={settings} />
<SettingsStorageTab settings={settings}/>
</TabsContent>
</Tabs>
);
@@ -0,0 +1,81 @@
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {useMutation} from "@tanstack/react-query";
import {ColumnDef} from "@tanstack/react-table";
import {Unlink} from "lucide-react";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {unlinkUserProviderAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
export const accountsColumns: ColumnDef<{
id: string;
provider: string;
createdAt: Date;
updatedAt: Date;
accountId: string;
scopes: string[];
}>[] = [
{
id: "provider",
header: "Provider",
cell: ({row}) => {
return (
<div>
{providerSwitch(row.original.provider)}
</div>
)
},
},
{
header: "Action",
id: "actions",
cell: ({row, table}) => {
const router = useRouter();
const mutation = useMutation({
mutationFn: async () => {
if (row.original.provider === "credential") {
toast.error(`This provider cannot be unlinked.`);
router.refresh();
return;
}
if (table.getRowModel().rows.length <= 1) {
toast.error(`You only have one provider linked to your account. Please add more one to unlink this`);
router.refresh();
return;
}
const status = await unlinkUserProviderAction({
provider: row.original.provider,
account: row.original.accountId,
});
if (status?.serverError || !status) {
toast.error(status?.serverError);
return;
}
toast.success(`Provider unlinked successfully.`);
router.refresh();
},
});
return (
<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 () => {
await mutation.mutateAsync();
}}
size="icon"
/>
</div>
);
},
},
];
@@ -0,0 +1,26 @@
import {User, 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";
export type AdminUsersTableProps = {
users: UserWithAccounts[];
};
export const AdminUsersTable = (props: AdminUsersTableProps) => {
const {users} = props;
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<Card>
<CardHeader>
<CardTitle>Active users</CardTitle>
<CardDescription>Manage your users</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={usersColumnsAdmin} data={users}/>
</CardContent>
</Card>
</div>
);
};
@@ -9,22 +9,20 @@ 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 {User} from "@/db/schema/01_user";
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";
export const usersColumnsAdmin: ColumnDef<User>[] = [
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
{
accessorKey: "role",
header: "Role",
cell: ({row}) => {
const [role, setRole] = useState<string>(row.getValue("role"));
const {data: session, isPending, error} = authClient.useSession();
const isSuperAdmin = session?.user.role == "superadmin";
const isCurrentUser = session?.user.email === row.original.email;
const updateMutation = useMutation({
@@ -51,11 +49,10 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
}
return (
<Badge
className={isCurrentUser ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
onClick={isCurrentUser ? undefined : () => handleUpdateRole()}
className={isCurrentUser || !isSuperAdmin ? "cursor-not-allowed opacity-50" : "cursor-pointer"}
onClick={isCurrentUser || !isSuperAdmin ? undefined : () => handleUpdateRole()}
variant="outline"
>
{role}
@@ -71,6 +68,21 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
accessorKey: "email",
header: "Email",
},
{
accessorKey: "accounts",
header: "Provider ID",
cell: ({row}) => {
return(
<div>
{row.original.accounts.map((item) => (
<div key={item.id}>
{providerSwitch(item.providerId)}
</div>
))}
</div>
)
}
},
{
accessorKey: "updatedAt",
header: "Updated At",
@@ -84,6 +96,7 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
cell: ({row}) => {
const router = useRouter();
const {data: session, isPending} = useSession();
const isSuperAdmin = session?.user.role == "superadmin";
const mutation = useMutation({
mutationFn: () => deleteUserAction(row.original.id),
@@ -95,16 +108,16 @@ export const usersColumnsAdmin: ColumnDef<User>[] = [
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
disabled={!session || session?.user.email === row.original.email}
variant="outline"
text=""
icon={<Trash2 color="red" size={15}/>}
onClick={async () => {
await mutation.mutateAsync();
}}
size="sm"
/>
<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>
);
},
@@ -0,0 +1,87 @@
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import { useMutation } from "@tanstack/react-query";
import { ColumnDef } from "@tanstack/react-table";
import { Session } from "better-auth";
import { Unlink } from "lucide-react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import detectOSWithUA from "@/utils/os-parser";
import { Icon } from "@iconify/react";
import { authClient } from "@/lib/auth/auth-client";
import { timeAgo } from "@/utils/date-formatting";
import {deleteUserSessionAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
export const sessionsColumns: ColumnDef<Session>[] = [
{
accessorKey: "expiresAt",
header: "Expires At",
cell: ({ row }) => {
return timeAgo(row.original.expiresAt);
},
},
{
accessorKey: "ipAddress",
header: "IP Address",
},
{
id: "device",
header: "Device",
cell: ({ row }) => {
const os = detectOSWithUA(row.original.userAgent!);
return (
<div className="flex flex-row gap-x-2 items-center pt-4 pb-4">
{os.icon && <Icon icon={`logos:${os.icon.name}`} height={os.icon.size.height} width={os.icon.size.width} />}
{os.showText && <span>{os.name}</span>}
</div>
);
},
},
{
accessorKey: "userAgent",
header: "User Agent",
},
{
header: "Action",
id: "actions",
cell: ({ row }) => {
const router = useRouter();
const { data: session } = authClient.useSession();
const mutation = useMutation({
mutationFn: async () => {
if (session?.session.id === row.original.id) {
toast.error(`Unable to unlink active session.`);
router.refresh();
return;
}
const status = await deleteUserSessionAction(row.original.token);
if (status?.serverError || !status) {
toast.error(status?.serverError);
return;
}
toast.success(`Session deleted successfully.`);
router.refresh();
},
});
return (
<div className="flex items-center gap-2">
<ButtonWithLoading
variant="outline"
disabled={session?.session.id === row.original.id}
text=""
icon={<Unlink color="red" size={15} />}
onClick={async () => {
await mutation.mutateAsync();
}}
size="icon"
/>
</div>
);
},
},
];
@@ -1,21 +0,0 @@
import { usersColumnsAdmin } from "@/components/wrappers/dashboard/admin/columns-users";
import { User } from "@/db/schema/01_user";
import { DataTable } from "../../common/table/data-table";
export type AdminUsersTableProps = {
users: User[];
};
export const AdminUsersTable = (props: AdminUsersTableProps) => {
const { users } = props;
return (
<div className="flex flex-col h-full py-4">
<div className="flex gap-4 h-fit justify-between">
<h1>List of Portabase's users</h1>
</div>
<div className="mt-5 h-full">
<DataTable columns={usersColumnsAdmin} data={users} />
</div>
</div>
);
};
@@ -4,7 +4,7 @@ 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/07_agent";
import {Agent} from "@/db/schema/08_agent";
export type AgentCardKeyProps = {
agent: Agent;
@@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import { formatDateLastContact } from "@/utils/date-formatting";
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
import {Agent} from "@/db/schema/07_agent";
import {Agent} from "@/db/schema/08_agent";
export type agentCardProps = {
data: Agent;
@@ -1,5 +1,5 @@
"use server";
import {ActionError, userAction} from "@/safe-actions";
import {ActionError, userAction} from "@/lib/safe-actions/actions";
import {AgentSchema} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
import {z} from "zod";
import {eq, and, ne, count} from "drizzle-orm";
@@ -8,7 +8,7 @@ import { PropsWithChildren } from "react";
import { CopyButton } from "@/components/wrappers/common/button/copy-button";
import { getServerUrl } from "@/utils/get-server-url";
import { CodeSnippet } from "@/components/wrappers/code-snippet/code-snippet";
import {Agent} from "@/db/schema/07_agent";
import {Agent} from "@/db/schema/08_agent";
export type agentRegistrationDialogProps = PropsWithChildren<{
agent: Agent;
@@ -1,10 +1,10 @@
"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 {Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-delete-agent/delete-agent.action";
export type ButtonDeleteAgentProps = {
@@ -29,14 +29,31 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
return (
<ButtonWithConfirm
text={props.text ? props.text : ""}
onClick={() => {
mutation.mutate();
title={props.text ? props.text : ""}
description="Are you sure you want to remove this agent? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate();
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
variant={"destructive"}
isPending={mutation.isPending}
className="gap-2"
icon={<Trash2 />}
/>
);
};
@@ -1,13 +1,13 @@
"use server";
import {userAction} from "@/safe-actions";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {Agent} from "@/db/schema/07_agent";
import {Agent} from "@/db/schema/08_agent";
import {userAction} from "@/lib/safe-actions/actions";
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
try {
@@ -1,13 +1,14 @@
"use server";
import { z } from "zod";
import { userAction } from "@/safe-actions";
import { db } from "@/db";
import { ServerActionResult } from "@/types/action-type";
import {z} from "zod";
import {db} from "@/db";
import {ServerActionResult} from "@/types/action-type";
import * as drizzleDb from "@/db";
import {Backup} from "@/db/schema/06_database";
import {Backup} from "@/db/schema/07_database";
import {withUpdatedAt} from "@/db/utils";
import {userAction} from "@/lib/safe-actions/actions";
export const backupButtonAction = userAction.schema(z.string()).action(async ({ parsedInput }): Promise<ServerActionResult<Backup>> => {
export const backupButtonAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
try {
const [createdBackup] = await db
.insert(drizzleDb.schemas.backup)
@@ -22,7 +23,7 @@ export const backupButtonAction = userAction.schema(z.string()).action(async ({
value: createdBackup,
actionSuccess: {
message: "Backup has been successfully created.",
messageParams: { databaseId: parsedInput },
messageParams: {databaseId: parsedInput},
},
};
} catch (error) {
@@ -34,7 +35,7 @@ export const backupButtonAction = userAction.schema(z.string()).action(async ({
message: "Failed to create backup.",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: { databaseId: parsedInput },
messageParams: {databaseId: parsedInput},
},
};
}
@@ -1,12 +1,14 @@
"use client";
import { useRouter } from "next/navigation";
import {useRouter} from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import { backupButtonAction } from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
import {backupButtonAction} from "@/components/wrappers/dashboard/backup/backup-button/backup-button.action";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {Database, DatabaseZap} from "lucide-react";
import {useIsMobile} from "@/hooks/use-mobile";
export type BackupButtonProps = {
databaseId: string;
@@ -15,6 +17,8 @@ export type BackupButtonProps = {
export const BackupButton = (props: BackupButtonProps) => {
const router = useRouter();
const isMobile = useIsMobile()
const mutation = useMutation({
mutationFn: async (databaseId: string) => {
const backup = await backupButtonAction(databaseId);
@@ -32,8 +36,9 @@ export const BackupButton = (props: BackupButtonProps) => {
return (
<ButtonWithLoading
icon={<DatabaseZap/>}
disabled={props.disable}
text="Backup"
text={isMobile ? "" : "Backup"}
isPending={mutation.isPending}
size={"default"}
onClick={async () => {
@@ -6,7 +6,7 @@ import { redirect } from "next/navigation";
import { CircleUser, LogOut, ShieldHalf } from "lucide-react";
import { signOut } from "@/lib/auth/auth-client";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/01_user";
import { User } from "@/db/schema/02_user";
export type LoggedInDropdownProps = PropsWithChildren<{
user: User;
@@ -22,9 +22,11 @@ export const SidebarMenuCustomMain = () => {
return null;
}
const groupContentApplication: SidebarGroupItem["group_content"] = [
{ title: "Dashboard", url: "/home", icon: Home },
];
const groupContent: SidebarGroupItem["group_content"] = [
{ title: "Dashboard", url: "/home", icon: Home },
{ title: "Projects", url: "/projects", icon: Layers, details:true },
{ title: "Statistics", url: "/statistics", icon: ChartArea },
];
@@ -37,6 +39,11 @@ export const SidebarMenuCustomMain = () => {
{
label: "Application",
type: "list",
group_content: groupContentApplication,
},
{
label: "Organization",
type: "list",
group_content: groupContent,
},
];
@@ -11,7 +11,7 @@ import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {Database} from "@/db/schema/06_database";
import {Database} from "@/db/schema/07_database";
export type CronButtonProps = {
database: Database;
@@ -20,9 +20,11 @@ export type CronButtonProps = {
export const CronButton = (props: CronButtonProps) => {
const router = useRouter();
const [isSwitched, setIsSwitched] = useState(props.database.backupPolicy !== null);
const [open, setOpen] = useState(false);
const updateDatabaseBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
mutationFn: (value: string) =>
updateDatabaseBackupPolicyAction({ databaseId: props.database.id, backupPolicy: value }),
onSuccess: () => {
toast.success(`Method updated successfully.`);
router.refresh();
@@ -34,15 +36,15 @@ export const CronButton = (props: CronButtonProps) => {
const handleTypeChange = async (state: boolean) => {
setIsSwitched(state);
if (state == false) {
if (!state) {
await updateDatabaseBackupPolicy.mutateAsync("");
}
};
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" {...props}>
<Button variant="outline" {...props} onClick={() => setOpen(true)}>
<Clock9 />
</Button>
</DialogTrigger>
@@ -64,7 +66,14 @@ export const CronButton = (props: CronButtonProps) => {
id="type-mode"
/>
</div>
{isSwitched ? <CronInput database={props.database} /> : null}
{isSwitched ? (
<CronInput
database={props.database}
onSuccess={() => {
setOpen(false);
}}
/>
) : null}
</DialogContent>
</Dialog>
);
@@ -1,25 +1,27 @@
import { AdvancedCronSelect } from "./advanced-cron-select";
import { updateDatabaseBackupPolicyAction } from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import {Database} from "@/db/schema/06_database";
import {AdvancedCronSelect} from "./advanced-cron-select";
import {updateDatabaseBackupPolicyAction} from "@/components/wrappers/dashboard/database/cron-button/cron.action";
import {useMutation} from "@tanstack/react-query";
import {useState} from "react";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {Separator} from "@/components/ui/separator";
import {Database} from "@/db/schema/07_database";
export type CronInputProps = {
database: Database;
onSuccess?: () => void;
};
export const CronInput = ({ database }: CronInputProps) => {
export const CronInput = ({database, onSuccess}: CronInputProps) => {
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
const router = useRouter();
const updateBackupPolicy = useMutation({
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({ databaseId: database.id, backupPolicy: value }),
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
onSuccess: () => {
toast.success(`Cron updated successfully.`);
onSuccess?.()
router.refresh();
},
onError: () => {
@@ -29,7 +31,7 @@ export const CronInput = ({ database }: CronInputProps) => {
const handleChangeCron = (type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week", value: string) => {
const cronParts = cron.split(" ");
const indexMap = { minute: 0, hour: 1, "day-of-month": 2, month: 3, "day-of-week": 4 };
const indexMap = {minute: 0, hour: 1, "day-of-month": 2, month: 3, "day-of-week": 4};
cronParts[indexMap[type]] = value;
setCron(cronParts.join(" "));
};
@@ -44,7 +46,7 @@ export const CronInput = ({ database }: CronInputProps) => {
<AdvancedCronSelect
id="minute"
label="Minute"
options={Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0"))}
options={Array.from({length: 60}, (_, i) => String(i).padStart(2, "0"))}
type="minute"
value={cron.split(" ")[0]}
defaultValue={cron.split(" ")[0]}
@@ -53,7 +55,7 @@ export const CronInput = ({ database }: CronInputProps) => {
<AdvancedCronSelect
id="hour"
label="Hour"
options={Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0"))}
options={Array.from({length: 24}, (_, i) => String(i).padStart(2, "0"))}
type="hour"
value={cron.split(" ")[1]}
defaultValue={cron.split(" ")[1]}
@@ -62,7 +64,7 @@ export const CronInput = ({ database }: CronInputProps) => {
<AdvancedCronSelect
id="day-of-month"
label="Day of Month"
options={Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0"))}
options={Array.from({length: 31}, (_, i) => String(i + 1).padStart(2, "0"))}
type="day-of-month"
value={cron.split(" ")[2]}
defaultValue={cron.split(" ")[2]}
@@ -86,13 +88,14 @@ export const CronInput = ({ database }: CronInputProps) => {
defaultValue={cron.split(" ")[4]}
onValueChange={(value) => handleChangeCron("day-of-week", value)}
/>
<Separator />
<Separator/>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<div className="font-semibold">Cron Expression</div>
<div className="font-mono text-muted-foreground">{cron}</div>
</div>
<div className="text-sm text-muted-foreground">This cron expression determines when the job will run.</div>
<div className="text-sm text-muted-foreground">This cron expression determines when the job will run.
</div>
</div>
<div className="flex justify-between gap-2">
<Button
@@ -1,10 +1,10 @@
"use server";
import { userAction } from "@/safe-actions";
import { z } from "zod";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import {z} from "zod";
import {db} from "@/db";
import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
export const updateDatabaseBackupPolicyAction = userAction
.schema(
@@ -13,7 +13,7 @@ export const updateDatabaseBackupPolicyAction = userAction
backupPolicy: z.string(),
})
)
.action(async ({ parsedInput }) => {
.action(async ({parsedInput}) => {
const cronPolicy = parsedInput.backupPolicy === "" ? null : parsedInput.backupPolicy;
const [updated] = await db
@@ -25,6 +25,11 @@ export const updateDatabaseBackupPolicyAction = userAction
.returning()
.execute();
if (cronPolicy == null) {
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, parsedInput.databaseId)).execute();
}
return {
data: updated,
};
@@ -58,7 +58,6 @@ export const DatabaseForm = (props: DatabaseFormProps) => {
form={form}
className="flex flex-col gap-4 mt-3"
onSubmit={async (values) => {
console.log("sssssss");
await mutation.mutateAsync(values);
}}
>
@@ -1,11 +1,12 @@
"use server";
import { z } from "zod";
import { userAction } from "@/safe-actions";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { DatabaseSchema } from "@/components/wrappers/dashboard/database/database-form/form-database.schema";
import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions";
import {Database} from "@/db/schema/07_database";
export const updateDatabaseAction = userAction
.schema(
@@ -0,0 +1,304 @@
"use client"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
useZodForm
} from "@/components/ui/form";
import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
import {useMutation} from "@tanstack/react-query";
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action";
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group";
import {Badge} from "@/components/ui/badge";
import {Separator} from "@/components/ui/separator";
import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
import {Calendar, Save} from "lucide-react";
export type BackupRetentionSettingsFormProps = {
defaultValues?: RetentionPolicy;
database: DatabaseWith;
};
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
const router = useRouter();
const defaultValuesFormatted: RetentionSettings = {
type: defaultValues?.type,
count: defaultValues?.count ?? 7,
days: defaultValues?.days ?? 30,
gfs: {
daily: defaultValues?.gfsDaily ?? 7,
weekly: defaultValues?.gfsWeekly ?? 4,
monthly: defaultValues?.gfsMonthly ?? 12,
yearly: defaultValues?.gfsYearly ?? 3,
},
};
const form = useZodForm({
schema: RetentionSettingsSchema,
defaultValues: defaultValuesFormatted,
});
const mutation = useMutation({
mutationFn: async (payload: RetentionSettings) =>
await updateOrCreateBackupRetentionPolicyAction({
databaseId: database.id,
settings: payload,
}),
onSuccess: () => {
toast.success("Retention policy updated successfully.");
router.refresh();
},
onError: () => {
toast.error("An error occurred while updating retention policy.");
},
});
const calculateTotalFiles = (values: RetentionSettings) => {
if (values.type === "gfs" && values.gfs) {
return (
(values.gfs.daily ?? 0) +
(values.gfs.weekly ?? 0) +
(values.gfs.monthly ?? 0) +
(values.gfs.yearly ?? 0)
);
}
return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0;
};
const getStorageEstimate = (totalFiles: number) => {
if (totalFiles <= 10) return "Low";
if (totalFiles <= 30) return "Medium";
return "High";
};
return (
<div className="flex flex-col gap-3 py-0">
<div className="px-3">
<Form
form={form}
className="flex flex-col gap-6 mt-0"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="type"
render={({field}) => (
<FormItem>
<FormLabel>Retention Policy Type</FormLabel>
<FormControl>
<RadioGroup
value={field.value ?? ""}
onValueChange={field.onChange}
className="grid grid-cols-1 gap-4"
>
{[
{
id: "count",
label: "Keep last N backups",
desc: "Simple count-based retention (e.g., keep last 10 backups)",
},
{
id: "days",
label: "Keep backups for X days",
desc: "Time-based retention (e.g., keep backups for 30 days)",
},
{
id: "gfs",
label: "GFS Rotation",
desc: "Grandfather-Father-Son rotation for enterprise/critical systems",
badge: "Recommended",
},
].map((opt) => (
<FormLabel
key={opt.id}
htmlFor={opt.id}
className={`flex items-center space-x-3 rounded-lg border p-4 transition-colors cursor-pointer ${
field.value === opt.id
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<RadioGroupItem value={opt.id} id={opt.id}/>
<div className="flex-1">
<span className="font-medium flex items-center gap-2">
{opt.label}
{opt.badge && (
<Badge variant="secondary" className="text-xs">
{opt.badge}
</Badge>
)}
</span>
<p className="text-sm text-muted-foreground">{opt.desc}</p>
</div>
{database.retentionPolicy?.type === opt.id && (
<Badge variant="secondary" className="text-xs">
Actual
</Badge>
)}
</FormLabel>
))}
</RadioGroup>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
{form.watch("type") && (
<Separator/>
)}
{form.watch("type") === "count" && (
<FormField
control={form.control}
name="count"
render={({field}) => (
<FormItem>
<FormLabel>Number of backups to keep</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={100}
className="w-32"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormDescription>
Older backups beyond this count will be automatically deleted.
</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
)}
{form.watch("type") === "days" && (
<FormField
control={form.control}
name="days"
render={({field}) => (
<FormItem>
<FormLabel>Retention period (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={3650}
className="w-32"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormDescription>
Backups older than {field.value} days will be automatically deleted.
</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
)}
{form.watch("type") === "gfs" && (
<div className="space-y-4">
{["daily", "weekly", "monthly", "yearly"].map((key) => (
<FormField
key={key}
control={form.control}
name={`gfs.${key}` as const}
render={({field}) => (
<FormItem>
<FormLabel>
{key.charAt(0).toUpperCase() + key.slice(1)} backups
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={key === "yearly" ? 50 : key === "monthly" ? 120 : key === "weekly" ? 52 : 31}
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormDescription>
Keep N {key} backups
</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
))}
</div>
)}
{form.watch("type") && (
<>
<Separator/>
<div className="rounded-lg border p-4 space-y-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground"/>
<span className="font-medium">Storage Impact Summary</span>
</div>
{(() => {
const totalFiles = calculateTotalFiles(form.getValues());
const estimate = getStorageEstimate(totalFiles);
return (
<Badge
variant={
estimate === "Low"
? "default"
: estimate === "Medium"
? "secondary"
: "destructive"
}
>
{estimate} Usage
</Badge>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Estimated files per database:</span>
<p className="font-medium">
{calculateTotalFiles(form.getValues())} backup files
</p>
</div>
<div>
<span className="text-muted-foreground">Policy type:</span>
<p className="font-medium capitalize">
{form.watch("type") === "gfs"
? "GFS Rotation"
: form.watch("type") === "count"
? "Count-based"
: "Time-based"}
</p>
</div>
</div>
</div>
<Button type="submit" disabled={mutation.isPending} className="w-full">
<Save className="h-4 w-4 mr-2"/>
{mutation.isPending ? "Saving Policy..." : "Save Retention Policy"}
</Button>
</>
)}
</Form>
</div>
</div>
);
};
@@ -0,0 +1,67 @@
"use server"
import {z} from "zod"
import {db} from "@/db"
import {eq} from "drizzle-orm"
import * as drizzleDb from "@/db";
import {
RetentionSettingsSchema
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema";
import {userAction} from "@/lib/safe-actions/actions";
export const updateOrCreateBackupRetentionPolicyAction = userAction
.schema(
z.object({
databaseId: z.string(),
settings: RetentionSettingsSchema,
})
)
.action(async ({parsedInput}) => {
const {databaseId, settings} = parsedInput
// Check if a retention policy already exists
const existing = await db
.select()
.from(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.limit(1)
let updated
if (existing.length > 0) {
// Update existing policy
updated = await db
.update(drizzleDb.schemas.retentionPolicy)
.set({
type: settings.type,
count: settings.count,
days: settings.days,
gfsDaily: settings.gfs.daily,
gfsWeekly: settings.gfs.weekly,
gfsMonthly: settings.gfs.monthly,
gfsYearly: settings.gfs.yearly,
})
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.returning()
} else {
// Insert new policy
updated = await db
.insert(drizzleDb.schemas.retentionPolicy)
.values({
databaseId,
type: settings.type ?? "gfs",
count: settings.count,
days: settings.days,
gfsDaily: settings.gfs.daily,
gfsWeekly: settings.gfs.weekly,
gfsMonthly: settings.gfs.monthly,
gfsYearly: settings.gfs.yearly,
})
.returning()
}
return {
data: updated[0],
}
})
@@ -0,0 +1,17 @@
import {z} from "zod";
const GFSSettingsSchema = z.object({
daily: z.number().min(1).max(31),
weekly: z.number().min(0).max(52),
monthly: z.number().min(0).max(120),
yearly: z.number().min(0).max(50),
});
export const RetentionSettingsSchema = z.object({
type: z.enum(["count", "days", "gfs"]).optional(),
count: z.number().min(1).max(100),
days: z.number().min(1).max(3650),
gfs: GFSSettingsSchema,
});
export type RetentionSettings = z.infer<typeof RetentionSettingsSchema>;
@@ -0,0 +1,342 @@
"use client"
import {useState} from "react"
import {Label} from "@/components/ui/label"
import {Button} from "@/components/ui/button"
import {Badge} from "@/components/ui/badge"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"
import {Clock, Database, Save, Settings, Calendar, RotateCcw} from "lucide-react"
import {toast} from "sonner";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {
updateOrCreateBackupRetentionPolicyAction
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action";
import {Database as DbSchema} from "@/db/schema/07_database";
type RetentionPolicyType = "count" | "days" | "gfs"
interface GFSSettings {
daily: number
weekly: number
monthly: number
yearly: number
}
export interface RetentionSettings {
type: RetentionPolicyType
count: number
days: number
gfs: GFSSettings
}
type BackupRetentionSettingsProps = {
database: DbSchema,
}
export function BackupRetentionSettings({database}: BackupRetentionSettingsProps) {
const [settings, setSettings] = useState<RetentionSettings>({
type: "gfs",
count: 7,
days: 30,
gfs: {
daily: 7,
weekly: 4,
monthly: 12,
yearly: 3,
},
})
const router = useRouter()
const updateRetentionPolicy = useMutation({
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
databaseId: database.id,
settings: payload
}),
onSuccess: () => {
toast.success("Retention policy updated successfully.")
router.refresh()
},
onError: () => {
toast.error("An error occurred while updating retention policy.")
},
})
const handleSave = () => {
updateRetentionPolicy.mutate(settings)
}
const calculateTotalFiles = () => {
if (settings.type === "gfs") {
return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly
}
return settings.type === "count" ? settings.count : Math.ceil(settings.days / 1) // Assuming daily backups
}
const getStorageEstimate = () => {
const totalFiles = calculateTotalFiles()
if (totalFiles <= 10) return "Low"
if (totalFiles <= 30) return "Medium"
return "High"
}
return (
<div className="space-y-6">
<div className="flex flex-col">
<div className="px-3 space-y-6">
<div className="space-y-4">
<Label className="text-sm font-medium">Retention Policy Type</Label>
<RadioGroup
value={settings.type}
onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({
...prev,
type: value
}))}
className="grid grid-cols-1 gap-4"
>
<div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="count" id="count"/>
<div className="flex-1">
<Label htmlFor="count" className="font-medium cursor-pointer">
Keep last N backups
</Label>
<p className="text-sm text-muted-foreground">
Simple count-based retention (e.g., keep last 10 backups)
</p>
</div>
</div>
<div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="days" id="days"/>
<div className="flex-1">
<Label htmlFor="days" className="font-medium cursor-pointer">
Keep backups for X days
</Label>
<p className="text-sm text-muted-foreground">Time-based retention (e.g., keep
backups for 30 days)</p>
</div>
</div>
<div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors bg-accent/5">
<RadioGroupItem value="gfs" id="gfs"/>
<div className="flex-1">
<Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2">
GFS Rotation
<Badge variant="secondary" className="text-xs">
Recommended
</Badge>
</Label>
<p className="text-sm text-muted-foreground">
Grandfather-Father-Son rotation for enterprise/critical systems
</p>
</div>
</div>
</RadioGroup>
</div>
<Separator/>
{settings.type === "count" && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground"/>
<Label className="font-medium">Count-Based Configuration</Label>
</div>
<div className="space-y-2">
<Label htmlFor="backup-count">Number of backups to keep</Label>
<Input
id="backup-count"
type="number"
min="1"
max="100"
value={settings.count}
onChange={(e) => setSettings((prev) => ({
...prev,
count: Number.parseInt(e.target.value) || 1
}))}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Older backups beyond this count will be automatically deleted
</p>
</div>
</div>
)}
{settings.type === "days" && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground"/>
<Label className="font-medium">Time-Based Configuration</Label>
</div>
<div className="space-y-2">
<Label htmlFor="retention-days">Retention period (days)</Label>
<Input
id="retention-days"
type="number"
min="1"
max="3650"
value={settings.days}
onChange={(e) => setSettings((prev) => ({
...prev,
days: Number.parseInt(e.target.value) || 1
}))}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Backups older than {settings.days} days will be automatically deleted
</p>
</div>
</div>
)}
{settings.type === "gfs" && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<RotateCcw className="h-4 w-4 text-muted-foreground"/>
<Label className="font-medium">GFS Rotation Configuration</Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="daily-backups">Daily backups</Label>
<Input
id="daily-backups"
type="number"
min="1"
max="31"
value={settings.gfs.daily}
onChange={(e) =>
setSettings((prev) => ({
...prev,
gfs: {...prev.gfs, daily: Number.parseInt(e.target.value) || 1},
}))
}
/>
<p className="text-xs text-muted-foreground">Keep last N daily backups</p>
</div>
<div className="space-y-2">
<Label htmlFor="weekly-backups">Weekly backups</Label>
<Input
id="weekly-backups"
type="number"
min="0"
max="52"
value={settings.gfs.weekly}
onChange={(e) =>
setSettings((prev) => ({
...prev,
gfs: {...prev.gfs, weekly: Number.parseInt(e.target.value) || 0},
}))
}
/>
<p className="text-xs text-muted-foreground">Keep N weekly backups</p>
</div>
<div className="space-y-2">
<Label htmlFor="monthly-backups">Monthly backups</Label>
<Input
id="monthly-backups"
type="number"
min="0"
max="120"
value={settings.gfs.monthly}
onChange={(e) =>
setSettings((prev) => ({
...prev,
gfs: {...prev.gfs, monthly: Number.parseInt(e.target.value) || 0},
}))
}
/>
<p className="text-xs text-muted-foreground">Keep N monthly backups</p>
</div>
<div className="space-y-2">
<Label htmlFor="yearly-backups">Yearly backups</Label>
<Input
id="yearly-backups"
type="number"
min="0"
max="50"
value={settings.gfs.yearly}
onChange={(e) =>
setSettings((prev) => ({
...prev,
gfs: {...prev.gfs, yearly: Number.parseInt(e.target.value) || 0},
}))
}
/>
<p className="text-xs text-muted-foreground">Keep N yearly backups</p>
</div>
</div>
<div className="bg-muted/50 p-3 rounded-lg">
<p className="text-sm font-medium mb-1">GFS Strategy Benefits:</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li> Balanced storage usage with long-term retention</li>
<li> Automatic promotion of backups (daily weekly monthly yearly)</li>
<li> Industry standard for enterprise backup systems</li>
</ul>
</div>
</div>
)}
<Separator/>
<div className="rounded-lg border p-4 space-y-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground"/>
<span className="font-medium">Storage Impact Summary</span>
</div>
<Badge
variant={
getStorageEstimate() === "Low"
? "default"
: getStorageEstimate() === "Medium"
? "secondary"
: "destructive"
}
>
{getStorageEstimate()} Usage
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Estimated files per database:</span>
<p className="font-medium">{calculateTotalFiles()} backup files</p>
</div>
<div>
<span className="text-muted-foreground">Policy type:</span>
<p className="font-medium capitalize">
{settings.type === "gfs" ? "GFS Rotation" : settings.type === "count" ? "Count-based" : "Time-based"}
</p>
</div>
</div>
{settings.type === "gfs" && (
<div
className="text-xs text-muted-foreground bg-accent/10 p-2 rounded border border-accent/20">
<strong>Note:</strong> With GFS rotation, you'll have
approximately {calculateTotalFiles()} files per
database per year, providing excellent long-term retention with optimized storage usage.
</div>
)}
</div>
<Button onClick={handleSave} disabled={updateRetentionPolicy.isPending} className="w-full">
<Save className="h-4 w-4 mr-2"/>
{updateRetentionPolicy.isPending ? "Saving Policy..." : "Save Retention Policy"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,49 @@
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet";
import {Button} from "@/components/ui/button";
import {Database, Ruler} from "lucide-react";
import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database";
import {
BackupRetentionSettingsForm
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings-form";
type RetentionPolicySheetProps = {
database: DbSchema
}
export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">
<Ruler />
</Button>
</SheetTrigger>
<SheetContent
className="flex gap-4 p-4 w-full md:w-[800px] max-w-[800px] max-h-screen overflow-y-scroll"
>
<SheetHeader>
<SheetTitle className="flex items-center gap-2 text-balance">
<Database className="h-5 w-5"/>
Backup Retention Policy
</SheetTitle>
<SheetDescription className="text-pretty">
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based,
or
enterprise GFS rotation strategies.
</SheetDescription>
</SheetHeader>
{database.backupPolicy !== null ?
<BackupRetentionSettingsForm database={database}
defaultValues={database.retentionPolicy as RetentionPolicy}/>
:
<div
className="flex flex-col items-center justify-center text-center py-12 gap-4 border rounded-lg">
<p className="text-muted-foreground">
No backup policy configured yet. Please configure one !
</p>
</div>
}
</SheetContent>
</Sheet>
)
}
@@ -1,11 +1,12 @@
"use client";
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 {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";
import {Trash2} from "lucide-react";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
@@ -13,6 +14,8 @@ export type DeleteOrganizationButtonProps = {
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
const router = useRouter();
const {data: organizations, refetch} = authClient.useListOrganizations();
const mutation = useMutation({
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
@@ -22,8 +25,9 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
organizationSlug: "default",
});
router.push("/");
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
router.refresh()
refetch()
} else {
// @ts-ignore
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
@@ -38,13 +42,35 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
});
return (
<ButtonWithConfirm
onClick={() => {
mutation.mutate();
title="Delete Organization"
description="Are you sure you want to remove this organization? This action cannot be undone."
button={{
main: {
text: "Delete Organization",
variant: "outline",
icon: <Trash2 color="red"/>,
},
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}
text="Delete Organization"
variant="destructive"
/>
);
};
@@ -4,7 +4,6 @@ import {ComboBox} from "@/components/wrappers/common/combobox";
import {useSidebar} from "@/components/ui/sidebar";
import {useRouter} from "next/navigation";
import {authClient} from "@/lib/auth/auth-client";
import {auth} from "@/lib/auth/auth";
export function OrganizationCombobox() {
@@ -16,9 +15,6 @@ export function OrganizationCombobox() {
if (!organizations) return null;
console.log("organizations", organizations);
console.log("activeOrganization", activeOrganization);
const values = organizations.map((organization) => {
return {
value: organization.slug,
@@ -26,8 +22,6 @@ export function OrganizationCombobox() {
};
});
const onValueChange = async (slug: string) => {
await authClient.organization.setActive({
@@ -20,14 +20,14 @@ import {
OrganizationFormSchema,
OrganizationFormType
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
import {
deleteOrganizationAction,
updateOrganizationAction
} from "@/components/wrappers/dashboard/organization/organization.action";
import {toast} from "sonner";
import {User as BetterAuthUser} from "better-auth";
import {User} from "@/db/schema/01_user";
import {User} from "@/db/schema/02_user";
export type organizationFormProps = {
defaultValues?: OrganizationWithMembers;
@@ -1,6 +1,6 @@
"use server";
import {userAction} from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import {OrganizationSchema} from "@/components/wrappers/dashboard/organization/organization.schema";
import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
@@ -11,7 +11,7 @@ import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
import {slugify} from "@/utils/slugify";
import {Organization} from "@/db/schema/02_organization";
import {Organization} from "@/db/schema/03_organization";
import * as drizzleDb from "@/db";
import {headers} from "next/headers";
@@ -77,7 +77,6 @@ export const updateOrganizationAction = userAction
.action(async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
try {
const newUserList = parsedInput.data.users;
console.log(parsedInput);
const organization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, parsedInput.organizationId),
with: {
@@ -222,7 +221,13 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
let deletedOrganization: Organization;
try {
deletedOrganization = await deleteOrganization(org.id) as Organization;
// 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))
.returning();
} catch (authError: any) {
console.error("Auth deletion failed:", authError);
return {
@@ -6,7 +6,8 @@ import { uploadImageAction } from "@/features/upload/public/upload.action";
import { useMutation } from "@tanstack/react-query";
import { updateImageUserAction } from "@/components/wrappers/dashboard/profile/avatar/avatar.action";
import { useRouter } from "next/navigation";
import { User } from "@/db/schema/01_user";
import { User } from "@/db/schema/02_user";
import {ChangeEvent} from "react";
export type AvatarWithUploadProps = {
user: User;
@@ -43,7 +44,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
},
});
const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.includes("image")) {
@@ -53,6 +54,8 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
submitImage.mutate(file);
};
return (
<div className="relative ">
<Avatar className="size-14 mr-3 ">
@@ -1,6 +1,6 @@
"use server";
import { db } from "@/db";
import { userAction } from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import { eq } from "drizzle-orm";
import { z } from "zod";
import * as drizzleDb from "@/db";
@@ -1,11 +1,11 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
import { signOut } from "@/lib/auth/auth-client";
import { useRouter } from "next/navigation";
import { deleteUserAction } from "./delete-account.action";
import {useMutation} from "@tanstack/react-query";
import {Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {signOut} from "@/lib/auth/auth-client";
import {useRouter} from "next/navigation";
import {deleteUserAction} from "./delete-account.action";
export type ButtonDeleteAccountProps = {
text?: string;
@@ -29,14 +29,33 @@ export const ButtonDeleteAccount = (props: ButtonDeleteAccountProps) => {
return (
<ButtonWithConfirm
text={props.text ? props.text : ""}
onClick={() => {
mutation.mutate();
title={props.text ? props.text : ""}
description="Are you sure you want to delete your account ? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate();
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
variant={"destructive"}
isPending={mutation.isPending}
className="gap-2"
icon={<Trash2 />}
/>
);
};
@@ -1,28 +1,36 @@
"use server";
import { userAction } from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {authClient} from "@/lib/auth/auth-client";
import {auth} from "@/lib/auth/auth";
export const deleteUserAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }) => {
const userId = parsedInput.length > 0 ? parsedInput : ctx.user.id;
const uuid = uuidv4();
const [updatedUser] = await db
.update(drizzleDb.schemas.user)
.set({
email: `${uuid}@portabase.com`,
name: `${uuid}`,
//deleted: true,
//todo: add deleted
})
// const [updatedUser] = await db
// .update(drizzleDb.schemas.user)
// .set({
// email: `${uuid}@portabase.com`,
// name: `${uuid}`,
// //deleted: true,
// //todo: add deleted
// })
// .where(eq(drizzleDb.schemas.user.id, userId))
// .returning();
const [deletedUser] = await db
.delete(drizzleDb.schemas.user)
.where(eq(drizzleDb.schemas.user.id, userId))
.returning();
return {
data: updatedUser,
data: deletedUser,
};
});
@@ -1,10 +1,11 @@
"use server";
import { userAction } from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import { z } from "zod";
import { UserSchema } from "@/components/wrappers/dashboard/profile/user-form/user-form.schema";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import {revokeSession, unlinkAccount} from "@/lib/auth/auth";
export const updateUserAction = userAction
.schema(
@@ -19,3 +20,23 @@ export const updateUserAction = userAction
data: updatedUser,
};
});
export const deleteUserSessionAction = userAction.schema(z.string()).action(async ({ parsedInput }) => {
const status = await revokeSession(parsedInput);
return status;
});
export const unlinkUserProviderAction = userAction
.schema(
z.object({
provider: z.string(),
account: z.string(),
})
)
.action(async ({ parsedInput }) => {
const status = await unlinkAccount(parsedInput.provider, parsedInput.account);
return status;
});
@@ -11,13 +11,26 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/user-form/user-form.schema";
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 {Session} from "better-auth";
export type userFormProps = {
export type UserFormProps = {
defaultValues?: UserType;
userId?: string;
sessions: Session[];
accounts: {
id: string;
provider: string;
createdAt: Date;
updatedAt: Date;
accountId: string;
scopes: string[];
}[];
};
export const UserForm = (props: userFormProps) => {
export const UserForm = (props: UserFormProps) => {
const isCreate = !Boolean(props.defaultValues);
const form = useZodForm({
@@ -29,8 +42,6 @@ export const UserForm = (props: userFormProps) => {
const mutation = useMutation({
mutationFn: async (values: UserType) => {
console.log("values", values);
console.log(props.userId);
const updateUser = await updateUserAction({
id: props.userId ?? "-",
data: values,
@@ -49,6 +60,7 @@ export const UserForm = (props: userFormProps) => {
});
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<TooltipProvider>
<Card>
<CardHeader>
@@ -93,6 +105,25 @@ export const UserForm = (props: userFormProps) => {
</Form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Active sessions</CardTitle>
<CardDescription>Manage your active sessions</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={sessionsColumns} data={props.sessions} enableSelect={false}/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Auth providers</CardTitle>
<CardDescription>Manage your active auth providers</CardDescription>
</CardHeader>
<CardContent>
<DataTable columns={accountsColumns} data={props.accounts} enableSelect={false}/>
</CardContent>
</Card>
</TooltipProvider>
</div>
);
};
@@ -1,11 +1,13 @@
"use client";
import { Trash2 } from "lucide-react";
import { ButtonWithConfirm } from "@/components/wrappers/common/button/button-with-confirm";
import { useMutation } from "@tanstack/react-query";
import { deleteProjectAction } from "@/components/wrappers/dashboard/projects/button-delete-project/delete-project.action";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {Trash2} from "lucide-react";
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
import {useMutation} from "@tanstack/react-query";
import {
deleteProjectAction
} from "@/components/wrappers/dashboard/projects/button-delete-project/delete-project.action";
import {useRouter} from "next/navigation";
import {toast} from "sonner";
export type ButtonDeleteProjectProps = {
text?: string;
@@ -27,15 +29,33 @@ export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
});
return (
<ButtonWithConfirm
text={props.text ? props.text : ""}
onClick={() => {
mutation.mutate();
title={props.text ? props.text : ""}
description="Are you sure you want to delete this project ? This action cannot be undone."
button={{
main: {
text: props.text ? props.text : "",
variant: "outline",
icon: <Trash2 color="red"/>,
},
confirm: {
className: "w-full",
text: "Delete",
icon: <Trash2/>,
variant: "destructive",
onClick: () => {
mutation.mutate();
},
},
cancel: {
className: "w-full",
text: "Cancel",
icon: <Trash2/>,
variant: "outline",
},
}}
variant={"destructive"}
isPending={mutation.isPending}
className="gap-2"
icon={<Trash2 />}
/>
);
};
@@ -1,16 +1,22 @@
"use server";
import {userAction} from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type";
import {eq} from "drizzle-orm";
import {and, eq} from "drizzle-orm";
import {db} from "@/db";
import * as drizzleDb from "@/db";
export const deleteProjectAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<typeof drizzleDb.schemas.project.$inferSelect>> => {
try {
const uuid = uuidv4();
await db
.update(drizzleDb.schemas.database)
.set({
projectId: null,
})
.where(eq(drizzleDb.schemas.database.projectId, parsedInput));
const updatedProjects = await db
.update(drizzleDb.schemas.project)
@@ -27,6 +33,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
throw new Error("Project not found or update failed");
}
return {
success: true,
value: updatedProject,
@@ -36,6 +43,7 @@ export const deleteProjectAction = userAction.schema(z.string()).action(async ({
},
};
} catch (error) {
console.log(error);
return {
success: false,
actionError: {
@@ -0,0 +1,147 @@
"use client"
import {backupColumns} from "@/features/dashboard/backup/columns";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MoreHorizontal, Trash2} from "lucide-react";
import {FilterItem, FiltersDropdown} from "@/components/wrappers/common/table/filters";
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {useMemo, useState} from "react";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {useMutation} from "@tanstack/react-query";
import {deleteBackupAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
type DatabaseBackupListProps = {
isAlreadyRestore: boolean;
settings: Setting;
database: DatabaseWith;
backups: Backup[];
}
export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
const items = [
{label: "Deleted", value: "deleted"},
{label: "Available", value: "available"},
]
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
const router = useRouter();
const filteredBackups = useMemo(() => {
if (!props.backups) return [];
return props.backups.filter(backup => {
// --- Status Filter ---
if (selectedFilters.length > 0) {
const selectedValues = selectedFilters.map(f => f.value);
const status = backup.deletedAt != null ? "deleted" : "available";
if (!selectedValues.includes(status)) return false;
}
return true;
});
}, [props.backups, selectedFilters]);
const handleSelectFilter = (item: FilterItem) => {
setSelectedFilters(prev =>
prev.some(f => f.value === item.value)
? prev.filter(f => f.value !== item.value)
: [...prev, item]
);
};
const clearFilters = () => setSelectedFilters([]);
const mutationDeleteBackups = useMutation({
mutationFn: async (backups: Backup[]) => {
const results = await Promise.all(
backups.map(async (backup) => {
if (backup.deletedAt == null) {
const backupDeleted = await deleteBackupAction({
backupId: backup.id,
databaseId: backup.databaseId,
status: backup.status,
file: backup.file!,
projectSlug: props.database?.project?.slug!
});
return {
success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success
? backupDeleted?.data?.actionSuccess?.message
// @ts-ignore
: restoration?.data?.actionError.message,
};
}
return {
success: true,
message: `Already deleted this backup (ref: ${backup.id}).`,
}
})
);
results.forEach((result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
});
router.refresh();
},
});
return (
<DataTable
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database)}
data={filteredBackups}
enablePagination
selectedActions={(rows) => (
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
<div className="flex gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
text="Actions"
onClick={() => {
}}
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
icon={<MoreHorizontal/>}
isPending={mutationDeleteBackups.isPending}
size="sm"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
console.log("Deleting rows:", rows)
await mutationDeleteBackups.mutateAsync(rows)
}}
className="text-red-600 focus:text-red-700"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<FiltersDropdown
items={items}
selectedItems={selectedFilters}
onSelect={handleSelectFilter}
clearFilters={clearFilters}
/>
</div>
</div>
)}
/>
)
}
@@ -1,29 +1,63 @@
"use client";
import {Card, CardContent, CardHeader} from "@/components/ui/card";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/06_database";
import {Database} from "@/db/schema/07_database";
import {DatabaseBackup, Server, CheckCircle, Clock} from "lucide-react";
export type DatabaseKpiPro = {
successRate: any;
database: Database;
totalBackups: number;
availableBackups: number;
};
export const DatabaseKpi = (props: DatabaseKpiPro) => {
return (
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">Backups</CardHeader>
<CardContent>{props.totalBackups}</CardContent>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Available Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{props.availableBackups}</div>
<p className="text-xs text-muted-foreground">Backups currently available</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">Success rate</CardHeader>
<CardContent>{props.successRate ? `${props.successRate} %` : "Unavailable for now."}</CardContent>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Backups</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{props.totalBackups}</div>
<p className="text-xs text-muted-foreground">Total backups recorded</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">Last contact</CardHeader>
<CardContent>{formatDateLastContact(props.database.lastContact)}</CardContent>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
</div>
<p className="text-xs text-muted-foreground">Backup success rate</p>
</CardContent>
</Card>
<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>
<Clock className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatDateLastContact(props.database.lastContact)}
</div>
<p className="text-xs text-muted-foreground">Time of last backup contact</p>
</CardContent>
</Card>
</div>
);
@@ -0,0 +1,87 @@
"use client"
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {restoreColumns} from "@/features/dashboard/restore/columns";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {MoreHorizontal, Trash2} from "lucide-react";
import {Restoration} from "@/db/schema/07_database";
import {useMutation} from "@tanstack/react-query";
import {deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
type DatabaseRestoreListProps = {
isAlreadyRestore: boolean;
restorations: Restoration[];
}
export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
const router = useRouter();
const mutationDeleteRestorations = useMutation({
mutationFn: async (restorations: Restoration[]) => {
const results = await Promise.all(
restorations.map(async (restoration) => {
const restorationDeleted = await deleteRestoreAction({
restorationId: restoration.id,
});
return {
success: restorationDeleted?.data?.success,
message: restorationDeleted?.data?.success
? restorationDeleted?.data?.actionSuccess?.message
// @ts-ignore
: restorationDeleted?.data?.actionError.message,
};
})
);
results.forEach((result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
});
router.refresh();
},
});
return (
<DataTable
columns={restoreColumns(props.isAlreadyRestore)}
data={props.restorations}
enablePagination
selectedActions={(rows) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ButtonWithLoading
variant="outline"
text="Actions"
onClick={() => {
}}
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
icon={<MoreHorizontal/>}
isPending={mutationDeleteRestorations.isPending}
size="sm"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem
onClick={async () => {
await mutationDeleteRestorations.mutateAsync(rows)
}}
disabled={props.isAlreadyRestore}
className="text-red-600 focus:text-red-700"
>
<Trash2 className="w-4 h-4 mr-2"/>
Delete Selected
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
/>
)
}
@@ -1,26 +1,30 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { eventUpdate } from "@/types/events";
import { backupColumns } from "@/features/dashboard/backup/columns";
import { restoreColumns } from "@/features/dashboard/restore/columns";
import { DataTable } from "@/components/wrappers/common/table/data-table";
import {Backup, Database, Restoration} from "@/db/schema/06_database";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {eventUpdate} from "@/types/events";
import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {DatabaseBackupList} from "@/components/wrappers/dashboard/projects/database/database-backup-list";
import {DatabaseRestoreList} from "@/components/wrappers/dashboard/projects/database/database-restore-list";
export type DatabaseTabsProps = {
settings: Setting
backups: Backup[];
restorations: Restoration[];
isAlreadyRestore: boolean;
database: Database;
database: DatabaseWith;
};
export const DatabaseTabs = (props: DatabaseTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
useEffect(() => {
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("modification", (event) => {
const data: eventUpdate = JSON.parse(event.data);
if (data.update) {
@@ -33,18 +37,34 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
};
}, []);
useEffect(() => {
const newTab = searchParams.get("tab") ?? "backup";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
return (
<Tabs className="flex flex-col flex-1" defaultValue="backup">
<Tabs className="flex flex-col flex-1" value={tab} onValueChange={handleChangeTab}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="backup">Backup</TabsTrigger>
<TabsTrigger value="restore">Restoration</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="backup">
<DataTable columns={backupColumns(props.isAlreadyRestore)} data={props.backups} enablePagination />
<DatabaseBackupList
isAlreadyRestore={props.isAlreadyRestore}
settings={props.settings}
database={props.database}
backups={props.backups}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="restore">
<DataTable columns={restoreColumns(props.isAlreadyRestore)} data={props.restorations} enablePagination />
<DatabaseRestoreList
isAlreadyRestore={props.isAlreadyRestore}
restorations={props.restorations}
/>
</TabsContent>
</Tabs>
);
@@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import Link from "next/link";
import {ProjectWith} from "@/db/schema/05_project";
import {ProjectWith} from "@/db/schema/06_project";
export type projectCardProps = {
data: ProjectWith;
@@ -5,7 +5,7 @@ import Image from "next/image";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ConnectionCircle } from "@/components/wrappers/common/connection-circle";
import { formatDateLastContact } from "@/utils/date-formatting";
import {Database} from "@/db/schema/06_database";
import {Database} from "@/db/schema/07_database";
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
export type projectDatabaseCardProps = {
@@ -1,14 +1,14 @@
"use server";
import { userAction } from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import { ProjectSchema } from "@/components/wrappers/dashboard/projects/project-form/project-form.schema";
import { z } from "zod";
import { ServerActionResult } from "@/types/action-type";
import { db } from "@/db";
import { eq, inArray } from "drizzle-orm";
import {Project} from "@/db/schema/05_project";
import {Project} from "@/db/schema/06_project";
import * as drizzleDb from "@/db";
import {Database} from "@/db/schema/06_database";
import {Database} from "@/db/schema/07_database";
import {slugify} from "@/utils/slugify";
export const createProjectAction = userAction
@@ -89,13 +89,13 @@ export const updateProjectAction = userAction
if (databasesToRemove.length > 0) {
await db.update(drizzleDb.schemas.database).set({ projectId: null }).where(inArray(drizzleDb.schemas.database.id, databasesToRemove));
}
const slug = slugify(parsedInput.data.name);
// const slug = slugify(parsedInput.data.name);
const [updatedProject] = await db
.update(drizzleDb.schemas.project)
.set({
name: parsedInput.data.name,
slug: slug,
// slug: slug,
})
.where(eq(drizzleDb.schemas.project.id, parsedInput.projectId))
.returning();
@@ -11,8 +11,8 @@ import { createProjectAction, updateProjectAction } from "@/components/wrappers/
import { useRouter } from "next/navigation";
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
import { toast } from "sonner";
import {DatabaseWith} from "@/db/schema/06_database";
import {Organization} from "@/db/schema/02_organization";
import {DatabaseWith} from "@/db/schema/07_database";
import {Organization} from "@/db/schema/03_organization";
export type projectFormProps = {
defaultValues?: ProjectType;
@@ -27,7 +27,7 @@ export const ProjectForm = (props: projectFormProps) => {
const formatDatabasesList = (databases: DatabaseWith[]) => {
return databases.map((database) => ({
value: database.id,
label: `${database.name} (${database.id}) | ${database.agent.name}`,
label: `${database.name} (${database.agentDatabaseId}) | ${database.agent?.name}`,
}));
};
@@ -47,7 +47,6 @@ export const ProjectForm = (props: projectFormProps) => {
const mutation = useMutation({
mutationFn: async (values: ProjectType) => {
console.log(values);
if (!isCreate && !props.projectId) {
throw new Error("Project ID is required for updates");
}
@@ -89,15 +88,15 @@ export const ProjectForm = (props: projectFormProps) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name="name"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Project 1" {...field} />
<Input placeholder="Project 1" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1,7 +1,7 @@
"use client";
import {ColumnDef} from "@tanstack/react-table";
import {MemberWithUser} from "@/db/schema/02_organization";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useState} from "react";
import {authClient, useSession} from "@/lib/auth/auth-client";
import {useMutation} from "@tanstack/react-query";
@@ -1,7 +1,7 @@
import {DataTable} from "@/components/wrappers/common/table/data-table";
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/columns-users";
import {MemberWithUser, Organization, OrganizationWithMembers} from "@/db/schema/02_organization";
import {OrganizationInvitation} from "@/db/schema/04_invitation";
import {MemberWithUser, Organization, OrganizationWithMembers} from "@/db/schema/03_organization";
import {OrganizationInvitation} from "@/db/schema/05_invitation";
import {organizationMemberColumns} from "@/components/wrappers/dashboard/settings/columns-organization-members";
@@ -1,5 +1,5 @@
"use server";
import {userAction} from "@/safe-actions";
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {auth} from "@/lib/auth/auth";
import {ServerActionResult} from "@/types/action-type";
@@ -15,25 +15,38 @@ export type evolutionLineChartProps = {
export function EvolutionLineChart(props: evolutionLineChartProps) {
const { data } = props;
// Process data to calculate cumulative count
const cumulativeData = data.reduce(
(acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
// const cumulativeData = data.reduce(
// (acc, backup) => {
// const date = backup.createdAt.toISOString().split("T")[0]; // Format as YYYY-MM-DD
//
// // Increment count for the current date or initialize it
// if (acc.length && acc[acc.length - 1].date === date) {
// acc[acc.length - 1].count += 1;
// } else {
// const lastCount = acc.length ? acc[acc.length - 1].count : 0;
// acc.push({ date, count: lastCount + 1 });
// }
//
// return acc;
// },
// [] as { date: string; count: number }[]
// );
const dailyData = data
.reduce((acc, backup) => {
const date = backup.createdAt.toISOString().split("T")[0]; // Format: YYYY-MM-DD
// Increment count for the current date or initialize it
if (acc.length && acc[acc.length - 1].date === date) {
acc[acc.length - 1].count += 1;
// Find if the date already exists in the accumulator
const existing = acc.find(item => item.date === date);
if (existing) {
existing.count += 1;
} else {
const lastCount = acc.length ? acc[acc.length - 1].count : 0;
acc.push({ date, count: lastCount + 1 });
acc.push({ date, count: 1 });
}
return acc;
},
[] as { date: string; count: number }[]
);
}, [] as { date: string; count: number }[]);
console.log(cumulativeData);
const chartConfig = {
date: {
@@ -50,7 +63,7 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={cumulativeData}
data={dailyData}
margin={{
left: 12,
right: 12,
@@ -68,7 +81,9 @@ export function EvolutionLineChart(props: evolutionLineChartProps) {
/>
<YAxis />
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />
<Line dataKey="count" type="linear" stroke="#60a5fa" strokeWidth={2} dot={false} />
{/*<Line dataKey="count" type="linear" stroke="var(--color-desktop)" strokeWidth={2} dot={false} />*/}
</LineChart>
</ChartContainer>
);
+9 -9
View File
@@ -1,13 +1,13 @@
import {drizzle} from "drizzle-orm/node-postgres";
import * as settings from "./schema/00_setting";
import * as user from "./schema/01_user";
import * as organisation from "./schema/02_organization";
import * as invitation from "./schema/03_member";
import * as member from "./schema/04_invitation";
import * as project from "./schema/05_project";
import * as agent from "./schema/07_agent";
import * as database from "./schema/06_database";
import * as settings from "./schema/01_setting";
import * as user from "./schema/02_user";
import * as organisation from "./schema/03_organization";
import * as invitation from "./schema/04_member";
import * as member from "./schema/05_invitation";
import * as project from "./schema/06_project";
import * as agent from "./schema/08_agent";
import * as database from "./schema/07_database";
import {Pool} from "pg";
@@ -37,7 +37,7 @@ export const schemas = {
export const db = drizzle({
client: pool,
logger: true,
// logger: process.env.NODE_ENV != 'production',
schema: schemas,
});

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