Merge branch 'feature'

# Conflicts:
#	src/features/dashboard/backup/columns.tsx
This commit is contained in:
charlesgauthereau
2025-08-07 15:06:05 +02:00
46 changed files with 1350 additions and 418 deletions
-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>
);
@@ -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>
@@ -19,6 +19,7 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
notFound();
}
return (
<Page>
<PageHeader>
@@ -27,6 +28,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,41 @@ 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";
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;
console.log("ici", projectId);
console.log("laa", databaseId);
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
}
});
if (!dbItem) {
notFound();
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
@@ -51,6 +74,12 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
.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 (
@@ -67,8 +96,10 @@ export default async function RoutePage(props: PageParams<{ databaseId: string }
</div>
<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} totalBackups={totalBackups}/>
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}/>
</PageContent>
</Page>
);
@@ -42,8 +42,8 @@ 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);
@@ -57,7 +57,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>
@@ -6,7 +6,7 @@ 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 {notFound, redirect} from "next/navigation";
import { db } from "@/db";
import { eq } from "drizzle-orm";
@@ -14,15 +14,12 @@ import {getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db";
export default async function RoutePage(props: PageParams<{
// slug: string;
projectId: string
}>) {
const {
// slug: organizationSlug,
projectId } = await props.params;
const organization = await getOrganization({});
if (!organization) {
notFound();
}
@@ -39,7 +36,9 @@ export default async function RoutePage(props: PageParams<{
},
});
if (!proj) notFound();
if (!proj) {
redirect("/dashboard/projects");
}
return (
@@ -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,8 @@ export default async function RoutePage(props: PageParams<{ }>) {
if (!org) notFound();
console.log("ici",availableDatabases)
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>
@@ -45,6 +45,56 @@ export default async function RoutePage(props: PageParams<{}>) {
});
console.log("evolution",backupsEvolution);
// const tomorrow = new Date();
// tomorrow.setDate(tomorrow.getDate() + 1);
//
// const backupsEvolution = [
// {
// id: '22e84aa4-228c-45b3-82ec-846a639cd509',
// createdAt: new Date()
// },
// {
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
// createdAt: new Date()
// },
// {
// id: '6a6106fe-7f45-48eb-a56f-0a1e734126a1',
// createdAt: new Date()
// },
// {
// 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: '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({
+8 -6
View File
@@ -6,6 +6,7 @@ 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();
@@ -18,8 +19,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 +44,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>
);
+18 -2
View File
@@ -1,12 +1,13 @@
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 {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {env} from "@/env.mjs";
export async function POST(
request: Request,
@@ -48,6 +49,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 +112,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(
+70 -21
View File
@@ -1,13 +1,16 @@
import {NextResponse} from "next/server";
import {Body} from "./route";
import {isUuidv4} from "@/utils/verify-uuid";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
import {Agent} from "@/db/schema/07_agent";
import {Database} from "@/db/schema/06_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";
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
const databasesResponse = [];
@@ -35,13 +38,13 @@ 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)
@@ -58,62 +61,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({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;
}
}
+1 -1
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({
+1 -1
View File
@@ -6,7 +6,7 @@ echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
echo " "
echo " Community Edition v1.0.0 "
echo " Community Edition v1.1.1 "
echo " "
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "portabase",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 8887",
+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'",
@@ -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>
);
})}
@@ -342,10 +202,7 @@ export function DataTable<TData, TValue>({
/>
)}
</div>
</div>
</div>
);
}
@@ -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/01_user";
import {Setting} from "@/db/schema/00_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/01_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/01_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>
);
};
@@ -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 {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";
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
@@ -7,6 +7,7 @@ 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 {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")) {
@@ -5,6 +5,7 @@ import { UserSchema } from "@/components/wrappers/dashboard/profile/user-form/us
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({
@@ -49,6 +62,7 @@ export const UserForm = (props: userFormProps) => {
});
return (
<div className="flex flex-col gap-y-4 h-full py-4">
<TooltipProvider>
<Card>
<CardHeader>
@@ -93,6 +107,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>
);
};
@@ -4,13 +4,19 @@ 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 {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: {
@@ -1,26 +1,37 @@
"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 {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, DatabaseWith, Restoration} from "@/db/schema/06_database";
import {Setting} from "@/db/schema/00_setting";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {MoreHorizontal, Trash2} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {deleteBackupAction, deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
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 +44,158 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
};
}, []);
useEffect(() => {
const newTab = searchParams.get("tab") ?? "backup";
setTab(newTab);
}, [searchParams]);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
};
const mutationDeleteBackups = useMutation({
mutationFn: async (backups: Backup[]) => {
const results = await Promise.all(
backups.map(async (backup) => {
const backupDeleted = await deleteBackupAction({
backupId: backup.id,
databaseId: backup.databaseId,
});
return {
success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success
? backupDeleted?.data?.actionSuccess?.message
// @ts-ignore
: restoration?.data?.actionError.message,
};
})
);
results.forEach((result) => {
if (result.success) {
toast.success(result.message);
} else {
toast.error(result.message);
}
});
router.refresh();
},
});
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 (
<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 />
<DataTable
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database)}
data={props.backups}
enablePagination
selectedActions={(rows) => (
<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={() => {*/}
{/* console.log("Deleting rows:", rows)*/}
{/* }}*/}
{/*>*/}
{/* <Download className="w-4 h-4 mr-2"/>*/}
{/* Download Selected*/}
{/*</DropdownMenuItem>*/}
{/*<Separator/>*/}
<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>
)}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="restore">
<DataTable columns={restoreColumns(props.isAlreadyRestore)} data={props.restorations} enablePagination />
<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>
)}
/>
</TabsContent>
</Tabs>
);
@@ -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}`,
}));
};
@@ -89,15 +89,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>
@@ -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>
);
+1 -1
View File
@@ -37,7 +37,7 @@ export const schemas = {
export const db = drizzle({
client: pool,
logger: true,
// logger: true,
schema: schemas,
});
+10 -1
View File
@@ -3,9 +3,10 @@ import { boolean, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import { project } from "./05_project";
import {member} from "@/db/schema/03_member";
import {member, OrganizationMember} from "@/db/schema/03_member";
import {invitation} from "@/db/schema/04_invitation";
import {organization} from "@/db/schema/02_organization";
import {Account} from "better-auth";
export const user = pgTable("user", {
id: uuid("id").defaultRandom().primaryKey(),
@@ -94,3 +95,11 @@ export const projectRelations = relations(project, ({ one }) => ({
export const userSchema = createSelectSchema(user);
export type User = z.infer<typeof userSchema>;
type FixedAccount = Omit<Account, 'updatedAt'> & {
updatedAt: Date | null;
};
export type UserWithAccounts = User & {
accounts: FixedAccount[];
};
+24 -23
View File
@@ -1,10 +1,10 @@
import { pgTable, text, boolean, timestamp, uuid, integer, pgEnum, uniqueIndex } from "drizzle-orm/pg-core";
import { Agent, agent } from "./07_agent";
import { Project, project } from "./05_project";
import { relations } from "drizzle-orm";
import { dbmsEnum, statusEnum } from "./types";
import { createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum, uniqueIndex} from "drizzle-orm/pg-core";
import {Agent, agent} from "./07_agent";
import {Project, project} from "./05_project";
import {relations} from "drizzle-orm";
import {dbmsEnum, statusEnum} from "./types";
import {createSelectSchema} from "drizzle-zod";
import {z} from "zod";
export const database = pgTable("databases", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -19,7 +19,7 @@ export const database = pgTable("databases", {
updatedAt: timestamp("updated_at"),
agentId: uuid("agent_id")
.notNull()
.references(() => agent.id, { onDelete: "cascade" }),
.references(() => agent.id, {onDelete: "cascade"}),
lastContact: timestamp("last_contact"),
projectId: uuid("project_id")
@@ -36,7 +36,7 @@ export const backup = pgTable(
updatedAt: timestamp("updated_at"),
databaseId: uuid("database_id")
.notNull()
.references(() => database.id, { onDelete: "cascade" }),
.references(() => database.id, {onDelete: "cascade"}),
},
// (table) => [uniqueIndex("database_id_status_unique").on(table.databaseId, table.status)]
);
@@ -48,25 +48,25 @@ export const restoration = pgTable("restorations", {
updatedAt: timestamp("updated_at"),
backupId: uuid("backup_id")
.notNull()
.references(() => backup.id, { onDelete: "cascade" }),
databaseId: uuid("database_id").references(() => database.id, { onDelete: "cascade" }),
.references(() => backup.id, {onDelete: "cascade"}),
databaseId: uuid("database_id").references(() => database.id, {onDelete: "cascade"}),
});
export const databaseRelations = relations(database, ({ one, many }) => ({
agent: one(agent, { fields: [database.agentId], references: [agent.id] }),
project: one(project, { fields: [database.projectId], references: [project.id] }),
export const databaseRelations = relations(database, ({one, many}) => ({
agent: one(agent, {fields: [database.agentId], references: [agent.id]}),
project: one(project, {fields: [database.projectId], references: [project.id]}),
backups: many(backup),
restorations: many(restoration),
}));
export const backupRelations = relations(backup, ({ one, many }) => ({
database: one(database, { fields: [backup.databaseId], references: [database.id] }),
export const backupRelations = relations(backup, ({one, many}) => ({
database: one(database, {fields: [backup.databaseId], references: [database.id]}),
restorations: many(restoration),
}));
export const restorationRelations = relations(restoration, ({ one }) => ({
backup: one(backup, { fields: [restoration.backupId], references: [backup.id] }),
database: one(database, { fields: [restoration.databaseId], references: [database.id] }),
export const restorationRelations = relations(restoration, ({one}) => ({
backup: one(backup, {fields: [restoration.backupId], references: [backup.id]}),
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
}));
export const databaseSchema = createSelectSchema(database);
@@ -79,8 +79,9 @@ export const restorationSchema = createSelectSchema(restoration);
export type Restoration = z.infer<typeof restorationSchema>;
export type DatabaseWith = Database & {
agent: Agent;
project: Project;
backups: Backup[];
restorations: Restoration[];
agent?: Agent | null;
project?: Project | null;
backups?: Backup[] | null;
restorations?: Restoration[] | null;
};
+30 -5
View File
@@ -12,19 +12,25 @@ import {
import {Button} from "@/components/ui/button";
import {Download, MoreHorizontal, Trash2} from "lucide-react";
import {ReloadIcon} from "@radix-ui/react-icons";
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
import {
getFileUrlPresignedLocal,
getFileUrlPreSignedS3Action
} from "@/features/upload/private/upload.action";
import {useMutation} from "@tanstack/react-query";
import {createRestorationAction, deleteBackupAction} from "@/features/dashboard/restore/restore.action";
import {toast} from "sonner";
import {useRouter} from "next/navigation";
import {StatusBadge} from "@/components/wrappers/common/status-badge";
import {Backup} from "@/db/schema/06_database";
import {Backup, DatabaseWith} from "@/db/schema/06_database";
import {formatFrenchDate} from "@/utils/date-formatting";
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
import {Setting} from "@/db/schema/00_setting";
import {SafeActionResult} from "next-safe-action";
import {ZodString} from "zod";
import {ServerActionResult} from "@/types/action-type";
export function backupColumns(isAlreadyRestore: boolean): ColumnDef<Backup>[] {
export function backupColumns(isAlreadyRestore: boolean, settings: Setting, database: DatabaseWith): ColumnDef<Backup>[] {
return [
{
@@ -99,7 +105,26 @@ export function backupColumns(isAlreadyRestore: boolean): ColumnDef<Backup>[] {
};
const handleDownload = async (fileName: string) => {
const url = await getFileUrlPresignedLocal(fileName);
let url: string = "";
let data: SafeActionResult<string, ZodString, readonly [], {
_errors?: string[] | undefined;
}, readonly [], ServerActionResult<string>, object> | undefined
if (settings.storage == "local") {
data = await getFileUrlPresignedLocal(fileName!)
} else if (settings.storage == "s3") {
data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`);
}
console.log(data)
if (data?.data?.success) {
url = data.data.value ?? "";
} else {
// @ts-ignore
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
toast.error(errorMessage);
}
window.open(url, "_self");
};
+141 -16
View File
@@ -1,16 +1,25 @@
"use server";
import { mkdir, writeFile } from "fs/promises";
import {mkdir, writeFile} from "fs/promises";
import path from "path";
import * as fs from "node:fs";
import { getServerUrl } from "@/utils/get-server-url";
import {getServerUrl} from "@/utils/get-server-url";
import {createPresignedUrlToDownload, saveFileInBucket} from "@/utils/s3-file-management";
import crypto from "crypto";
import {env} from "@/env.mjs";
import {action, userAction} from "@/safe-actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {Backup} from "@/db/schema/06_database";
import {db} from "@/db";
import * as drizzleDb from "@/db";
const privateLocalDir = "private/uploads/files/";
const privateS3Dir = "backups/";
export async function uploadLocalPrivate(fileName: string, buffer: any) {
try {
await mkdir(path.join(process.cwd(), privateLocalDir), { recursive: true });
await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
await writeFile(path.join(process.cwd(), privateLocalDir, fileName), buffer);
return {
@@ -24,22 +33,138 @@ export async function uploadLocalPrivate(fileName: string, buffer: any) {
}
}
export async function getFileUrlPresignedLocal(fileName: string) {
export async function uploadS3Private(fileName: string, buffer: any, bucketName: string) {
try {
const filePath = path.join(privateLocalDir, fileName);
await mkdir(path.join(process.cwd(), privateLocalDir), { recursive: true });
if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath);
return `File not found at: ${filePath}`;
}
const crypto = require("crypto");
const baseUrl = getServerUrl();
await saveFileInBucket({
bucketName,
fileName: `${privateS3Dir}${fileName}`,
file: buffer,
});
const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex");
return `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`;
return {
success: true,
filePath: `${privateS3Dir}${fileName}`,
message: "File uploaded successfully",
};
} catch (error) {
console.error("Error occurred:", error);
throw new Error("An error occurred while importing the private file");
}
}
// export async function getFileUrlPresignedLocal(fileName: string) {
// try {
// const filePath = path.join(privateLocalDir, fileName);
// await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
//
// if (!fs.existsSync(filePath)) {
// console.error("File not found at:", filePath);
// return `File not found at: ${filePath}`;
// }
// const crypto = require("crypto");
// const baseUrl = getServerUrl();
//
// const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
// const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex");
// return `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`;
// } catch (error) {
// throw error;
// }
// }
export async function getFileUrlPresignedS3(fileName: string) {
try {
return await createPresignedUrlToDownload({
bucketName: env.S3_BUCKET_NAME!,
fileName: fileName,
});
} catch (error) {
throw error;
}
}
export const getFileUrlPresignedLocal = action
.schema(z.string())
.action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
try {
const filePath = path.join(privateLocalDir, parsedInput);
await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
if (!fs.existsSync(filePath)) {
console.error("File not found at:", filePath);
throw new Error(`File not found at: ${filePath}`);
}
const crypto = require("crypto");
const baseUrl = getServerUrl();
const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
const token = crypto.createHash("sha256").update(`${parsedInput}${expiresAt}`).digest("hex");
return {
success: true,
value: `${baseUrl}/api/files/${parsedInput}?token=${token}&expires=${expiresAt}`,
actionSuccess: {
message: "Successfully retrieved presigned URL Local",
messageParams: {fileName: parsedInput},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Failed to generate presigned URL",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {fileName: parsedInput},
},
};
}
});
export const getFileUrlPreSignedS3Action = action
.schema(z.string())
.action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
try {
const data = await createPresignedUrlToDownload({
bucketName: env.S3_BUCKET_NAME!,
fileName: parsedInput,
});
return {
success: true,
value: data.url,
actionSuccess: {
message: "Successfully retrieved presigned URL",
messageParams: {fileName: parsedInput},
},
};
} catch (error) {
const isNotFound = error instanceof Error && error.message.includes("File does not exist");
const logContext = {
file: parsedInput,
reason: error instanceof Error ? error.message : "Unknown",
};
console.error("Presigned URL generation failed:", logContext);
return {
success: false,
actionError: {
message: isNotFound
? "File not found in S3 bucket"
: "Failed to generate presigned URL",
status: isNotFound ? 404 : 500,
cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {fileName: parsedInput},
},
};
}
});
+7 -5
View File
@@ -30,11 +30,13 @@ export const uploadImageAction = userAction.schema(z.instanceof(FormData)).actio
let result: void | UploadedObjectInfo;
const bucketName = "public-image-bucket";
if (settings.storage === "local") {
result = await uploadLocal(fileName, buffer);
} else if (settings.storage === "s3") {
result = await uploadS3Compatible(bucketName, fileName, buffer);
}
// TODO : Do not delete
// if (settings.storage === "local") {
// result = await uploadLocal(fileName, buffer);
// } else if (settings.storage === "s3") {
// result = await uploadS3Compatible(bucketName, fileName, buffer);
// }
result = await uploadLocal(fileName, buffer);
const url = getUrl(fileName, settings, bucketName);
console.log(url);
+4 -1
View File
@@ -1,8 +1,9 @@
import {createAuthClient} from "better-auth/react";
import {adminClient, organizationClient} from "better-auth/client/plugins";
import {adminClient, inferAdditionalFields, organizationClient} from "better-auth/client/plugins";
import {env} from "@/env.mjs";
import {ac, user, admin as adminRole, pending, superadmin, orgAdmin, orgMember, orgOwner} from "./permissions";
import {auth} from "@/lib/auth/auth";
export const authClient = createAuthClient({
baseURL: env.NEXT_PUBLIC_PROJECT_URL,
@@ -24,7 +25,9 @@ export const authClient = createAuthClient({
superadmin,
},
}),
inferAdditionalFields<typeof auth>(),
],
});
export const {signIn, signOut, signUp, useSession, listAccounts, admin} = authClient;
+61
View File
@@ -0,0 +1,61 @@
import {auth, getOrganization} from "@/lib/auth/auth";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: {
organizationSlug: string, projectId: string
}) => {
try {
const organization = await getOrganization({});
if (!organization) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)),
with: {
databases: true
}
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id)
}
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
}
};
+3 -3
View File
@@ -1,12 +1,12 @@
import {format} from "date-fns";
import {format, formatDistanceToNow} from "date-fns";
export function humanReadableDate(rawDate: string | number | Date) {
return formatFrenchDate(rawDate);
}
export function timeAgo(rawDate: string | number | Date) {
const date = new Date(rawDate)
return "Not implemented"
const date = new Date(rawDate);
return formatDistanceToNow(date, { addSuffix: true });
}
export function formatDateLastContact(lastContact: string | number | Date | null) {
+148
View File
@@ -0,0 +1,148 @@
export default function detectOSWithUA(userAgent: string) {
const osList = [
{
name: "Windows",
keywords: ["Win", "NT", "Windows"],
icon: {
name: "microsoft-windows-icon",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Ubuntu",
keywords: ["Ubuntu"],
icon: {
name: "ubuntu",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "iOS",
keywords: ["iOS"],
icon: {
name: "ios",
size: {
width: 24,
height: 0,
},
},
showText: false,
},
{
name: "iPadOS",
keywords: ["iPadOS", "iPad"],
icon: {
name: "ios",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "MacOS",
keywords: ["MacOS", "Macintosh", "Mac OS", "Mac OS X"],
icon: {
name: "macos",
size: {
width: 0,
height: 16,
},
},
showText: false,
},
{
name: "Android",
keywords: ["Android"],
icon: {
name: "android-icon",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Linux",
keywords: ["X11", "Linux"],
icon: {
name: "linux-tux",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Playstation 4",
keywords: ["PlayStation 4"],
showText: true,
},
{
name: "Playstation 5",
keywords: ["PlayStation 5"],
showText: true,
},
{
name: "Xbox Series X",
keywords: ["Xbox Series X"],
showText: true,
},
{
name: "Xbox One S",
keywords: ["XBOX_ONE_ED"],
showText: true,
},
{
name: "Xbox One",
keywords: ["Xbox One"],
showText: true,
},
{
name: "Nintendo Switch",
keywords: ["Nintendo Switch"],
showText: true,
},
{
name: "AppleTV",
keywords: ["AppleTV"],
icon: {
name: "apple",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
];
for (let os of osList) {
if (os.keywords.some((keyword) => userAgent.includes(keyword))) {
return os;
}
}
return {
name: "Unknown OS",
icon: {
name: "unknown",
size: {
width: 24,
height: 0,
},
},
showText: true,
};
}
+85 -16
View File
@@ -23,16 +23,23 @@ async function getS3Client() {
secretKey: settings.s3SecretAccessKey ?? "",
};
const s3Client =
env.NODE_ENV === "production"
? new Minio.Client({
...baseConfig,
})
: new Minio.Client({
...baseConfig,
port: Number(env.S3_PORT ?? 0),
useSSL: env.S3_USE_SSL === "true",
});
// const s3Client =
// env.NODE_ENV === "production"
// ? new Minio.Client({
// ...baseConfig,
// useSSL: env.S3_USE_SSL === "true",
// })
// : new Minio.Client({
// ...baseConfig,
// port: Number(env.S3_PORT ?? 0),
// useSSL: env.S3_USE_SSL === "true",
// });
const s3Client = new Minio.Client({
...baseConfig,
port: Number(env.S3_PORT ?? 0),
useSSL: env.S3_USE_SSL === "true",
})
return s3Client;
}
@@ -101,15 +108,35 @@ export async function saveFileInBucket({bucketName, fileName, file}: {
* @param fileName name of the file
* @returns true if file exists, false if not
*/
export async function checkFileExistsInBucket({bucketName, fileName}: { bucketName: string; fileName: string }) {
const s3Client = await getS3Client();
// export async function checkFileExistsInBucket({bucketName, fileName}: { bucketName: string; fileName: string }) {
// const s3Client = await getS3Client();
//
// try {
// await s3Client.statObject(bucketName, fileName);
// } catch (error) {
// return false;
// }
// return true;
// }
export async function checkFileExistsInBucket({
bucketName,
fileName,
}: {
bucketName: string;
fileName: string;
}): Promise<boolean> {
const s3 = await getS3Client();
try {
await s3Client.statObject(bucketName, fileName);
} catch (error) {
const stat = await s3.statObject(bucketName, fileName);
return !!stat;
} catch (error: any) {
if (error.code === 'NoSuchKey' || error.message?.includes('not found')) {
return false;
}
// Instead of throwing, return false to prevent crashes
// console.error("Unexpected S3 statObject error:", error);
return false;
}
return true;
}
/**
@@ -168,3 +195,45 @@ export async function createPublicBucket({bucketName}: { bucketName: string }) {
console.error("Error creating bucket:", error);
}
}
/**
* Generate a presigned URL for downloading a file from a private S3 bucket
* @param bucketName name of the bucket
* @param fileName name of the file
* @param expiry expiry time in seconds (default 1 hour)
* @returns presigned download URL
*/
export async function createPresignedUrlToDownload({
bucketName,
fileName,
expiry = 60 * 60,
}: {
bucketName: string;
fileName: string;
expiry?: number;
}) {
try {
const s3Client = await getS3Client();
console.debug("Checking if file exists in bucket:", {bucketName, fileName});
const fileExists = await checkFileExistsInBucket({bucketName, fileName});
if (!fileExists) {
console.warn("File does not exist:", {bucketName, fileName});
throw new Error("File does not exist in the bucket.");
}
const presignedUrl = await s3Client.presignedGetObject(bucketName, fileName, expiry);
console.debug("Generated pre signed URL:", presignedUrl);
return {url: presignedUrl};
} catch (err: any) {
console.error("Error in createPreSignedUrlToDownload:", {
bucketName,
fileName,
errorMessage: err?.message,
// stack: err?.stack,
});
throw {error: err.message ?? "Unknown error"};
}
}