mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
21
Commits
1.1.2
...
1.1.3-rc.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ebc876bf | ||
|
|
a435a92d41 | ||
|
|
bcbd6bbea1 | ||
|
|
82559b49bd | ||
|
|
8c1b869004 | ||
|
|
c4a0692985 | ||
|
|
750f1cb145 | ||
|
|
b7d3487c53 | ||
|
|
529f157c6e | ||
|
|
c62be9854a | ||
|
|
9c1aa66b41 | ||
|
|
9894e2582d | ||
|
|
8a8ef81fce | ||
|
|
2fdd9f1296 | ||
|
|
5251e01011 | ||
|
|
84dab35f4b | ||
|
|
ae766dab2f | ||
|
|
027928c29f | ||
|
|
e3239639f3 | ||
|
|
7d6038e5f1 | ||
|
|
2a90d4933d |
@@ -5,9 +5,9 @@ NODE_ENV=production
|
|||||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||||
|
|
||||||
# Projet
|
# Projet
|
||||||
NEXT_PUBLIC_PROJECT_NAME="Portabase"
|
PROJECT_NAME="Portabase"
|
||||||
NEXT_PUBLIC_PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||||
NEXT_PUBLIC_PROJECT_URL=http://app.portabase.io
|
PROJECT_URL=http://app.portabase.io
|
||||||
PROJECT_SECRET=
|
PROJECT_SECRET=
|
||||||
|
|
||||||
# SMTP (email)
|
# SMTP (email)
|
||||||
@@ -20,6 +20,7 @@ SMTP_FROM=
|
|||||||
# Google
|
# Google
|
||||||
AUTH_GOOGLE_ID=
|
AUTH_GOOGLE_ID=
|
||||||
AUTH_GOOGLE_SECRET=
|
AUTH_GOOGLE_SECRET=
|
||||||
|
AUTH_GOOGLE_METHOD=
|
||||||
|
|
||||||
# S3
|
# S3
|
||||||
S3_ENDPOINT=http://app.s3.portabase.io
|
S3_ENDPOINT=http://app.s3.portabase.io
|
||||||
@@ -31,3 +32,6 @@ S3_USE_SSL=true
|
|||||||
|
|
||||||
# Storage Type (s3, local)
|
# Storage Type (s3, local)
|
||||||
STORAGE_TYPE=local
|
STORAGE_TYPE=local
|
||||||
|
|
||||||
|
# Retention
|
||||||
|
RETENTION_CRON="* * * * *"
|
||||||
@@ -48,22 +48,3 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.tags }}
|
tags: ${{ steps.set-tags.outputs.tags }}
|
||||||
target: prod
|
target: prod
|
||||||
|
|
||||||
# Do not delete
|
|
||||||
# - name: Build and push Docker image
|
|
||||||
# id: push
|
|
||||||
# uses: docker/build-push-action@v6
|
|
||||||
# with:
|
|
||||||
# context: .
|
|
||||||
# file: ./docker/dockerfile/Dockerfile
|
|
||||||
# push: true
|
|
||||||
# tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
# labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
# target: prod
|
|
||||||
|
|
||||||
# - name: Generate artifact attestation
|
|
||||||
# uses: actions/attest-build-provenance@v2
|
|
||||||
# with:
|
|
||||||
# subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
|
|
||||||
# subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
# push-to-registry: true
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
.idea
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
/.pnp
|
/.pnp
|
||||||
|
|||||||
Binary file not shown.
@@ -20,7 +20,6 @@
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -178,6 +177,9 @@ S3_USE_SSL=true
|
|||||||
|
|
||||||
# Storage Backend: 'local' or 's3'
|
# Storage Backend: 'local' or 's3'
|
||||||
STORAGE_TYPE=local
|
STORAGE_TYPE=local
|
||||||
|
|
||||||
|
# Retention
|
||||||
|
RETENTION_CRON="* * * * *"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Semantic Versioning
|
### Semantic Versioning
|
||||||
|
|||||||
+9
-38
@@ -1,49 +1,20 @@
|
|||||||
"use client"
|
import React from "react";
|
||||||
import React, {useEffect, useState} from "react";
|
import {redirect} from "next/navigation";
|
||||||
import {LayoutAdmin} from "@/components/layout";
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
import Image from "next/image";
|
import {AuthLogoSection} from "@/components/wrappers/auth/auth-logo-section";
|
||||||
import {env} from "@/env.mjs";
|
|
||||||
import {useTheme} from "next-themes";
|
|
||||||
import {useSession} from "@/lib/auth/auth-client";
|
|
||||||
import {useRouter} from "next/navigation";
|
|
||||||
|
|
||||||
|
export default async function Layout({children}: { children: React.ReactNode }) {
|
||||||
|
|
||||||
export default function Layout({children}: { children: React.ReactNode }) {
|
const user = await currentUser();
|
||||||
|
|
||||||
const { resolvedTheme } = useTheme();
|
if (user && !user.banned && user.role !== "pending") {
|
||||||
const [mounted, setMounted] = useState(false);
|
redirect("/dashboard/home");
|
||||||
const router = useRouter();
|
|
||||||
const { data: session } = useSession();
|
|
||||||
|
|
||||||
if (session && session.user && !session.user.banned && session.user.role !== "pending") {
|
|
||||||
router.replace("/dashboard/home");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
<div className="flex min-h-full flex-1 flex-col justify-center py-12 sm:px-6 lg:px-8 ">
|
||||||
<div className="mx-auto w-full max-w-md">
|
<div className="mx-auto w-full max-w-md">
|
||||||
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
<AuthLogoSection/>
|
||||||
<img
|
|
||||||
className="p-12 text-black dark:text-white"
|
|
||||||
src={imageTheme}
|
|
||||||
// loading="eager"
|
|
||||||
alt="Logo"
|
|
||||||
// width={1024}
|
|
||||||
// height={1024}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-sm text-muted-foreground -ml-12 -mb-12">
|
|
||||||
v{env.NEXT_PUBLIC_PROJECT_VERSION}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
<footer className="py-4 text-center text-xs justify-items-end text-muted-foreground">
|
||||||
|
|||||||
+11
-23
@@ -1,32 +1,20 @@
|
|||||||
"use client"
|
import {notFound} from "next/navigation";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
import {useEffect, useRef, useState} from "react";
|
|
||||||
import {useSearchParams} from "next/navigation";
|
|
||||||
|
|
||||||
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
import {LoginForm} from "@/components/wrappers/auth/login/login-form/login-form";
|
||||||
import {toast} from "sonner";
|
import {Metadata} from "next";
|
||||||
|
|
||||||
export default function SignInPage(props: {
|
export const metadata: Metadata = {
|
||||||
searchParams: Promise<{ callbackUrl: string | undefined }>
|
title: "Login",
|
||||||
}) {
|
};
|
||||||
|
|
||||||
const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
export default async function SignInPage() {
|
||||||
|
const authGoogleEnabled = env.AUTH_GOOGLE_METHOD;
|
||||||
useEffect(() => {
|
if (!authGoogleEnabled) {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
notFound()
|
||||||
setUrlParams(urlParams);
|
|
||||||
const error = urlParams.get("error");
|
|
||||||
console.log(urlParams.get("redirect"));
|
|
||||||
if (error?.includes("pending")) {
|
|
||||||
toast.error("Your account is not active.");
|
|
||||||
urlParams.delete("error");
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto grid w-full gap-6">
|
<div className="mx-auto grid w-full gap-6">
|
||||||
<LoginForm/>
|
<LoginForm authGoogleEnabled={authGoogleEnabled}/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
import {RegisterForm} from "@/components/wrappers/auth/register/register-form/register-form";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Register",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {notFound} from "next/navigation";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {PageParams} from "@/types/next";
|
||||||
|
import {Page} from "@/features/layout/page";
|
||||||
|
import {OrganizationManagement} from "@/components/wrappers/dashboard/admin/organization/organization-management";
|
||||||
|
import {buildOrganizationWithMembers} from "@/utils/common";
|
||||||
|
import {isUUID} from "@/utils/text";
|
||||||
|
import {user} from "@/db/schema/02_user";
|
||||||
|
import {invitation} from "@/db/schema/05_invitation";
|
||||||
|
import {member} from "@/db/schema/04_member";
|
||||||
|
import {organization} from "@/db/schema/03_organization";
|
||||||
|
import {user as drizzleUser} from "@/db/schema/02_user";
|
||||||
|
|
||||||
|
|
||||||
|
export default async function RoutePage(props: PageParams<{ organizationId: string }>) {
|
||||||
|
const {organizationId} = await props.params;
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUUID(organizationId)) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await db.select().from(drizzleUser);
|
||||||
|
|
||||||
|
const organizationData = await db
|
||||||
|
.select({organization, member, user, invitation})
|
||||||
|
.from(organization)
|
||||||
|
.leftJoin(member, eq(drizzleDb.schemas.organization.id, member.organizationId))
|
||||||
|
.leftJoin(invitation, eq(drizzleDb.schemas.invitation.id, invitation.organizationId))
|
||||||
|
.leftJoin(user, eq(drizzleDb.schemas.member.userId, user.id))
|
||||||
|
.where(eq(organization.id, organizationId));
|
||||||
|
|
||||||
|
const formattedData = buildOrganizationWithMembers(organizationData);
|
||||||
|
|
||||||
|
if (!formattedData) return notFound();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<OrganizationManagement organization={formattedData} users={users}/>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,11 @@ import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|||||||
import {AdminTabs} from "@/components/wrappers/dashboard/admin/admin-tabs";
|
import {AdminTabs} from "@/components/wrappers/dashboard/admin/admin-tabs";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {isNull} from "drizzle-orm";
|
import {isNull} from "drizzle-orm";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Admin",
|
||||||
|
};
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
const users = await db.query.user.findMany({
|
const users = await db.query.user.findMany({
|
||||||
@@ -14,17 +17,28 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const organizations = await db.query.organization.findMany({
|
||||||
|
where: (fields) => isNull(fields.deletedAt),
|
||||||
|
with: {
|
||||||
|
members: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
const settings = await db.query.setting.findFirst({
|
||||||
where: (fields, {eq}) => eq(fields.name, "system"),
|
where: (fields, {eq}) => eq(fields.name, "system"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>Administration Panel</PageTitle>
|
<PageTitle>Administration Panel</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<AdminTabs settings={settings!} users={users}/>
|
<AdminTabs
|
||||||
|
organizations={organizations}
|
||||||
|
settings={settings!}
|
||||||
|
users={users}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
|
|||||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||||
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
import {DatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
|
||||||
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
import {AgentCardKey} from "@/components/wrappers/dashboard/agent/agent-card-key/agent-card-key";
|
||||||
import { db } from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {and, eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {
|
|
||||||
ButtonDeleteProject
|
|
||||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
|
||||||
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
import {Server} from "lucide-react";
|
import {Server} from "lucide-react";
|
||||||
|
import {generateEdgeKey} from "@/utils/edge_key";
|
||||||
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||||
|
|
||||||
@@ -31,11 +29,11 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -48,7 +46,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
|||||||
</Link>
|
</Link>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
<PageActions className="justify-between">
|
<PageActions className="justify-between">
|
||||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} />
|
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</div>
|
</div>
|
||||||
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
<PageDescription className="mt-5 sm:mt-0">{agent.description}</PageDescription>
|
||||||
@@ -58,7 +56,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
|||||||
<Card className="w-full sm:w-auto flex-1">
|
<Card className="w-full sm:w-auto flex-1">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
<CardTitle className="text-sm font-medium">Databases</CardTitle>
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
<div className="text-2xl font-bold">{agent.databases.length}</div>
|
||||||
@@ -69,7 +67,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
|||||||
<Card className="w-full sm:w-auto flex-1">
|
<Card className="w-full sm:w-auto flex-1">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
<CardTitle className="text-sm font-medium">Last contact</CardTitle>
|
||||||
<Server className="h-4 w-4 text-muted-foreground" />
|
<Server className="h-4 w-4 text-muted-foreground"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
<div className="text-2xl font-bold">{formatDateLastContact(agent.lastContact)}</div>
|
||||||
@@ -83,7 +81,9 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
|||||||
Edge Key
|
Edge Key
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<AgentCardKey agent={agent}/>
|
<AgentCardKey
|
||||||
|
edgeKey={edgeKey}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
|
<CardsWithPagination cardsPerPage={2} data={agent.databases} cardItem={DatabaseCard}/>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { PageParams } from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Create Agent",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
return (
|
return (
|
||||||
@@ -9,7 +14,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageTitle>Create new agent</PageTitle>
|
<PageTitle>Create new agent</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<AgentForm />
|
<AgentForm/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { PageParams } from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import { AgentCard } from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||||
import { CardsWithPagination } from "@/components/wrappers/common/cards-with-pagination";
|
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||||
import { Button } from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Page, PageActions, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
import { notFound } from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import { db } from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq, not} from "drizzle-orm";
|
||||||
import {and, eq, not} from "drizzle-orm";
|
|
||||||
import {Plus} from "lucide-react";
|
|
||||||
import {cn} from "@/lib/utils";
|
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||||
export const dynamic = "force-dynamic";
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Agents",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
{agents.length > 0 ? (
|
{agents.length > 0 ? (
|
||||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1} />
|
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyStatePlaceholder
|
<EmptyStatePlaceholder
|
||||||
url={"/dashboard/agents/new"}
|
url={"/dashboard/agents/new"}
|
||||||
|
|||||||
+9
-3
@@ -11,7 +11,7 @@ import {db} from "@/db";
|
|||||||
import {eq, and, inArray} from "drizzle-orm";
|
import {eq, and, inArray} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
|
||||||
@@ -22,8 +22,9 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
const {projectId, databaseId} = await props.params;
|
const {projectId, databaseId} = await props.params;
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
|
const activeMember = await getActiveMember()
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization || !activeMember) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +84,9 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
|
|
||||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||||
|
|
||||||
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="justify-between gap-2 sm:flex">
|
<div className="justify-between gap-2 sm:flex">
|
||||||
@@ -90,6 +94,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
<div className=" w-full md:w-fit">
|
<div className=" w-full md:w-fit">
|
||||||
{capitalizeFirstLetter(dbItem.name)}
|
{capitalizeFirstLetter(dbItem.name)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isMember && (
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full">
|
<div className="flex items-center gap-2 md:justify-between w-full">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Do not delete*/}
|
{/* Do not delete*/}
|
||||||
@@ -101,6 +106,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,7 +116,7 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
<PageContent className="flex flex-col w-full h-full">
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
<DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
|
||||||
totalBackups={totalBackups}/>
|
totalBackups={totalBackups}/>
|
||||||
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
|
<DatabaseTabs activeMember={activeMember} settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
|
||||||
backups={backups}
|
backups={backups}
|
||||||
restorations={restorations}/>
|
restorations={restorations}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {notFound, redirect} from "next/navigation";
|
|||||||
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {capitalizeFirstLetter} from "@/utils/text";
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
} = await props.params;
|
} = await props.params;
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
|
const activeMember = await getActiveMember()
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -48,23 +50,27 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
redirect("/dashboard/projects");
|
redirect("/dashboard/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="justify-between gap-2 sm:flex">
|
<div className="justify-between gap-2 sm:flex">
|
||||||
<PageTitle className="flex items-center">
|
<PageTitle className="flex items-center">
|
||||||
{capitalizeFirstLetter(proj.name)}
|
{capitalizeFirstLetter(proj.name)}
|
||||||
<Link className={buttonVariants({variant: "outline"})} href={`/dashboard/projects/${proj.id}/edit`}>
|
{!isMember && (
|
||||||
|
<Link className={buttonVariants({variant: "outline"})}
|
||||||
|
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||||
<GearIcon className="w-7 h-7"/>
|
<GearIcon className="w-7 h-7"/>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
|
{!isMember && (
|
||||||
<PageActions className="justify-between">
|
<PageActions className="justify-between">
|
||||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||||
</PageActions>
|
</PageActions>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PageDescription>The list of associated databases</PageDescription>
|
<PageDescription>The list of associated databases</PageDescription>
|
||||||
|
|
||||||
<PageContent className="flex flex-col w-full h-full">
|
<PageContent className="flex flex-col w-full h-full">
|
||||||
{proj.databases.length > 0 ? (
|
{proj.databases.length > 0 ? (
|
||||||
<CardsWithPagination
|
<CardsWithPagination
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import {getOrganization} from "@/lib/auth/auth";
|
|||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ }>) {
|
|
||||||
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
|
|
||||||
if (!organization ) {
|
if (!organization) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
|
|||||||
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
import {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ }>) {
|
export const metadata: Metadata = {
|
||||||
|
title: "Projects",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
|
const activeMember = await getActiveMember()
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
notFound();
|
notFound();
|
||||||
@@ -28,12 +34,14 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
|||||||
databases: true,
|
databases: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>Projects</PageTitle>
|
<PageTitle>Projects</PageTitle>
|
||||||
{projects.length > 0 && (
|
{(projects.length > 0 && !isMember) && (
|
||||||
<PageActions>
|
<PageActions>
|
||||||
<Link href={`/dashboard/projects/new`}>
|
<Link href={`/dashboard/projects/new`}>
|
||||||
<Button>+ Create Project</Button>
|
<Button>+ Create Project</Button>
|
||||||
@@ -44,13 +52,17 @@ export default async function RoutePage(props: PageParams<{ }>) {
|
|||||||
|
|
||||||
<PageContent>
|
<PageContent>
|
||||||
{projects.length > 0 ? (
|
{projects.length > 0 ? (
|
||||||
<CardsWithPagination organizationSlug={organization.slug} data={projects} cardItem={ProjectCard}
|
<CardsWithPagination
|
||||||
cardsPerPage={4} numberOfColumns={1}/>
|
organizationSlug={organization.slug}
|
||||||
) : (
|
data={projects}
|
||||||
<EmptyStatePlaceholder
|
cardItem={ProjectCard}
|
||||||
url={"/dashboard/projects/new"}
|
cardsPerPage={4}
|
||||||
text={"Create new Project"}
|
numberOfColumns={1}
|
||||||
/>
|
/>
|
||||||
|
) : isMember ? (
|
||||||
|
<EmptyStatePlaceholder text="No project available"/>
|
||||||
|
) : (
|
||||||
|
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
||||||
)}
|
)}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
where: (fields) => isNull(fields.deletedAt)
|
where: (fields) => isNull(fields.deletedAt)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!user || !users || !organization || organization.slug == "default") {
|
if (!user || !users || !organization || organization.slug == "default") {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {EditButtonSettings} from "@/components/wrappers/dashboard/settings/edit-
|
|||||||
import {
|
import {
|
||||||
SettingsOrganizationMembersTable
|
SettingsOrganizationMembersTable
|
||||||
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
} from "@/components/wrappers/dashboard/settings/settings-organization-members-table";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
@@ -22,10 +26,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
|
const isOwner = activeMember?.role === "owner";
|
||||||
if (isMember) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -37,14 +38,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
)}
|
)}
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
<PageActions>
|
<PageActions>
|
||||||
{!isMember && organization.slug !== "default" && (
|
{isOwner && organization.slug !== "default" && (
|
||||||
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
<DeleteOrganizationButton organizationSlug={organization.slug}/>
|
||||||
)}
|
)}
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{/*<PageDescription>*/}
|
|
||||||
{/* Manage your organization settings.*/}
|
|
||||||
{/*</PageDescription>*/}
|
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<SettingsOrganizationMembersTable organization={organization}/>
|
<SettingsOrganizationMembersTable organization={organization}/>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ import {db} from "@/db";
|
|||||||
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
import {and, asc, count, eq, inArray} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
import {getOrganization} from "@/lib/auth/auth";
|
||||||
import {DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
import {Building2, DatabaseBackup, Folder, RefreshCcw} from "lucide-react";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Statistics",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
@@ -45,79 +50,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// const tomorrow = new Date();
|
|
||||||
// tomorrow.setDate(tomorrow.getDate() + 1);
|
|
||||||
//
|
|
||||||
// const before = new Date();
|
|
||||||
// before.setDate(before.getDate() - 1);
|
|
||||||
//
|
|
||||||
// const backupsEvolution = [
|
|
||||||
// {
|
|
||||||
// id: '22e84aa4-228c-45b3-82ec-846a639cd509',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: '2c114dc3-1fa6-4ef1-972c-9765c65e9331',
|
|
||||||
// createdAt: new Date(before)
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: '6a6106fe-7f45-48eb-a56f-0a1e734126a1',
|
|
||||||
// createdAt: new Date(before)
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'a529d790-502e-4609-ad37-9b1c00c73477',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'a8c105a8-3e29-423e-b7dd-d3218092cde1',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'd33aaf4f-8525-4490-addb-12e3c8650d6d',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
|
||||||
// createdAt: new Date(tomorrow)
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
|
||||||
// createdAt: new Date(tomorrow)
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'e88a588a-2353-4470-9976-8c3eb2ffc88d',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'ee11441e-4b41-4c1b-9d91-929565b4204a',
|
|
||||||
// createdAt: new Date()
|
|
||||||
// },
|
|
||||||
//
|
|
||||||
// // Entries with tomorrow's date
|
|
||||||
// {
|
|
||||||
// id: 'f1d5a4e2-1c33-41c4-932b-02456c2a6f1d',
|
|
||||||
// createdAt: new Date(tomorrow)
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// id: 'c0a8323d-9241-4896-9e64-01e905c24e51',
|
|
||||||
// createdAt: new Date(tomorrow)
|
|
||||||
// }
|
|
||||||
// ];
|
|
||||||
|
|
||||||
|
|
||||||
const backupsRate = await db
|
const backupsRate = await db
|
||||||
.select({
|
.select({
|
||||||
createdAt: drizzleDb.schemas.backup.createdAt,
|
createdAt: drizzleDb.schemas.backup.createdAt,
|
||||||
@@ -130,7 +62,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
.orderBy(drizzleDb.schemas.backup.createdAt);
|
.orderBy(drizzleDb.schemas.backup.createdAt);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const restorationsCountResult = await db
|
const restorationsCountResult = await db
|
||||||
.select({
|
.select({
|
||||||
count: count(),
|
count: count(),
|
||||||
@@ -139,60 +70,84 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
|
.where(inArray(drizzleDb.schemas.restoration.databaseId, databaseIds));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
|
const restorationsCount = restorationsCountResult[0]?.count ?? 0;
|
||||||
const projectsCount = projects.length;
|
const projectsCount = projects.length;
|
||||||
const backupsEvolutionCount = backupsEvolution.length;
|
const backupsEvolutionCount = backupsEvolution.length;
|
||||||
|
|
||||||
|
|
||||||
const sortedBackupsEvolution = backupsEvolution.sort(
|
const sortedBackupsEvolution = backupsEvolution.sort(
|
||||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const Placeholder = ({text}: { text: string }) => (
|
||||||
|
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">{text}</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader>
|
<PageHeader>
|
||||||
<PageTitle>Statistics</PageTitle>
|
<PageTitle>Statistics Overview</PageTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<PageContent className="flex flex-col gap-y-4">
|
<PageContent className="flex flex-col gap-y-4">
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<Card className="w-full flex-1">
|
<Card className="w-full">
|
||||||
<CardHeader className="flex items-center gap-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<Folder className="w-5 h-5 text-muted-foreground" />
|
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||||
<CardTitle>Projects</CardTitle>
|
<Building2 className="h-4 w-4 text-muted-foreground"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-3xl font-bold">{projectsCount}</CardContent>
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{projectsCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Active projects in this organization</p>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="w-full flex-1">
|
|
||||||
<CardHeader className="flex items-center gap-2">
|
<Card className="w-full">
|
||||||
<DatabaseBackup className="w-5 h-5 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle>Backups</CardTitle>
|
<CardTitle className="text-sm font-medium">Backups</CardTitle>
|
||||||
|
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-3xl font-bold">{backupsEvolutionCount}</CardContent>
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{backupsEvolutionCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total backups executed across all databases</p>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="w-full flex-1">
|
|
||||||
<CardHeader className="flex items-center gap-2">
|
<Card className="w-full">
|
||||||
<RefreshCcw className="w-5 h-5 text-muted-foreground" />
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle>Restorations</CardTitle>
|
<CardTitle className="text-sm font-medium">Restorations</CardTitle>
|
||||||
|
<RefreshCcw className="h-4 w-4 text-muted-foreground"/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="text-3xl font-bold">{restorationsCount}</CardContent>
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{restorationsCount}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Total restoration operations performed</p>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
<div className="flex flex-col md:flex-row gap-4">
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Evolution of the number of backups</CardTitle>
|
<CardTitle>Evolution of the number of backups</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{sortedBackupsEvolution.length > 0 ? (
|
||||||
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
<EvolutionLineChart data={sortedBackupsEvolution}/>
|
||||||
|
) : (
|
||||||
|
<Placeholder text="No backup data available"/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Success rate of backups</CardTitle>
|
<CardTitle>Success rate of backups</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{backupsRate.length > 0 ? (
|
||||||
<PercentageLineChart data={backupsRate}/>
|
<PercentageLineChart data={backupsRate}/>
|
||||||
|
) : (
|
||||||
|
<Placeholder text="No backup rate data available"/>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {db} from "@/db";
|
|||||||
import {asc, inArray} from "drizzle-orm";
|
import {asc, inArray} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {listOrganizations} from "@/lib/auth/auth";
|
import {listOrganizations} from "@/lib/auth/auth";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Home",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {ButtonDeleteAccount} from "@/components/wrappers/dashboard/profile/butto
|
|||||||
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
import {AvatarWithUpload} from "@/components/wrappers/dashboard/profile/avatar/avatar-with-upload";
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
import {currentUser} from "@/lib/auth/current-user";
|
||||||
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
import {getAccounts, getSessions} from "@/lib/auth/auth";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Profile",
|
||||||
|
};
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
@@ -39,11 +44,8 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
{user.name}
|
{user.name}
|
||||||
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
|
<Badge className="ml-3 hidden lg:block">{user.role}</Badge>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
{/*<PageActions className="mt-2 hidden sm:block">*/}
|
|
||||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
|
||||||
{/*</PageActions>*/}
|
|
||||||
</div>
|
</div>
|
||||||
<PageContent >
|
<PageContent>
|
||||||
<UserForm
|
<UserForm
|
||||||
userId={user.id}
|
userId={user.id}
|
||||||
sessions={sessions} accounts={accounts}
|
sessions={sessions} accounts={accounts}
|
||||||
@@ -53,9 +55,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
role: user.role ?? undefined,
|
role: user.role ?? undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/*<div className="mt-4 sm:hidden ">*/}
|
|
||||||
{/* <ButtonDeleteAccount text="Delete my account"/>*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,2 +1,51 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import forge from "node-forge";
|
||||||
|
|
||||||
|
|
||||||
|
export async function decryptedDump(file: File, aesKeyHex: string, ivHex: string, fileExtension: string ): Promise<File> {
|
||||||
|
const privateKeyPem = fs.readFileSync("private/keys/server_private.pem", "utf8");
|
||||||
|
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
|
||||||
|
// Decrypt AES key with RSA-OAEP
|
||||||
|
const encryptedAesKey = forge.util.hexToBytes(aesKeyHex);
|
||||||
|
const aesKey = privateKey.decrypt(encryptedAesKey, "RSA-OAEP", {
|
||||||
|
md: forge.md.sha256.create(),
|
||||||
|
mgf1: {md: forge.md.sha256.create()},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read encrypted file content
|
||||||
|
const encryptedBuffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const iv = forge.util.hexToBytes(ivHex);
|
||||||
|
|
||||||
|
// AES decryption
|
||||||
|
const decipher = forge.cipher.createDecipher("AES-CBC", aesKey);
|
||||||
|
decipher.start({iv});
|
||||||
|
decipher.update(forge.util.createBuffer(encryptedBuffer.toString("binary")));
|
||||||
|
const success = decipher.finish();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error("Decryption failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedBytes = decipher.output.getBytes();
|
||||||
|
const decryptedBuffer = Buffer.from(decryptedBytes, "binary");
|
||||||
|
|
||||||
|
// Return a File so you can use file.arrayBuffer() later
|
||||||
|
return new File(
|
||||||
|
[decryptedBuffer],
|
||||||
|
file.name.replace(/\.enc$/, fileExtension),
|
||||||
|
{type: "application/octet-stream"}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getFileExtension(dbType: string) {
|
||||||
|
switch (dbType) {
|
||||||
|
case "postgresql":
|
||||||
|
return ".dump";
|
||||||
|
case "mysql":
|
||||||
|
return ".sql";
|
||||||
|
default:
|
||||||
|
return ".dump";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import {isUuidv4} from "@/utils/verify-uuid";
|
|||||||
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
import {uploadLocalPrivate, uploadS3Private} from "@/features/upload/private/upload.action";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {eventEmitter} from "../../../events/route";
|
import {eventEmitter} from "../../../events/route";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {Backup} from "@/db/schema/07_database";
|
import {Backup} from "@/db/schema/07_database";
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import {env} from "@/env.mjs";
|
import {env} from "@/env.mjs";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {decryptedDump, getFileExtension} from "./helpers";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -27,6 +28,8 @@ export async function POST(
|
|||||||
|
|
||||||
const agentId = (await params).agentId;
|
const agentId = (await params).agentId;
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const aesKeyHex = formData.get("aes_key") as string;
|
||||||
|
const ivHex = formData.get("iv") as string;
|
||||||
const generatedId = formData.get("generatedId") as string | null;
|
const generatedId = formData.get("generatedId") as string | null;
|
||||||
const method = formData.get("method") as string | null;
|
const method = formData.get("method") as string | null;
|
||||||
|
|
||||||
@@ -102,16 +105,22 @@ export async function POST(
|
|||||||
if (status === "success") {
|
if (status === "success") {
|
||||||
const file = formData.get("file") as File | null;
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
if (!aesKeyHex || !ivHex) {
|
||||||
|
return NextResponse.json({error: "Missing fields"}, {status: 400});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "File is required for successful backup"},
|
{error: "File is required for successful backup"},
|
||||||
{status: 400}
|
{status: 400}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const fileExtension = getFileExtension(database.dbms)
|
||||||
|
const decryptedFile = await decryptedDump(file, aesKeyHex, ivHex, fileExtension);
|
||||||
const uuid = uuidv4();
|
const uuid = uuidv4();
|
||||||
const fileName = `${uuid}.dump`;
|
const fileName = `${uuid}${fileExtension}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||||
|
|
||||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
@@ -174,3 +183,4 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import * as drizzleDb from "@/db";
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
|
|
||||||
|
|
||||||
export type BodyResultRestore = {
|
export type BodyResultRestore = {
|
||||||
generatedId: string
|
generatedId: string
|
||||||
status: string
|
status: string
|
||||||
@@ -13,7 +12,6 @@ export type BodyResultRestore = {
|
|||||||
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
{params}: { params: Promise<{ agentId: string }> }
|
{params}: { params: Promise<{ agentId: string }> }
|
||||||
@@ -27,7 +25,6 @@ export async function POST(
|
|||||||
|
|
||||||
console.log(body)
|
console.log(body)
|
||||||
|
|
||||||
|
|
||||||
if (!isUuidv4(body.generatedId)) {
|
if (!isUuidv4(body.generatedId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "generatedId is not a valid uuid"},
|
{error: "generatedId is not a valid uuid"},
|
||||||
@@ -71,10 +68,7 @@ export async function POST(
|
|||||||
|
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
|
||||||
return Response.json(response, {status: 200})
|
return Response.json(response, {status: 200})
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in POST handler:', error);
|
console.error('Error in POST handler:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -124,13 +124,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
try {
|
try {
|
||||||
|
|
||||||
if (settings.storage == "local") {
|
if (settings.storage == "local") {
|
||||||
data = await getFileUrlPresignedLocal(fileName!)
|
data = await getFileUrlPresignedLocal({fileName: fileName!})
|
||||||
} else if (settings.storage == "s3") {
|
} else if (settings.storage == "s3") {
|
||||||
|
|
||||||
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (data?.data?.success) {
|
if (data?.data?.success) {
|
||||||
urlBackup = data.data.value ?? "";
|
urlBackup = data.data.value ?? "";
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type Body = {
|
|||||||
|
|
||||||
// Function to test the get file url presigned local
|
// Function to test the get file url presigned local
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const url = await getFileUrlPresignedLocal("d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump")
|
const url = await getFileUrlPresignedLocal({fileName:"d4a7fa35-2506-4d01-a612-a8ef2e2cc1c5.dump"})
|
||||||
return Response.json({
|
return Response.json({
|
||||||
message: url
|
message: url
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
PROJECT_URL: process.env.PROJECT_URL,
|
||||||
|
PROJECT_NAME: process.env.PROJECT_NAME,
|
||||||
|
PROJECT_DESCRIPTION: process.env.PROJECT_DESCRIPTION,
|
||||||
|
});
|
||||||
|
}
|
||||||
+22
-12
@@ -1,9 +1,19 @@
|
|||||||
import { EventEmitter } from 'events';
|
import {EventEmitter} from 'events';
|
||||||
|
import {auth} from "@/lib/auth/auth";
|
||||||
|
import {headers} from "next/headers";
|
||||||
|
import {NextResponse} from "next/server";
|
||||||
|
|
||||||
export const eventEmitter = new EventEmitter();
|
export const eventEmitter = new EventEmitter();
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
console.log('GET request received');
|
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
new ReadableStream({
|
new ReadableStream({
|
||||||
@@ -37,13 +47,13 @@ export async function GET(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
// export async function POST(request: Request) {
|
||||||
console.log('POST request received');
|
// console.log('POST request received');
|
||||||
const data = await request.json();
|
// const data = await request.json();
|
||||||
console.log('Data received:', data);
|
// console.log('Data received:', data);
|
||||||
|
//
|
||||||
// Emit the event to all connected clients
|
// // Emit the event to all connected clients
|
||||||
eventEmitter.emit('modification', data);
|
// eventEmitter.emit('modification', data);
|
||||||
|
//
|
||||||
return new Response('Event sent', { status: 200 });
|
// return new Response('Event sent', {status: 200});
|
||||||
}
|
// }
|
||||||
@@ -12,16 +12,16 @@ export async function GET(
|
|||||||
const expires = searchParams.get('expires');
|
const expires = searchParams.get('expires');
|
||||||
const fileName = (await params).fileName
|
const fileName = (await params).fileName
|
||||||
|
|
||||||
const privateLocalDir = "private/uploads/files/";
|
const uploadsDir = "private/uploads/files/";
|
||||||
const filePath = path.join(privateLocalDir, fileName);
|
const uploadPath = path.join(uploadsDir, fileName);
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
let filePath = null;
|
||||||
return NextResponse.json(
|
if (fs.existsSync(uploadPath)) {
|
||||||
{error: 'File not found'},
|
filePath = uploadPath;
|
||||||
{status: 404}
|
} else {
|
||||||
);
|
return NextResponse.json({error: "File not found"}, {status: 404})
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
const expectedToken = crypto.createHash('sha256').update(`${fileName}${expires}`).digest('hex');
|
||||||
@@ -31,8 +31,8 @@ export async function GET(
|
|||||||
{status: 403}
|
{status: 403}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
//@ts-ignore
|
|
||||||
const expiresAt = parseInt(expires, 10);
|
const expiresAt = parseInt(expires!, 10);
|
||||||
if (Date.now() > expiresAt) {
|
if (Date.now() > expiresAt) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: 'Signed token expired'},
|
{error: 'Signed token expired'},
|
||||||
|
|||||||
@@ -1,38 +1,103 @@
|
|||||||
|
import {NextResponse} from "next/server";
|
||||||
|
import {auth} from "@/lib/auth/auth";
|
||||||
|
import {headers} from "next/headers";
|
||||||
|
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
import * as stream from "node:stream";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET(
|
function nodeStreamToWebStream(nodeStream: stream.Readable) {
|
||||||
request: Request,
|
return new ReadableStream({
|
||||||
{params}: { params: Promise<{ fileName: string }> }
|
start(controller) {
|
||||||
) {
|
nodeStream.on("data", chunk => controller.enqueue(chunk));
|
||||||
|
nodeStream.on("end", () => controller.close());
|
||||||
try {
|
nodeStream.on("error", err => controller.error(err));
|
||||||
const fileName = (await params).fileName;
|
},
|
||||||
|
cancel() {
|
||||||
|
nodeStream.destroy();
|
||||||
console.log("fileName", fileName);
|
|
||||||
|
|
||||||
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
|
||||||
|
|
||||||
// Check if the file exists
|
|
||||||
try {
|
|
||||||
await fs.access(filePath); // Ensures the file exists
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the file
|
|
||||||
const fileContent = await fs.readFile(filePath); // Returns a Buffer
|
|
||||||
|
|
||||||
return new NextResponse(fileContent, {
|
|
||||||
headers: {
|
|
||||||
"Content-Disposition": `attachment; filename="${fileName}"`,
|
|
||||||
"Content-Type": "application/octet-stream", // Adjust MIME type as needed
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
}
|
||||||
console.error("Error reading file:", error);
|
|
||||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
const privateS3ImageDir = "images/";
|
||||||
|
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: Request,
|
||||||
|
{params}: { params: Promise<{ fileName: string }> }
|
||||||
|
) {
|
||||||
|
const fileName = (await params).fileName;
|
||||||
|
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({headers: await headers()});
|
||||||
|
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||||
|
|
||||||
|
const [settings] = await db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.setting)
|
||||||
|
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!settings) throw new Error("System settings not found.");
|
||||||
|
|
||||||
|
const storageType = settings.storage; // "local" or "s3"
|
||||||
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
ext === "png"
|
||||||
|
? "image/png"
|
||||||
|
: ext === "jpg" || ext === "jpeg"
|
||||||
|
? "image/jpeg"
|
||||||
|
: ext === "gif"
|
||||||
|
? "image/gif"
|
||||||
|
: ext === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "application/octet-stream";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (storageType === "local") {
|
||||||
|
const filePath = path.join(process.cwd(), "private/uploads/images", fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
const file = await fs.readFile(filePath);
|
||||||
|
|
||||||
|
return new NextResponse(file, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// if not found locally, fallback to S3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exists = await checkFileExistsInBucket({
|
||||||
|
bucketName: env.S3_BUCKET_NAME!,
|
||||||
|
fileName: `${privateS3ImageDir}${fileName}`,
|
||||||
|
});
|
||||||
|
if (!exists) return NextResponse.json({error: "File not found"}, {status: 404});
|
||||||
|
|
||||||
|
const nodeStream = await getObjectFromClient({
|
||||||
|
bucketName: env.S3_BUCKET_NAME!,
|
||||||
|
fileName: `${privateS3ImageDir}${fileName}`,
|
||||||
|
});
|
||||||
|
const webStream = nodeStreamToWebStream(nodeStream);
|
||||||
|
|
||||||
|
return new NextResponse(webStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Content-Disposition": `inline; filename="${fileName}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error streaming image:", err);
|
||||||
|
return NextResponse.json({error: "Error fetching file"}, {status: 500});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export type BodyInit = {
|
|
||||||
initialize: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body: BodyInit = await request.json();
|
|
||||||
|
|
||||||
console.log(body);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
message: "Initialization successfully done!",
|
|
||||||
},
|
|
||||||
{ status: 200 }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in POST initialization:", error);
|
|
||||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-4
@@ -1,16 +1,19 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import type {Metadata} from "next";
|
import type {Metadata} from "next";
|
||||||
import {Inter} from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import {Providers} from "./providers";
|
import {Providers} from "./providers";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
import {ConsoleSilencer} from "@/components/wrappers/common/console-silencer";
|
||||||
|
import {inter} from "@/fonts/fonts";
|
||||||
|
|
||||||
const inter = Inter({subsets: ["latin"]});
|
const title = process.env.PROJECT_NAME ?? "App Title";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: process.env.NEXT_PUBLIC_PROJECT_NAME ?? "App Title",
|
title: {
|
||||||
description: process.env.NEXT_PUBLIC_PROJECT_DESCRIPTION ?? undefined,
|
default: title,
|
||||||
|
template: `%s - ${title}`
|
||||||
|
},
|
||||||
|
description: process.env.PROJECT_DESCRIPTION ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BackButton from "@/components/wrappers/common/button/back-button";
|
||||||
|
|
||||||
export default async function NotFound() {
|
export default async function NotFound() {
|
||||||
return(
|
return(
|
||||||
@@ -7,6 +8,7 @@ export default async function NotFound() {
|
|||||||
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">Not found</h1>
|
||||||
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
<p className="leading-7 [&:not(:first-child)]:mt-6">The content you are trying to view is not available.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<BackButton>Go home</BackButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-20
@@ -3,22 +3,20 @@ name: portabase-prod
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: docker/dockerfile/Dockerfile
|
# dockerfile: docker/dockerfile/Dockerfile
|
||||||
target: prod
|
# target: prod
|
||||||
|
image: solucetechnologies/portabase:1.1.3-rc.3
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
container_name: portabase-app-prod
|
container_name: portabase-app-prod
|
||||||
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
ports:
|
ports:
|
||||||
@@ -35,20 +33,19 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
# s3:
|
||||||
s3:
|
# image: docker.io/bitnami/minio:latest
|
||||||
image: docker.io/bitnami/minio:latest
|
# ports:
|
||||||
ports:
|
# - '9000:9000'
|
||||||
- '9000:9000'
|
# - '9001:9001'
|
||||||
- '9001:9001'
|
# volumes:
|
||||||
volumes:
|
# - minio_data:/data
|
||||||
- minio_data:/data
|
# environment:
|
||||||
environment:
|
# - MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
||||||
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
# - MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
||||||
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
# - MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
||||||
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
minio_data:
|
# minio_data:
|
||||||
|
|||||||
@@ -1,25 +1,6 @@
|
|||||||
name: portabase-dev
|
name: portabase-dev
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# app:
|
|
||||||
# build:
|
|
||||||
# context: .
|
|
||||||
# dockerfile: docker/dockerfile/Dockerfile
|
|
||||||
# target: dev
|
|
||||||
# ports:
|
|
||||||
# - "8887:8887"
|
|
||||||
# environment:
|
|
||||||
# - TIME_ZONE="Europe/Paris"
|
|
||||||
# - NODE_ENV=development
|
|
||||||
# depends_on:
|
|
||||||
# db:
|
|
||||||
# condition: service_healthy
|
|
||||||
# volumes:
|
|
||||||
# - .:/app
|
|
||||||
# - /app/node_modules
|
|
||||||
# container_name: portabase-app
|
|
||||||
#
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
ports:
|
ports:
|
||||||
@@ -36,38 +17,5 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# db-pgadmin:
|
|
||||||
# image: dpage/pgadmin4
|
|
||||||
# environment:
|
|
||||||
# PGADMIN_DEFAULT_EMAIL: "devuser@devuser.devuser"
|
|
||||||
# PGADMIN_DEFAULT_PASSWORD: "changeme"
|
|
||||||
# PGADMIN_CONFIG_SERVER_MODE: "False"
|
|
||||||
# POSTGRES_USER: "devuser"
|
|
||||||
# POSTGRES_PASSWORD: "changeme"
|
|
||||||
# volumes:
|
|
||||||
# - pgadmin-data:/var/lib/pgadmin
|
|
||||||
# ports:
|
|
||||||
# - "8080:80"
|
|
||||||
# restart: unless-stopped
|
|
||||||
# depends_on:
|
|
||||||
# - db
|
|
||||||
|
|
||||||
s3:
|
|
||||||
container_name: s3-portabase-dev
|
|
||||||
image: docker.io/bitnami/minio:latest
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9001:9001"
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- MINIO_ROOT_USER=${S3_ACCESS_KEY}
|
|
||||||
- MINIO_ROOT_PASSWORD=${S3_SECRET_KEY}
|
|
||||||
- MINIO_DEFAULT_BUCKETS=${S3_BUCKET_NAME}
|
|
||||||
- MINIO_BROWSER=on
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
minio_data:
|
|
||||||
# pgadmin-data:
|
|
||||||
|
|||||||
@@ -94,9 +94,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
COPY --chown=nextjs:nodejs src/db ./src/db
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
COPY ./docker/entrypoints/app-prod-entrypoint.sh /app/app-prod-entrypoint.sh
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
|
||||||
#npx drizzle-kit generate
|
|
||||||
#npx drizzle-kit migrate
|
|
||||||
#
|
|
||||||
#npm run dev
|
|
||||||
#
|
|
||||||
#exec "$@"
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "▶ Running Drizzle codegen..."
|
echo "▶ Running Drizzle codegen..."
|
||||||
|
|||||||
@@ -1,14 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
#echo " ____ __ __ "
|
|
||||||
#echo " / __ \____ _____/ /_____ _/ /_ ____ _________ "
|
|
||||||
#echo " / /_/ / __ \/ ___/ __/ __ / __ \/ __ / ___/ _ \ "
|
|
||||||
#echo " / ____/ /_/ / / / /_/ /_/ / /_/ / /_/ (__ ) __/ "
|
|
||||||
#echo " /_/ \____/_/ \__/\__,_/_.___/\__,_/____/\___/ "
|
|
||||||
#echo " "
|
|
||||||
#echo " Community Edition v1.1.1 "
|
|
||||||
#echo " "
|
|
||||||
|
|
||||||
node server.js
|
node server.js
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {defineConfig, globalIgnores} from 'eslint/config'
|
||||||
|
import nextVitals from 'eslint-config-next/core-web-vitals'
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
'.next/**',
|
||||||
|
'out/**',
|
||||||
|
'build/**',
|
||||||
|
'next-env.d.ts',
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
|
||||||
|
export default eslintConfig
|
||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference path="./.next/types/routes.d.ts" />
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ const nextConfig: NextConfig = {
|
|||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -65,16 +65,17 @@
|
|||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.510.0",
|
"lucide-react": "^0.510.0",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"next": "15.5.2",
|
"next": "16.0.0",
|
||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
|
"node-forge": "^1.3.1",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"npm-check-updates": "^18.0.1",
|
"npm-check-updates": "^18.0.1",
|
||||||
"pg": "^8.16.0",
|
"pg": "^8.16.0",
|
||||||
"react": "19.1.0",
|
"react": "^19.2.0",
|
||||||
"react-day-picker": "9.7.0",
|
"react-day-picker": "9.7.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-email": "^4.0.13",
|
"react-email": "^4.0.13",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.56.3",
|
||||||
@@ -95,14 +96,15 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.7",
|
"@tailwindcss/postcss": "^4.1.7",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||||
"@types/node": "^22.15.18",
|
"@types/node": "^22.15.18",
|
||||||
|
"@types/node-forge": "^1",
|
||||||
"@types/pg": "^8.15.2",
|
"@types/pg": "^8.15.2",
|
||||||
"@types/react": "^19.1.4",
|
"@types/react": "^19.1.4",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"@zenstackhq/openapi": "^2.14.2",
|
"@zenstackhq/openapi": "^2.14.2",
|
||||||
"@zenstackhq/tanstack-query": "^2.14.2",
|
"@zenstackhq/tanstack-query": "^2.14.2",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "^0.31.1",
|
||||||
"eslint": "^9.26.0",
|
"eslint": "^9.39.0",
|
||||||
"eslint-config-next": "15.3.2",
|
"eslint-config-next": "^16.0.1",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
|
|||||||
+14
-28
@@ -1,11 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import {NextRequest, NextResponse} from "next/server";
|
||||||
import { loggingMiddleware } from "@/middleware/loggingMiddleware";
|
import {loggingMiddleware} from "@/middleware/loggingMiddleware";
|
||||||
import { errorHandler } from "@/middleware/errorHandler";
|
import {errorHandler} from "@/middleware/errorHandler";
|
||||||
import { auth } from "@/lib/auth/auth";
|
import {auth} from "@/lib/auth/auth";
|
||||||
import { headers } from "next/headers";
|
import {headers} from "next/headers";
|
||||||
import { signOut } from "@/lib/auth/auth-client";
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
const redirectUrl = encodeURIComponent(request.nextUrl.pathname)
|
||||||
|
|
||||||
@@ -13,40 +12,33 @@ export async function middleware(request: NextRequest) {
|
|||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers(),
|
headers: await headers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
return NextResponse.redirect(new URL(`/login?redirect=${redirectUrl}`, request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.banned) {
|
if (session.user.banned) {
|
||||||
signOut();
|
await auth.api.signOut({headers: await headers()});
|
||||||
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
return NextResponse.redirect(new URL("/login?error=banned", request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.user.role === "pending") {
|
if (session.user.role === "pending") {
|
||||||
signOut();
|
await auth.api.signOut({headers: await headers()});
|
||||||
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
return NextResponse.redirect(new URL(`/login?error=pending?redirect=${redirectUrl}`, request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/dashboard") {
|
if (url.pathname === "/dashboard") {
|
||||||
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
return NextResponse.redirect(new URL(`/dashboard/home`, request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exclude `/api/auth` and its subpaths
|
|
||||||
if (url.pathname.startsWith("/api/auth")) {
|
if (url.pathname.startsWith("/api/auth")) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith("/api")) {
|
if (url.pathname.startsWith("/api")) {
|
||||||
const routeExists = checkRouteExists(url.pathname);
|
const routeExists = checkRouteExists(url.pathname);
|
||||||
// If the route does not exist, return a 404 JSON response
|
|
||||||
if (!routeExists) {
|
if (!routeExists) {
|
||||||
return new NextResponse(JSON.stringify({ message: "This API route does not exist.", status: 404 }), {
|
return new NextResponse(JSON.stringify({message: "This API route does not exist.", status: 404}), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {"Content-Type": "application/json"},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,31 +48,25 @@ export async function middleware(request: NextRequest) {
|
|||||||
errorHandler(err);
|
errorHandler(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Function to check if the route exists (supports dynamic routes)
|
|
||||||
function checkRouteExists(pathname: string) {
|
function checkRouteExists(pathname: string) {
|
||||||
// Define static and dynamic routes with patterns
|
|
||||||
const routePatterns = [
|
const routePatterns = [
|
||||||
//do not delete
|
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||||
// /^\/api\/auth\/\d+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
|
|
||||||
// /^\/api\/auth\/\w+$/, // Dynamic route with a number as a parameter (e.g., /api/dynamic/123)
|
|
||||||
// /^\/api\/agent\/healthcheck\/\w+$/, // Dynamic route with an alphanumeric parameter (e.g., /api/user/username)
|
|
||||||
/^\/api\/agent\/[^/]+\/status\/?$/, // Dynamic route for /api/agent/[id]/status
|
|
||||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||||
/^\/api\/files\/[^/]+\/?$/,
|
/^\/api\/files\/[^/]+\/?$/,
|
||||||
/^\/api\/images\/[^/]+\/?$/,
|
/^\/api\/images\/[^/]+\/?$/,
|
||||||
/^\/api\/events\/?$/,
|
/^\/api\/events\/?$/,
|
||||||
/^\/api\/init\/?$/,
|
/^\/api\/init\/?$/,
|
||||||
|
/^\/api\/config\/?$/,
|
||||||
];
|
];
|
||||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
runtime: "nodejs",
|
|
||||||
matcher: [
|
matcher: [
|
||||||
// '/api/agent/:path*',
|
|
||||||
"/api/:path*",
|
"/api/:path*",
|
||||||
"/dashboard/:path*",
|
"/dashboard/:path*",
|
||||||
"/dashboard",
|
"/dashboard",
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
@@ -0,0 +1,191 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type React from "react"
|
||||||
|
import { useState, useRef, useEffect, forwardRef } from "react"
|
||||||
|
import { Search, X } from "lucide-react"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface IEntry {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchInputProps {
|
||||||
|
value?: IEntry
|
||||||
|
onChange?: (value: IEntry) => void
|
||||||
|
onSelect?: (value: IEntry) => void
|
||||||
|
name?: string
|
||||||
|
placeholder?: string
|
||||||
|
entries?: IEntry[]
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
value: controlledValue,
|
||||||
|
onChange,
|
||||||
|
onSelect,
|
||||||
|
name,
|
||||||
|
placeholder = "Search entries...",
|
||||||
|
entries = [],
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [internalValue, setInternalValue] = useState<IEntry | null>(null)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [filteredEntries, setFilteredEntries] = useState<IEntry[]>([])
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
|
const internalRef = useRef<HTMLInputElement>(null)
|
||||||
|
const listRef = useRef<HTMLUListElement>(null)
|
||||||
|
|
||||||
|
const query = controlledValue?.label ?? internalValue?.label ?? ""
|
||||||
|
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
|
||||||
|
|
||||||
|
// Filter entries based on query
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim()) {
|
||||||
|
const filtered = entries.filter((entry) =>
|
||||||
|
entry.label.toLowerCase().includes(query.toLowerCase()),
|
||||||
|
)
|
||||||
|
setFilteredEntries(filtered)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
} else {
|
||||||
|
setFilteredEntries([])
|
||||||
|
}
|
||||||
|
}, [query, entries])
|
||||||
|
|
||||||
|
const handleValueChange = (newValue: IEntry | null) => {
|
||||||
|
if (controlledValue === undefined) {
|
||||||
|
setInternalValue(newValue)
|
||||||
|
}
|
||||||
|
if (newValue) {
|
||||||
|
onChange?.(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || filteredEntries.length === 0) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev < filteredEntries.length - 1 ? prev + 1 : 0,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : filteredEntries.length - 1,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "Enter":
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
handleSelect(filteredEntries[selectedIndex])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case "Escape":
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelect = (entry: IEntry) => {
|
||||||
|
handleValueChange(entry)
|
||||||
|
onSelect?.(entry)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
inputRef.current?.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
handleValueChange(null)
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative w-full", className)}>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
{...props}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
name={name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newLabel = e.target.value
|
||||||
|
handleValueChange({ value: newLabel, label: newLabel })
|
||||||
|
}}
|
||||||
|
onFocus={() => !disabled && setIsOpen(true)}
|
||||||
|
onBlur={() => {
|
||||||
|
// Delay closing to allow clicking on entries
|
||||||
|
setTimeout(() => setIsOpen(false), 150)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{query && !disabled && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 hover:bg-muted"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results dropdown */}
|
||||||
|
{isOpen && !disabled && filteredEntries.length > 0 && (
|
||||||
|
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
|
||||||
|
<ul ref={listRef} className="max-h-60 overflow-auto py-1" role="listbox">
|
||||||
|
{filteredEntries.map((entry, index) => (
|
||||||
|
<li
|
||||||
|
key={entry.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === selectedIndex}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2 text-sm cursor-pointer transition-colors",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
index === selectedIndex && "bg-accent text-accent-foreground",
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(entry)}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results message */}
|
||||||
|
{isOpen && !disabled && query && filteredEntries.length === 0 && (
|
||||||
|
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
No results found for "{query}"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
SearchInput.displayName = "SearchInput"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {useTheme} from "next-themes";
|
||||||
|
|
||||||
|
|
||||||
|
export const AuthLogoSection = () => {
|
||||||
|
|
||||||
|
const {resolvedTheme} = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
|
||||||
|
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md flex items-center justify-center space-x-2">
|
||||||
|
<img
|
||||||
|
className="p-12 text-black dark:text-white"
|
||||||
|
src={imageTheme}
|
||||||
|
alt="Logo"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground -ml-12 -mb-12">v{env.NEXT_PUBLIC_PROJECT_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { Button } from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import { signIn } from "@/lib/auth/auth-client";
|
import {signIn} from "@/lib/auth/auth-client";
|
||||||
import { JSX } from "react";
|
import {JSX} from "react";
|
||||||
|
|
||||||
export type AuthButtonProps = {
|
export type AuthButtonProps = {
|
||||||
providers: SocialProviderType[];
|
providers: SocialProviderType[];
|
||||||
|
callBackURL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SocialProviderType = {
|
export type SocialProviderType = {
|
||||||
@@ -41,7 +42,7 @@ export const SocialAuthButton = (props: AuthButtonProps): JSX.Element => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void signIn.social({
|
void signIn.social({
|
||||||
provider: provider.id,
|
provider: provider.id,
|
||||||
callbackURL: "/dashboard/profile",
|
callbackURL: props.callBackURL ?? "/dashboard/profile",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,23 +1,172 @@
|
|||||||
|
// "use client";
|
||||||
|
//
|
||||||
|
// import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||||
|
// import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
|
// import {Input} from "@/components/ui/input";
|
||||||
|
// import {Form} from "@/components/ui/form";
|
||||||
|
// import {Button} from "@/components/ui/button";
|
||||||
|
// import {toast} from "sonner";
|
||||||
|
// import {useMutation} from "@tanstack/react-query";
|
||||||
|
// import {TooltipProvider} from "@/components/ui/tooltip";
|
||||||
|
// import Link from "next/link";
|
||||||
|
// import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
|
// import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||||
|
// import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||||
|
// import {signIn} from "@/lib/auth/auth-client";
|
||||||
|
// import {useRouter} from "next/navigation";
|
||||||
|
// import {Icon} from "@iconify/react";
|
||||||
|
// import {useEffect, useState} from "react";
|
||||||
|
//
|
||||||
|
// export type loginFormProps = {
|
||||||
|
// defaultValues?: LoginType;
|
||||||
|
// authGoogleEnabled: boolean;
|
||||||
|
//
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// export const LoginForm = (props: loginFormProps) => {
|
||||||
|
// const router = useRouter();
|
||||||
|
//
|
||||||
|
// const form = useZodForm({
|
||||||
|
// schema: LoginSchema,
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const [urlParams, setUrlParams] = useState<URLSearchParams>();
|
||||||
|
//
|
||||||
|
// useEffect(() => {
|
||||||
|
// const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
// console.log(urlParams);
|
||||||
|
// setUrlParams(urlParams);
|
||||||
|
// const error = urlParams.get("error");
|
||||||
|
// console.log(urlParams.get("redirect"));
|
||||||
|
// if (error?.includes("pending")) {
|
||||||
|
// toast.error("Your account is not active.");
|
||||||
|
// urlParams.delete("error");
|
||||||
|
// window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||||
|
// }
|
||||||
|
// }, []);
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// const mutation = useMutation({
|
||||||
|
// mutationFn: async (values: LoginType) => {
|
||||||
|
// const {error} = await signIn.email(
|
||||||
|
// {
|
||||||
|
// password: values.password,
|
||||||
|
// email: values.email,
|
||||||
|
// callbackURL: urlParams?.get("redirect") ?? "/dashboard/profile",
|
||||||
|
// }, {
|
||||||
|
// onSuccess: () => {
|
||||||
|
// toast.success("Login success");
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// if (error) {
|
||||||
|
// toast.error(error.message);
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const availableProviders: SocialProviderType[] = [];
|
||||||
|
//
|
||||||
|
// if (props.authGoogleEnabled) {
|
||||||
|
// availableProviders.push(
|
||||||
|
// {
|
||||||
|
// id: "google",
|
||||||
|
// name: "Google",
|
||||||
|
// icon: <Icon icon={"logos:google-icon"} width="25" height="25"/>,
|
||||||
|
// },
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// return (
|
||||||
|
// <TooltipProvider>
|
||||||
|
// <Card>
|
||||||
|
// <CardHeader>
|
||||||
|
// <div className="grid gap-2 text-center mb-2">
|
||||||
|
// <h1 className="text-3xl font-bold">Login</h1>
|
||||||
|
// <p className="text-balance text-muted-foreground">Enter your informations below to login</p>
|
||||||
|
// </div>
|
||||||
|
// </CardHeader>
|
||||||
|
// <CardContent>
|
||||||
|
// <Form
|
||||||
|
// form={form}
|
||||||
|
// className="flex flex-col gap-4"
|
||||||
|
// onSubmit={async (values) => {
|
||||||
|
// await mutation.mutateAsync(values);
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// <FormField
|
||||||
|
// control={form.control}
|
||||||
|
// name="email"
|
||||||
|
// defaultValue=""
|
||||||
|
// render={({field}) => (
|
||||||
|
// <FormItem>
|
||||||
|
// <FormLabel>Email</FormLabel>
|
||||||
|
// <FormControl>
|
||||||
|
// <Input autoComplete="email webauthn"
|
||||||
|
// placeholder="exemple@portabase.io" {...field} />
|
||||||
|
// </FormControl>
|
||||||
|
// <FormMessage/>
|
||||||
|
// </FormItem>
|
||||||
|
// )}
|
||||||
|
// />
|
||||||
|
// <FormField
|
||||||
|
// control={form.control}
|
||||||
|
// name="password"
|
||||||
|
// defaultValue=""
|
||||||
|
// render={({field}) => (
|
||||||
|
// <FormItem>
|
||||||
|
// <div className="flex items-center">
|
||||||
|
// <FormLabel>Password</FormLabel>
|
||||||
|
// {/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline">
|
||||||
|
// Forgot your password?
|
||||||
|
// </Link>*/}
|
||||||
|
// </div>
|
||||||
|
// <FormControl>
|
||||||
|
// <PasswordInput autoComplete="current-password webauthn"
|
||||||
|
// placeholder="Your password" {...field} />
|
||||||
|
// </FormControl>
|
||||||
|
// <FormMessage/>
|
||||||
|
// </FormItem>
|
||||||
|
// )}
|
||||||
|
// />
|
||||||
|
// <Button>Sign in</Button>
|
||||||
|
// <div className="mt-4 text-center text-sm">
|
||||||
|
// Don't have an account?{" "}
|
||||||
|
// <Link href={"/register"} className="underline">
|
||||||
|
// Sign up
|
||||||
|
// </Link>
|
||||||
|
// </div>
|
||||||
|
// </Form>
|
||||||
|
// <SocialAuthButton
|
||||||
|
// callBackURL={urlParams?.get("redirect") ?? "/dashboard/profile"}
|
||||||
|
// providers={availableProviders}/>
|
||||||
|
// </CardContent>
|
||||||
|
// </Card>
|
||||||
|
// </TooltipProvider>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import {Card, CardContent, CardHeader} from "@/components/ui/card";
|
||||||
import { FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
import {FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import { Form } from "@/components/ui/form";
|
import {Form} from "@/components/ui/form";
|
||||||
import { Button } from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import { toast } from "sonner";
|
import {toast} from "sonner";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
import { LoginSchema, LoginType } from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
import {LoginSchema, LoginType} from "@/components/wrappers/auth/login/login-form/login-form.schema";
|
||||||
import { SocialAuthButton, SocialProviderType } from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
import {SocialAuthButton, SocialProviderType} from "@/components/wrappers/auth/login/button-auth/social-auth-button";
|
||||||
import { signIn } from "@/lib/auth/auth-client";
|
import {signIn} from "@/lib/auth/auth-client";
|
||||||
import { useRouter } from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import { Icon } from "@iconify/react";
|
import {Icon} from "@iconify/react";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
|
||||||
export type loginFormProps = {
|
export type loginFormProps = {
|
||||||
defaultValues?: LoginType;
|
defaultValues?: LoginType;
|
||||||
|
authGoogleEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LoginForm = (props: loginFormProps) => {
|
export const LoginForm = (props: loginFormProps) => {
|
||||||
@@ -27,27 +176,57 @@ export const LoginForm = (props: loginFormProps) => {
|
|||||||
schema: LoginSchema,
|
schema: LoginSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [urlParams] = useState(() =>
|
||||||
|
new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const error = urlParams.get("error");
|
||||||
|
if (error?.includes("pending")) {
|
||||||
|
toast.error("Your account is not active.");
|
||||||
|
urlParams.delete("error");
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname + "?" + urlParams.toString());
|
||||||
|
}
|
||||||
|
}, [urlParams]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (values: LoginType) => {
|
mutationFn: async (values: LoginType) => {
|
||||||
const { error } = await signIn.email(values, {
|
try {
|
||||||
onSuccess: () => {
|
const callbackURL =
|
||||||
toast.success("Login success");
|
urlParams.get("redirect")?.startsWith("/")
|
||||||
router.push("/dashboard/profile");
|
? urlParams.get("redirect")
|
||||||
},
|
: "/dashboard/profile";
|
||||||
|
|
||||||
|
const {error} = await signIn.email({
|
||||||
|
email: values.email,
|
||||||
|
password: values.password,
|
||||||
|
callbackURL: callbackURL ?? "/dashboard/profile",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
} else {
|
||||||
|
toast.success("Login success");
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("Unexpected client error during login");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
toast.error(err.message || "Client error");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableProviders: SocialProviderType[] = [
|
const availableProviders: SocialProviderType[] = [];
|
||||||
{
|
|
||||||
|
if (props.authGoogleEnabled) {
|
||||||
|
availableProviders.push({
|
||||||
id: "google",
|
id: "google",
|
||||||
name: "Google",
|
name: "Google",
|
||||||
icon: <Icon icon={"logos:google-icon"} width="25" height="25" />,
|
icon: <Icon icon="logos:google-icon" width="25" height="25"/>,
|
||||||
},
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -55,7 +234,9 @@ export const LoginForm = (props: loginFormProps) => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="grid gap-2 text-center mb-2">
|
<div className="grid gap-2 text-center mb-2">
|
||||||
<h1 className="text-3xl font-bold">Login</h1>
|
<h1 className="text-3xl font-bold">Login</h1>
|
||||||
<p className="text-balance text-muted-foreground">Enter your informations below to login</p>
|
<p className="text-balance text-muted-foreground">
|
||||||
|
Enter your information below to login
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -70,13 +251,17 @@ export const LoginForm = (props: loginFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="email"
|
name="email"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email</FormLabel>
|
<FormLabel>Email</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input autoComplete="email webauthn" placeholder="exemple@portabase.io" {...field} />
|
<Input
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="example@portabase.io"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -84,30 +269,38 @@ export const LoginForm = (props: loginFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="password"
|
name="password"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
{/* <Link href={"/forgot-password"} className="ml-auto inline-block text-sm underline">
|
{/* Optional forgot password link */}
|
||||||
Forgot your password?
|
|
||||||
</Link>*/}
|
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<PasswordInput autoComplete="current-password webauthn" placeholder="Your password" {...field} />
|
<PasswordInput
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="Your password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button>Sign in</Button>
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
<div className="mt-4 text-center text-sm">
|
<div className="mt-4 text-center text-sm">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link href={"/register"} className="underline">
|
<Link href="/register" className="underline">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
<SocialAuthButton providers={availableProviders} />
|
|
||||||
|
<SocialAuthButton
|
||||||
|
callBackURL={urlParams.get("redirect") ?? "/dashboard/profile"}
|
||||||
|
providers={availableProviders}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export const RegisterForm = (props: registerFormProps) => {
|
|||||||
await signUp.email(values, {
|
await signUp.email(values, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Success`);
|
toast.success(`Success`);
|
||||||
router.push(`/login`);
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
router.push(`/login`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type BackButtonProps = React.ComponentProps<typeof Button> & {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BackButton({ children, ...props }: BackButtonProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={() => router.back()} aria-label={children?.toString()} {...props}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,66 @@
|
|||||||
"use client";
|
// "use client";
|
||||||
|
//
|
||||||
|
// import { Button } from "@/components/ui/button";
|
||||||
|
// import { ButtonHTMLAttributes } from "react";
|
||||||
|
// import { Loader2 } from "lucide-react";
|
||||||
|
//
|
||||||
|
// export type VariantButton = {
|
||||||
|
// secondary: string;
|
||||||
|
// default: string;
|
||||||
|
// outline: string;
|
||||||
|
// ghost: string;
|
||||||
|
// link: string;
|
||||||
|
// destructive: string;
|
||||||
|
// };
|
||||||
|
// export type sizeButton = {
|
||||||
|
// default: string;
|
||||||
|
// icon: string;
|
||||||
|
// sm: string;
|
||||||
|
// lg: string;
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// export type ButtonWithConfirmProps = {
|
||||||
|
// icon?: any;
|
||||||
|
// text: string;
|
||||||
|
// variant?: keyof VariantButton;
|
||||||
|
// className?: string;
|
||||||
|
// onClick: () => void;
|
||||||
|
// isPending?: boolean;
|
||||||
|
// size: keyof sizeButton;
|
||||||
|
// };
|
||||||
|
//
|
||||||
|
// export const ButtonWithLoading = ({
|
||||||
|
// icon,
|
||||||
|
// text,
|
||||||
|
// variant,
|
||||||
|
// className,
|
||||||
|
// onClick,
|
||||||
|
// isPending,
|
||||||
|
// size,
|
||||||
|
// ...props // catch all remaining props
|
||||||
|
// }: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||||
|
// return (
|
||||||
|
// <Button
|
||||||
|
// onClick={() => {
|
||||||
|
// onClick();
|
||||||
|
// }}
|
||||||
|
// variant={variant ? variant : "default"}
|
||||||
|
// className={className}
|
||||||
|
// {...props} // forward the remaining props to the Button component
|
||||||
|
// size={size || "default"}
|
||||||
|
// >
|
||||||
|
// {isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||||
|
// {text}
|
||||||
|
// <>{icon ? icon : null}</>
|
||||||
|
// </Button>
|
||||||
|
// );
|
||||||
|
// };
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
'use client'
|
||||||
import { ButtonHTMLAttributes } from "react";
|
|
||||||
|
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export type VariantButton = {
|
export type VariantButton = {
|
||||||
secondary: string;
|
secondary: string;
|
||||||
@@ -12,46 +70,46 @@ export type VariantButton = {
|
|||||||
link: string;
|
link: string;
|
||||||
destructive: string;
|
destructive: string;
|
||||||
};
|
};
|
||||||
export type sizeButton = {
|
|
||||||
|
export type SizeButton = {
|
||||||
default: string;
|
default: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
sm: string;
|
sm: string;
|
||||||
lg: string;
|
lg: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ButtonWithConfirmProps = {
|
export type ButtonWithLoadingProps = {
|
||||||
icon?: any;
|
children?: string | ReactNode;
|
||||||
text: string;
|
icon?: ReactNode;
|
||||||
variant?: keyof VariantButton;
|
variant?: keyof VariantButton;
|
||||||
className?: string;
|
className?: string;
|
||||||
onClick: () => void;
|
onClick?: () => void;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
size: keyof sizeButton;
|
size?: keyof SizeButton;
|
||||||
};
|
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
export const ButtonWithLoading = ({
|
export const ButtonWithLoading = ({
|
||||||
icon,
|
icon,
|
||||||
text,
|
children,
|
||||||
variant,
|
variant = "default",
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
isPending,
|
isPending,
|
||||||
size,
|
size = "default",
|
||||||
...props // catch all remaining props
|
...rest
|
||||||
}: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
|
}: ButtonWithLoadingProps) => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => onClick?.()}
|
||||||
onClick();
|
variant={variant}
|
||||||
}}
|
|
||||||
variant={variant ? variant : "default"}
|
|
||||||
className={className}
|
className={className}
|
||||||
{...props} // forward the remaining props to the Button component
|
size={size}
|
||||||
size={size || "default"}
|
{...rest}
|
||||||
>
|
>
|
||||||
{isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
|
||||||
{text}
|
{children && children}
|
||||||
<>{icon ? icon : null}</>
|
<>{icon ? icon : null}</>
|
||||||
|
{/*{icon && <span className="ml-2">{icon}</span>}*/}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import {cn} from "@/lib/utils";
|
|||||||
import {Plus} from "lucide-react";
|
import {Plus} from "lucide-react";
|
||||||
|
|
||||||
type EmptyStatePlaceholderProps = {
|
type EmptyStatePlaceholderProps = {
|
||||||
url: string;
|
url?: string;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
|
export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) => {
|
||||||
return (
|
return (
|
||||||
|
<>{url ?
|
||||||
<Link
|
<Link
|
||||||
href={url}
|
href={url}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -20,5 +20,12 @@ export const EmptyStatePlaceholder = ({url, text}: EmptyStatePlaceholderProps) =
|
|||||||
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
|
||||||
<span className="text-sm lg:text-base font-medium">{text}</span>
|
<span className="text-sm lg:text-base font-medium">{text}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
:
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<p className="text-lg text-muted-foreground">{text}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
|
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
|
||||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
|
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
|
||||||
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useRouter, useSearchParams} from "next/navigation";
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-tab/admin-user-table";
|
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/admin-user-table";
|
||||||
|
import {
|
||||||
|
AdminOrganizationsTable
|
||||||
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-organizations-tab/admin-organizations-table";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
export type AdminTabsProps = {
|
export type AdminTabsProps = {
|
||||||
users: UserWithAccounts[];
|
users: UserWithAccounts[];
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
|
organizations: OrganizationWithMembers[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
@@ -35,6 +40,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
|||||||
<TabsTrigger className="w-full" value="users">
|
<TabsTrigger className="w-full" value="users">
|
||||||
Users
|
Users
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="w-full" value="organizations">
|
||||||
|
Organizations
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger className="w-full" value="email">
|
<TabsTrigger className="w-full" value="email">
|
||||||
Email
|
Email
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@@ -42,10 +50,12 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
|||||||
Storage
|
Storage
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="users">
|
<TabsContent value="users">
|
||||||
<AdminUsersTable users={users}/>
|
<AdminUsersTable users={users}/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="organizations">
|
||||||
|
<AdminOrganizationsTable organizations={organizations}/>
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="email">
|
<TabsContent value="email">
|
||||||
<SettingsEmailTab settings={settings}/>
|
<SettingsEmailTab settings={settings}/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Plus} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {AdminOrganizationForm} from "@/components/wrappers/dashboard/admin/organization/admin-organization-form";
|
||||||
|
|
||||||
|
type AdminOrganizationAddModalProps = {}
|
||||||
|
|
||||||
|
|
||||||
|
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
|
||||||
|
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus/> add
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>add organization</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
your description
|
||||||
|
</DialogDescription>
|
||||||
|
<AdminOrganizationForm onSuccess={() => setOpen(false)}/>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ErrorContext } from "@better-fetch/fetch";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||||
|
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||||
|
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import { slugify } from "@/utils/slugify";
|
||||||
|
|
||||||
|
type AdminOrganizationFormProps = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminOrganizationForm = ({ onSuccess }: AdminOrganizationFormProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useZodForm({ schema: OrganizationSchema });
|
||||||
|
|
||||||
|
const mutationCreateOrganisation = useMutation({
|
||||||
|
mutationFn: async ({ name }: OrganizationSchema) => {
|
||||||
|
const slug = slugify(name);
|
||||||
|
await authClient.organization.checkSlug(
|
||||||
|
{
|
||||||
|
slug: slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
await authClient.organization.create(
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
slug: slug,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Organization created successfully.");
|
||||||
|
router.refresh();
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.error.message);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error: ErrorContext) => {
|
||||||
|
toast.error(error.error.message);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
await mutationCreateOrganisation.mutateAsync(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Name of your organization" {...field} value={field.value ?? ""} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>Validate</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
|
||||||
|
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organization/admin-organization-add-modal";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
type AdminOrganizationSectionProps = {
|
||||||
|
organizations: OrganizationWithMembers[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminOrganizationSection = ({ organizations }: AdminOrganizationSectionProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add a new organization</CardTitle>
|
||||||
|
<CardAction>
|
||||||
|
<AdminOrganizationAddModal />
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AdminOrganizationList organizations={organizations} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"use client"
|
||||||
|
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||||
|
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organization/table-colums";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
type AdminOrganizationListProps = {
|
||||||
|
organizations: OrganizationWithMembers[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminOrganizationList = ({ organizations }: AdminOrganizationListProps) => {
|
||||||
|
return <DataTable columns={organizationsListColumns()} data={organizations} enablePagination={true} enableSelect={false} />;
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {Trash2} from "lucide-react";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||||
|
|
||||||
|
export type ButtonDeleteFleetProps = {
|
||||||
|
text?: string;
|
||||||
|
organisationId: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonDeleteOrganization = (props: ButtonDeleteFleetProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||||
|
|
||||||
|
|
||||||
|
const mutationDeleteOrganisation = useMutation({
|
||||||
|
mutationFn: () => deleteOrganizationAction({id: props.organisationId}),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (result?.data?.success) {
|
||||||
|
await authClient.organization.setActive({
|
||||||
|
organizationSlug: "default",
|
||||||
|
});
|
||||||
|
toast.success("Organization deleted!");
|
||||||
|
router.refresh()
|
||||||
|
refetch()
|
||||||
|
} else {
|
||||||
|
toast.error("An error occurred.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("network error:", error);
|
||||||
|
toast.error(error?.message || "A network error occurred.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title={props.text ? props.text : ""}
|
||||||
|
description={"Are you sure you want to delete this organization?"}
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
variant: "outline",
|
||||||
|
icon: <Trash2 color="red"/>,
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Delete",
|
||||||
|
icon: <Trash2/>,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: async () => {
|
||||||
|
await mutationDeleteOrganisation.mutateAsync()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Cancel",
|
||||||
|
icon: <Trash2/>,
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
isPending={mutationDeleteOrganisation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { ServerActionResult } from "@/types/action-type";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/lib/auth/auth";
|
||||||
|
import { MemberRoleType } from "@/types/common";
|
||||||
|
import { Member } from "better-auth/plugins/organization";
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
|
||||||
|
export const addMemberOrganizationAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
organizationId: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({ parsedInput }): Promise<ServerActionResult<Member | null>> => {
|
||||||
|
try {
|
||||||
|
const data = await auth.api.addMember({
|
||||||
|
body: {
|
||||||
|
userId: parsedInput.userId,
|
||||||
|
role: parsedInput.role as MemberRoleType,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: data,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Member added successfully",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "An error occurred while addinng member",
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
AddMemberSchema,
|
||||||
|
AddMemberSchemaType
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||||
|
import {SearchInput} from "@/components/ui/search-input";
|
||||||
|
import {
|
||||||
|
addMemberOrganizationAction
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/add-member.action";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
|
||||||
|
type OrganizationAddMemberFormProps = {
|
||||||
|
onSuccessAction?: () => void;
|
||||||
|
users: User[];
|
||||||
|
organization: OrganizationWithMembersAndUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}: OrganizationAddMemberFormProps) => {
|
||||||
|
|
||||||
|
const organizationMemberUserIds = organization.members.map((member) => member.user.id);
|
||||||
|
const filteredUsers = users
|
||||||
|
.filter((user) => !organizationMemberUserIds.includes(user.id))
|
||||||
|
.map((user) => ({value: user.id, label: `${user.name} | ${user.email}`}));
|
||||||
|
const router = useRouter();
|
||||||
|
const form = useZodForm({schema: AddMemberSchema});
|
||||||
|
|
||||||
|
const mutationAddMemberOrganisation = useMutation({
|
||||||
|
mutationFn: async (data: AddMemberSchemaType) => {
|
||||||
|
console.log(data);
|
||||||
|
const result = await addMemberOrganizationAction({
|
||||||
|
userId: data.userId,
|
||||||
|
organizationId: organization.id,
|
||||||
|
role: "member",
|
||||||
|
});
|
||||||
|
console.log(result);
|
||||||
|
toast.success("Member successfully added!");
|
||||||
|
router.refresh();
|
||||||
|
onSuccessAction?.();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
onSuccessAction?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
await mutationAddMemberOrganisation.mutateAsync(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="userId"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<SearchInput
|
||||||
|
name="userId"
|
||||||
|
placeholder="Enter a user email"
|
||||||
|
entries={filteredUsers}
|
||||||
|
onSelect={(entySelected: any) => {
|
||||||
|
console.log("Form selection:", entySelected);
|
||||||
|
field.onChange(entySelected.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading
|
||||||
|
isPending={mutationAddMemberOrganisation.isPending}>Confirm</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { OrganizationAddMemberForm } from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-form";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
|
||||||
|
type OrganizationAddMemberModalProps = {
|
||||||
|
users: User[];
|
||||||
|
organization: OrganizationWithMembersAndUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationAddMemberModal = ({ users, organization }: OrganizationAddMemberModalProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<UserPlus className="w-4 h-4 mr-2" />
|
||||||
|
Add member
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add member to your organization</DialogTitle>
|
||||||
|
<DialogDescription>Select a user to add to your organization</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<OrganizationAddMemberForm users={users} organization={organization} onSuccessAction={() => setOpen(!open)} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { authClient } from "@/lib/auth/auth-client";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
type OrganizationDeleteMemberModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
member: MemberWithUser;
|
||||||
|
onOpenChangeAction: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction }: OrganizationDeleteMemberModalProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await authClient.organization.removeMember(
|
||||||
|
{
|
||||||
|
memberIdOrEmail: member.id,
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: async (response) => {
|
||||||
|
console.log(response);
|
||||||
|
toast.success("Member successfully deleted!");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: async (error) => {
|
||||||
|
console.log(error);
|
||||||
|
toast.error("An error occurred while deleting member!");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChangeAction}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure you want to delete {member.user.name } ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>This action is irreversible: it will permanently delete this member’s data.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>Validate</ButtonWithLoading>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||||
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
|
||||||
|
import {
|
||||||
|
OrganizationDeleteMemberModal
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-delete-member-modal";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
import {
|
||||||
|
OrganizationMemberChangeRoleModal
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-change-role";
|
||||||
|
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
type OrganizationMemberCardProps = {
|
||||||
|
member: MemberWithUser;
|
||||||
|
organization: OrganizationWithMembersAndUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationMemberCard = ({member, organization}: OrganizationMemberCardProps) => {
|
||||||
|
|
||||||
|
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||||
|
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
|
||||||
|
const {data: session, isPending, error} = authClient.useSession();
|
||||||
|
|
||||||
|
if (isPending || error) return null;
|
||||||
|
const isCurrentUser = session?.user?.id === member.user.id;
|
||||||
|
const isOwner = member?.role === "owner";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={member.id}
|
||||||
|
className="flex flex-col md:flex-row md:items-center justify-between p-4 border rounded-lg">
|
||||||
|
<OrganizationDeleteMemberModal member={member} open={isModalDeleteOpen}
|
||||||
|
onOpenChangeAction={setIsModalDeleteOpen}/>
|
||||||
|
<OrganizationMemberChangeRoleModal member={member} open={isModalRoleOpen}
|
||||||
|
onOpenChangeAction={setIsModalRoleOpen}/>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={member.user.image || ""} alt={member.user.name}/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{member.user.name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{member.user.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{member.user.email}</div>
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground">Joined {new Date(member.createdAt).toLocaleDateString()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-4 md:mt-0">
|
||||||
|
<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreHorizontal className="w-4 h-4"/>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onSelect={() => setIsModalRoleOpen(true)}>
|
||||||
|
<Settings className="w-4 h-4 mr-2"/>
|
||||||
|
Change role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator/>
|
||||||
|
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
|
||||||
|
<Trash2 className="w-4 h-4 mr-2"/>
|
||||||
|
Remove member
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleBadgeVariant = (role: string) => {
|
||||||
|
switch (role.toLowerCase()) {
|
||||||
|
case "owner":
|
||||||
|
return "default";
|
||||||
|
case "admin":
|
||||||
|
return "secondary";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useState} from "react";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {MemberRoleType} from "@/types/common";
|
||||||
|
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||||
|
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action";
|
||||||
|
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||||
|
import {
|
||||||
|
updateMemberRoleAdminAction
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/role-member.action";
|
||||||
|
|
||||||
|
type OrganizationMemberChangeRoleModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
member: MemberWithUser;
|
||||||
|
onOpenChangeAction: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChangeRoleModalProps) => {
|
||||||
|
const {member, open, onOpenChangeAction} = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const [role, setRole] = useState<MemberRoleType>(member.role as MemberRoleType);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateMemberRoleAdminAction({
|
||||||
|
memberId: member.id,
|
||||||
|
organizationId: member.organizationId,
|
||||||
|
role: RoleSchemaMember.parse(role),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Member successfully updated");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.log(error);
|
||||||
|
toast.error("An error occurred while updating member");
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChangeAction}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change the user’s role</DialogTitle>
|
||||||
|
<DialogDescription>Modify the role of this user within your organization.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Select defaultValue={member.role ?? ""} onValueChange={(role) => setRole(role as MemberRoleType)}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Sélectionnez un rôle"/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="owner">Owner</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChangeAction(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</ButtonWithLoading>
|
||||||
|
<ButtonWithLoading
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
onClick={async () => {
|
||||||
|
await mutation.mutateAsync();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Validate
|
||||||
|
</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use server";
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {Member} from "better-auth/plugins";
|
||||||
|
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||||
|
import {db as dbClient} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
|
||||||
|
|
||||||
|
export const updateMemberRoleAdminAction = userAction.schema(
|
||||||
|
z.object({
|
||||||
|
memberId: z.string(),
|
||||||
|
organizationId: z.string(),
|
||||||
|
role: RoleSchemaMember,
|
||||||
|
})
|
||||||
|
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const [updatedMember] = await dbClient
|
||||||
|
.update(drizzleDb.schemas.member)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
role: parsedInput.role as string,
|
||||||
|
}))
|
||||||
|
.where(and(eq(drizzleDb.schemas.member.id, parsedInput.memberId), eq(drizzleDb.schemas.member.organizationId, parsedInput.organizationId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: updatedMember,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Member has been successfully updated.",
|
||||||
|
messageParams: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to update member role.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
messageParams: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
UpdateOrganizationSchema,
|
||||||
|
UpdateOrganizationSchemaType
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||||
|
|
||||||
|
type UpdateOrganizationFormProps = {
|
||||||
|
onSuccessAction?: () => void;
|
||||||
|
defaultValues: OrganizationWithMembersAndUsers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpdateOrganizationForm = ({onSuccessAction, defaultValues}: UpdateOrganizationFormProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||||
|
|
||||||
|
const isDefaultOrganization = defaultValues.slug == "default";
|
||||||
|
|
||||||
|
const form = useZodForm({
|
||||||
|
schema: UpdateOrganizationSchema,
|
||||||
|
defaultValues: defaultValues,
|
||||||
|
disabled: isDefaultOrganization,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const mutationUpdateOrganisation = useMutation({
|
||||||
|
mutationFn: ({name}: UpdateOrganizationSchemaType) => updateOrganizationAction({
|
||||||
|
data: {
|
||||||
|
name: name,
|
||||||
|
users: [],
|
||||||
|
slug: defaultValues.slug
|
||||||
|
},
|
||||||
|
organizationId: defaultValues.id,
|
||||||
|
}),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (result?.data?.success) {
|
||||||
|
toast.success("Organization updated successfully.");
|
||||||
|
router.refresh();
|
||||||
|
refetch()
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
|
||||||
|
toast.error(errorMsg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("Mutation network error:", error);
|
||||||
|
toast.error(error?.message || "A network error occurred.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
await mutationUpdateOrganisation.mutateAsync(values);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="" {...field} value={field.value ?? ""}/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4 justify-end">
|
||||||
|
<ButtonWithLoading disabled={isDefaultOrganization} isPending={mutationUpdateOrganisation.isPending}>Validate</ButtonWithLoading>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {Building2, Shield, Users} from "lucide-react";
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
|
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
|
import {
|
||||||
|
UpdateOrganizationForm
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/update-organization-form";
|
||||||
|
import {
|
||||||
|
OrganizationMemberCard
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-card";
|
||||||
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import {capitalizeFirstLetter} from "@/utils/text";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
import {
|
||||||
|
OrganizationAddMemberModal
|
||||||
|
} from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-modal";
|
||||||
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
|
type OrganizationManagementProps = {
|
||||||
|
organization: OrganizationWithMembersAndUsers;
|
||||||
|
users: User[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrganizationManagement = ({organization, users}: OrganizationManagementProps) => {
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "members");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newTab = searchParams.get("tab") ?? "members";
|
||||||
|
setTab(newTab);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleChangeTab = (value: string) => {
|
||||||
|
router.push(`?tab=${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" space-y-8">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center justify-center w-12 h-12 dark:bg-gray-700 bg-gray-100 rounded-lg">
|
||||||
|
<Building2 className="w-6 h-6 "/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{capitalizeFirstLetter(organization.name)}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2 mt-3 md:mt-0">
|
||||||
|
<OrganizationAddMemberModal organization={organization} users={users}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Members</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground"/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{organization.members.length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Number of members</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Administrators</CardTitle>
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground"/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="text-2xl font-bold">{organization.members.filter((m) => m.role === "admin" || m.role === "owner").length}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">With admin roles</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<Tabs className="space-y-6" value={tab} onValueChange={handleChangeTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="members">Members</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="members" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Organization members</CardTitle>
|
||||||
|
<CardDescription>Manage who has access to your organization and their
|
||||||
|
roles.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{organization.members.map((member: MemberWithUser) => (
|
||||||
|
<OrganizationMemberCard key={member.id} member={member}
|
||||||
|
organization={organization}/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings" className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Settings</CardTitle>
|
||||||
|
<CardDescription>Organization configuration settings.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<UpdateOrganizationForm defaultValues={organization}/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {z} from "zod";
|
||||||
|
|
||||||
|
export const AddMemberSchema = z.object({
|
||||||
|
userId: z.string().min(1, "Invalid field"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateOrganizationSchema = z.object({
|
||||||
|
name: z.string().min(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OrganizationSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const OrganizationInvitationSchema = z.object({
|
||||||
|
email: z.string(),
|
||||||
|
invitedByUsername: z.string(),
|
||||||
|
invitedByEmail: z.string(),
|
||||||
|
teamName: z.string(),
|
||||||
|
inviteLink: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrganizationInvitationType = z.infer<typeof OrganizationInvitationSchema>;
|
||||||
|
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
|
||||||
|
export type UpdateOrganizationSchemaType = z.infer<typeof UpdateOrganizationSchema>;
|
||||||
|
export type AddMemberSchemaType = z.infer<typeof AddMemberSchema>;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import {ColumnDef} from "@tanstack/react-table";
|
||||||
|
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organization/button-delete-organization";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {Settings} from "lucide-react";
|
||||||
|
import {buttonVariants} from "@/components/ui/button";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
export function organizationsListColumns(): ColumnDef<OrganizationWithMembers>[] {
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "members",
|
||||||
|
header: "Members",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const membersCount = row.original.members?.length;
|
||||||
|
return <div className="flex items-center gap-3">{membersCount}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: "Actions",
|
||||||
|
id: "actions",
|
||||||
|
cell: ({row}) => {
|
||||||
|
const isDefaultOrganization = row.original.slug == "default";
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isDefaultOrganization && (
|
||||||
|
<ButtonDeleteOrganization organisationId={row.original.id}/>
|
||||||
|
)}
|
||||||
|
<Link className={buttonVariants({variant: "outline"})}
|
||||||
|
href={`admin/organization/${row.original.id}`}>
|
||||||
|
<Settings/>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
+2
-2
@@ -19,11 +19,11 @@ import {TooltipProvider} from "@/components/ui/tooltip";
|
|||||||
import {
|
import {
|
||||||
EmailFormSchema,
|
EmailFormSchema,
|
||||||
EmailFormType
|
EmailFormType
|
||||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
import {
|
import {
|
||||||
updateEmailSettingsAction
|
updateEmailSettingsAction
|
||||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
+12
-13
@@ -1,13 +1,13 @@
|
|||||||
import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form";
|
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
|
||||||
import { Send } from "lucide-react";
|
import {Send} from "lucide-react";
|
||||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import { sendEmail } from "@/utils/email-helper";
|
import {sendEmail} from "@/utils/email-helper";
|
||||||
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
|
import {render} from "@react-email/render";
|
||||||
import { render } from "@react-email/render";
|
import {toast} from "sonner";
|
||||||
import { toast } from "sonner";
|
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||||
|
import TestEmailSettings from "../../../../../../../emails/TestEmailSettings";
|
||||||
|
|
||||||
export type SettingsEmailTabProps = {
|
export type SettingsEmailTabProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -47,14 +47,13 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await handleSendMailTest();
|
await handleSendMailTest();
|
||||||
}}
|
}}
|
||||||
icon={<Send />}
|
icon={<Send/>}
|
||||||
text="Send email test"
|
|
||||||
size="default"
|
size="default"
|
||||||
/>
|
>Send email test</ButtonWithLoading>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } />
|
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
|
||||||
|
|
||||||
|
export type AdminOrganizationsTableProps = {
|
||||||
|
organizations: OrganizationWithMembers[];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminOrganizationsTable = (props: AdminOrganizationsTableProps) => {
|
||||||
|
const {organizations} = props;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active organizations</CardTitle>
|
||||||
|
<CardDescription>Manage all system organizations</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<AdminOrganizationList organizations={organizations} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"use client"
|
||||||
|
import {ColumnDef} from "@tanstack/react-table";
|
||||||
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {useState} from "react";
|
||||||
|
import {Trash2} from "lucide-react";
|
||||||
|
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||||
|
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||||
|
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||||
|
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||||
|
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||||
|
import {Organization} from "@/db/schema/03_organization";
|
||||||
|
|
||||||
|
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
|
||||||
|
|
||||||
|
{
|
||||||
|
accessorKey: "name",
|
||||||
|
header: "Name",
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// header: "Action",
|
||||||
|
// id: "actions",
|
||||||
|
// cell: ({row}) => {
|
||||||
|
// const router = useRouter();
|
||||||
|
// const {data: session, isPending} = useSession();
|
||||||
|
// const isSuperAdmin = session?.user.role == "superadmin";
|
||||||
|
//
|
||||||
|
// return (
|
||||||
|
// <ButtonDeleteUser
|
||||||
|
// disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||||
|
// userId={row.original.id}/>
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
];
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {Download} from "lucide-react";
|
||||||
|
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
export type AdminSettingsTabProps = {};
|
||||||
|
|
||||||
|
export const AdminSettingsTab = (props: AdminSettingsTabProps) => {
|
||||||
|
|
||||||
|
const handleDownloadKey = async () => {
|
||||||
|
|
||||||
|
let url: string = "";
|
||||||
|
const data = await getFileUrlPresignedLocal({dir: "private/keys/", fileName: "server_public.pem"})
|
||||||
|
if (data?.data?.success) {
|
||||||
|
url = data.data.value ?? "";
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
window.open(url, "_self");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Instance settings</CardTitle>
|
||||||
|
<CardDescription>Manage portabase settings</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Download Public Key</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Used for encrypting communications with this instance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleDownloadKey} variant="outline" size="sm">
|
||||||
|
<Download className="h-4 w-4 mr-2"/>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+5
-7
@@ -2,7 +2,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
|||||||
import {Info, ShieldCheck} from "lucide-react";
|
import {Info, ShieldCheck} from "lucide-react";
|
||||||
import {Switch} from "@/components/ui/switch";
|
import {Switch} from "@/components/ui/switch";
|
||||||
import {Label} from "@/components/ui/label";
|
import {Label} from "@/components/ui/label";
|
||||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
|
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/storage-s3-form";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
@@ -11,9 +11,9 @@ import {toast} from "sonner";
|
|||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {
|
import {
|
||||||
updateStorageSettingsAction
|
updateStorageSettingsAction
|
||||||
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||||
import {Setting} from "@/db/schema/01_setting";
|
import {Setting} from "@/db/schema/01_setting";
|
||||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
import {S3FormType} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||||
|
|
||||||
export type SettingsStorageTabProps = {
|
export type SettingsStorageTabProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -70,7 +70,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
|
Actually you can only store you data in one place : s3 compatible or in local. For exemple you
|
||||||
cannot choose to store images in one place
|
cannot choose to store images in one place
|
||||||
and .dump files in another.
|
and backups files in another.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
<div className="flex flex-col h-full py-4 ">
|
<div className="flex flex-col h-full py-4 ">
|
||||||
@@ -93,9 +93,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutation.mutateAsync();
|
await mutation.mutateAsync();
|
||||||
}}
|
}}
|
||||||
icon={<ShieldCheck/>}
|
icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
|
||||||
text="Test connexion"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSwitched && (
|
{isSwitched && (
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
+2
-2
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
|
|
||||||
export type S3FormProps = {
|
export type S3FormProps = {
|
||||||
-1
@@ -66,7 +66,6 @@ export const accountsColumns: ColumnDef<{
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
text=""
|
|
||||||
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
||||||
icon={<Unlink color="red" size={15}/>}
|
icon={<Unlink color="red" size={15}/>}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
+6
-3
@@ -1,7 +1,7 @@
|
|||||||
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/admin-user-tab/columns-users";
|
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/columns-users";
|
||||||
|
|
||||||
export type AdminUsersTableProps = {
|
export type AdminUsersTableProps = {
|
||||||
users: UserWithAccounts[];
|
users: UserWithAccounts[];
|
||||||
@@ -18,7 +18,10 @@ export const AdminUsersTable = (props: AdminUsersTableProps) => {
|
|||||||
<CardDescription>Manage your users</CardDescription>
|
<CardDescription>Manage your users</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DataTable columns={usersColumnsAdmin} data={users}/>
|
<DataTable
|
||||||
|
enableSelect={false}
|
||||||
|
columns={usersColumnsAdmin}
|
||||||
|
data={users}/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {Trash2} from "lucide-react";
|
||||||
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||||
|
|
||||||
|
export type ButtonDeleteUserProps = {
|
||||||
|
userId: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ButtonDeleteUser = (props: ButtonDeleteUserProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => deleteUserAction(props.userId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("User deleted successfully.");
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
|
||||||
|
<ButtonWithConfirm
|
||||||
|
title={""}
|
||||||
|
|
||||||
|
description="Are you sure you want to remove this user? This action cannot be undone."
|
||||||
|
button={{
|
||||||
|
main: {
|
||||||
|
disabled: !!props.disabled,
|
||||||
|
text: "",
|
||||||
|
variant: "outline",
|
||||||
|
size: "sm",
|
||||||
|
icon: <Trash2 color="red" size={15}/>,
|
||||||
|
},
|
||||||
|
confirm: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Delete",
|
||||||
|
icon: <Trash2/>,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => {
|
||||||
|
mutation.mutate()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cancel: {
|
||||||
|
className: "w-full",
|
||||||
|
text: "Cancel",
|
||||||
|
icon: <Trash2/>,
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
-20
@@ -13,6 +13,7 @@ import {UserWithAccounts} from "@/db/schema/02_user";
|
|||||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||||
|
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||||
|
|
||||||
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
||||||
{
|
{
|
||||||
@@ -72,7 +73,7 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
|||||||
accessorKey: "accounts",
|
accessorKey: "accounts",
|
||||||
header: "Provider ID",
|
header: "Provider ID",
|
||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
return(
|
return (
|
||||||
<div>
|
<div>
|
||||||
{row.original.accounts.map((item) => (
|
{row.original.accounts.map((item) => (
|
||||||
<div key={item.id}>
|
<div key={item.id}>
|
||||||
@@ -98,27 +99,10 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
|||||||
const {data: session, isPending} = useSession();
|
const {data: session, isPending} = useSession();
|
||||||
const isSuperAdmin = session?.user.role == "superadmin";
|
const isSuperAdmin = session?.user.role == "superadmin";
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: () => deleteUserAction(row.original.id),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("User deleted successfully.");
|
|
||||||
router.refresh();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<ButtonDeleteUser
|
||||||
<ButtonWithLoading
|
|
||||||
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||||
variant="outline"
|
userId={row.original.id}/>
|
||||||
text=""
|
|
||||||
icon={<Trash2 color="red" size={15}/>}
|
|
||||||
onClick={async () => {
|
|
||||||
await mutation.mutateAsync();
|
|
||||||
}}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
-5
@@ -19,10 +19,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
|
|||||||
return timeAgo(row.original.expiresAt);
|
return timeAgo(row.original.expiresAt);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "ipAddress",
|
|
||||||
header: "IP Address",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "device",
|
id: "device",
|
||||||
header: "Device",
|
header: "Device",
|
||||||
@@ -73,7 +69,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
|
|||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={session?.session.id === row.original.id}
|
disabled={session?.session.id === row.original.id}
|
||||||
text=""
|
|
||||||
icon={<Unlink color="red" size={15} />}
|
icon={<Unlink color="red" size={15} />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutation.mutateAsync();
|
await mutation.mutateAsync();
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import {generateEdgeKey} from "@/utils/edge_key";
|
|
||||||
import {getServerUrl} from "@/utils/get-server-url";
|
|
||||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
|
import {CopyButton} from "@/components/wrappers/common/button/copy-button";
|
||||||
import {Agent} from "@/db/schema/08_agent";
|
|
||||||
|
|
||||||
export type AgentCardKeyProps = {
|
export type AgentCardKeyProps = {
|
||||||
agent: Agent;
|
edgeKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentCardKey = (props: AgentCardKeyProps) => {
|
export const AgentCardKey = ({edgeKey}: AgentCardKeyProps) => {
|
||||||
const edge_key = generateEdgeKey(getServerUrl(), props.agent.id);
|
const [code, setCode] = useState<string>(`${edgeKey}`);
|
||||||
const [code, setCode] = useState<string>(`${edge_key}`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
value={code}
|
value={code}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
setCode(edge_key);
|
setCode(edgeKey);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CopyButton className="mt-5" value={code}/>
|
<CopyButton className="mt-5" value={code}/>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const AgentCard = (props: agentCardProps) => {
|
|||||||
const { data: agent } = props;
|
const { data: agent } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/dashboard/agents/${agent.id}`} className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md">
|
<Link href={`/dashboard/agents/${agent.id}`}
|
||||||
|
className="block transition-all duration-200 hover:scale-[1.01] hover:shadow-md rounded-xl"
|
||||||
|
>
|
||||||
<Card className="flex flex-row justify-between">
|
<Card className="flex flex-row justify-between">
|
||||||
<div className="flex-1 text-left">
|
<div className="flex-1 text-left">
|
||||||
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
|
<CardHeader className="text-2xl font-bold">{agent.name}</CardHeader>
|
||||||
|
|||||||
@@ -52,11 +52,14 @@ export const AgentForm = (props: agentFormProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
||||||
router.push(`/dashboard/agents/${data.id}`);
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
router.push(`/dashboard/agents/${data.id}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -38,12 +38,11 @@ export const BackupButton = (props: BackupButtonProps) => {
|
|||||||
<ButtonWithLoading
|
<ButtonWithLoading
|
||||||
icon={<DatabaseZap/>}
|
icon={<DatabaseZap/>}
|
||||||
disabled={props.disable}
|
disabled={props.disable}
|
||||||
text={isMobile ? "" : "Backup"}
|
|
||||||
isPending={mutation.isPending}
|
isPending={mutation.isPending}
|
||||||
size={"default"}
|
size={"default"}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await HandleAction();
|
await HandleAction();
|
||||||
}}
|
}}
|
||||||
/>
|
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const LoggedInDropdown = (props: LoggedInDropdownProps) => {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{props.children}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{props.children}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" className="w-[--radix-popper-anchor-width]">
|
<DropdownMenuContent side="top" className="min-w-[var(--radix-popper-anchor-width)]">
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
redirect("/dashboard/profile");
|
redirect("/dashboard/profile");
|
||||||
|
|||||||
@@ -10,15 +10,16 @@ import {SidebarMenuCustomMain} from "@/components/wrappers/dashboard/common/side
|
|||||||
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
|
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
|
||||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
|
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
const projectName = env.PROJECT_NAME;
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarLogo/>
|
<SidebarLogo projectName={projectName ?? "Portabase"}/>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<OrganizationCombobox/>
|
<OrganizationCombobox/>
|
||||||
@@ -32,10 +33,10 @@ export function AppSidebar() {
|
|||||||
|
|
||||||
<SidebarMenu className="mb-2">
|
<SidebarMenu className="mb-2">
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<LoggedInButton />
|
<LoggedInButton/>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
<SideBarFooterCredit />
|
<SideBarFooterCredit/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useSidebar } from "@/components/ui/sidebar";
|
import { useSidebar } from "@/components/ui/sidebar";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { env } from "@/env.mjs";
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export const SidebarLogo = () => {
|
export const SidebarLogo = ({projectName}: {
|
||||||
|
projectName: string;
|
||||||
|
}) => {
|
||||||
const { state, isMobile } = useSidebar();
|
const { state, isMobile } = useSidebar();
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export const SidebarLogo = () => {
|
|||||||
<Image
|
<Image
|
||||||
loading="eager"
|
loading="eager"
|
||||||
src={"/images/logo.png"}
|
src={"/images/logo.png"}
|
||||||
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
|
alt={`Logo ${projectName}`}
|
||||||
className="h-10 w-10 object-contain"
|
className="h-10 w-10 object-contain"
|
||||||
height={10}
|
height={10}
|
||||||
width={10}
|
width={10}
|
||||||
@@ -38,7 +39,7 @@ export const SidebarLogo = () => {
|
|||||||
<Image
|
<Image
|
||||||
loading="eager"
|
loading="eager"
|
||||||
src={imageTheme}
|
src={imageTheme}
|
||||||
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
|
alt={`Logo ${projectName}`}
|
||||||
className="object-contain"
|
className="object-contain"
|
||||||
width={190}
|
width={190}
|
||||||
height={90}
|
height={90}
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ export const SidebarMenuCustomMain = () => {
|
|||||||
const groupContent: SidebarGroupItem["group_content"] = [
|
const groupContent: SidebarGroupItem["group_content"] = [
|
||||||
{ title: "Projects", url: "/projects", icon: Layers, details:true },
|
{ title: "Projects", url: "/projects", icon: Layers, details:true },
|
||||||
{ title: "Statistics", url: "/statistics", icon: ChartArea },
|
{ title: "Statistics", url: "/statistics", icon: ChartArea },
|
||||||
|
{ title: "Settings", url: "/settings", icon: Settings, details:true }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
|
// if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
|
||||||
groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
|
// groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
|
||||||
}
|
// }
|
||||||
|
|
||||||
const items: SidebarGroupItem[] = [
|
const items: SidebarGroupItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
|
|||||||
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
|
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
toast.success(result.data.actionSuccess?.message || "Organization Created.");
|
toast.success(result.data.actionSuccess?.message || "Organization Created.");
|
||||||
// router.push("/");
|
|
||||||
router.replace(`/dashboard/home`);
|
router.replace(`/dashboard/home`);
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -82,26 +81,6 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/*<FormField*/}
|
|
||||||
{/* control={form.control}*/}
|
|
||||||
{/* name="slug"*/}
|
|
||||||
{/* defaultValue=""*/}
|
|
||||||
{/* render={({field}) => (*/}
|
|
||||||
{/* <FormItem>*/}
|
|
||||||
{/* <FormLabel>Slug</FormLabel>*/}
|
|
||||||
{/* <FormControl>*/}
|
|
||||||
{/* <Input*/}
|
|
||||||
{/* {...field}*/}
|
|
||||||
{/* onChange={(e) => {*/}
|
|
||||||
{/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
|
|
||||||
{/* field.onChange(value);*/}
|
|
||||||
{/* }}*/}
|
|
||||||
{/* />*/}
|
|
||||||
{/* </FormControl>*/}
|
|
||||||
{/* <FormMessage/>*/}
|
|
||||||
{/* </FormItem>*/}
|
|
||||||
{/* )}*/}
|
|
||||||
{/*/>*/}
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex items-center justify-between w-full">
|
||||||
<Button type="submit">Create</Button>
|
<Button type="submit">Create</Button>
|
||||||
|
|||||||
+2
-3
@@ -2,7 +2,6 @@
|
|||||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import {setCurrentOrganizationSlug} from "@/features/dashboard/organization-cookie";
|
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
@@ -17,17 +16,17 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
|||||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
|
mutationFn: () => deleteOrganizationAction({slug: props.organizationSlug}),
|
||||||
|
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
if (result?.data?.success) {
|
if (result?.data?.success) {
|
||||||
await authClient.organization.setActive({
|
await authClient.organization.setActive({
|
||||||
organizationSlug: "default",
|
organizationSlug: "default",
|
||||||
});
|
});
|
||||||
router.push("/");
|
|
||||||
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
|
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
|
||||||
router.refresh()
|
router.refresh()
|
||||||
refetch()
|
refetch()
|
||||||
|
router.push("/");
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
|
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
|
||||||
|
|||||||
@@ -39,5 +39,6 @@ export function OrganizationCombobox() {
|
|||||||
|
|
||||||
return <>{state === "expanded" &&
|
return <>{state === "expanded" &&
|
||||||
<ComboBox sideBar values={values} defaultValue={activeOrganization?.slug} onValueChange={onValueChange}
|
<ComboBox sideBar values={values} defaultValue={activeOrganization?.slug} onValueChange={onValueChange}
|
||||||
reload={handleReset}/>}</>;
|
reload={handleReset}/>}
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,6 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
|||||||
console.error("Mutation network error:", error);
|
console.error("Mutation network error:", error);
|
||||||
toast.error(error?.message || "A network error occurred.");
|
toast.error(error?.message || "A network error occurred.");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
OrganizationFormSchema
|
OrganizationFormSchema
|
||||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray, or} from "drizzle-orm";
|
||||||
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
|
import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
|
||||||
import {slugify} from "@/utils/slugify";
|
import {slugify} from "@/utils/slugify";
|
||||||
import {Organization} from "@/db/schema/03_organization";
|
import {Organization} from "@/db/schema/03_organization";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {headers} from "next/headers";
|
|
||||||
|
|
||||||
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +83,6 @@ export const updateOrganizationAction = userAction
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -113,61 +111,11 @@ export const updateOrganizationAction = userAction
|
|||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
// await db
|
|
||||||
// .insert(drizzleDb.schemas.member)
|
|
||||||
// .values(
|
|
||||||
// usersToAdd.map((userId) => ({
|
|
||||||
// userId,
|
|
||||||
// organizationId: organization.id,
|
|
||||||
// role: "member",
|
|
||||||
// }))
|
|
||||||
// )
|
|
||||||
// .execute();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usersToRemove.length > 0) {
|
if (usersToRemove.length > 0) {
|
||||||
await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute();
|
await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute();
|
||||||
// TODO : Do not delete, go permission error with better auth
|
|
||||||
// for (const userToRemove of usersToRemove) {
|
|
||||||
//
|
|
||||||
// const memberToRemove = await db.query.member.findFirst({
|
|
||||||
// where: and(eq(drizzleDb.schemas.member.userId, userToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id)),
|
|
||||||
// with: {
|
|
||||||
// user: true
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
// console.log(memberToRemove)
|
|
||||||
//
|
|
||||||
// if (memberToRemove) {
|
|
||||||
// console.log("ici")
|
|
||||||
// await auth.api.removeMember({
|
|
||||||
// body: {
|
|
||||||
// memberIdOrEmail: memberToRemove.user.email,
|
|
||||||
// organizationId: organization.id,
|
|
||||||
// },
|
|
||||||
// headers: await headers()
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// const updatedOrganization = await auth.api.updateOrganization({
|
|
||||||
// body: {
|
|
||||||
// data: {
|
|
||||||
// name: parsedInput.data.name,
|
|
||||||
// slug: parsedInput.data.slug,
|
|
||||||
// },
|
|
||||||
// organizationId: organization.id,
|
|
||||||
// },
|
|
||||||
// headers: await headers(),
|
|
||||||
// });
|
|
||||||
|
|
||||||
|
|
||||||
const updatedOrganization = await db
|
const updatedOrganization = await db
|
||||||
.update(drizzleDb.schemas.organization)
|
.update(drizzleDb.schemas.organization)
|
||||||
.set({
|
.set({
|
||||||
@@ -200,11 +148,24 @@ export const updateOrganizationAction = userAction
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
export const deleteOrganizationAction = userAction.schema(
|
||||||
|
z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
|
slug: z.string().optional(),
|
||||||
|
})
|
||||||
|
).action(
|
||||||
async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
|
async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
|
||||||
try {
|
try {
|
||||||
|
const conditions = [];
|
||||||
|
if (parsedInput.id) {
|
||||||
|
conditions.push(eq(drizzleDb.schemas.organization.id, parsedInput.id));
|
||||||
|
}
|
||||||
|
if (parsedInput.slug) {
|
||||||
|
conditions.push(eq(drizzleDb.schemas.organization.slug, parsedInput.slug));
|
||||||
|
}
|
||||||
|
|
||||||
const org = await db.query.organization.findFirst({
|
const org = await db.query.organization.findFirst({
|
||||||
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
|
where: or(...conditions),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
@@ -221,8 +182,6 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
|||||||
let deletedOrganization: Organization;
|
let deletedOrganization: Organization;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO : Improve with better auth, always getting 403 error
|
|
||||||
// deletedOrganization = await deleteOrganization(org.id) as Organization;
|
|
||||||
[deletedOrganization] = await db
|
[deletedOrganization] = await db
|
||||||
.delete(drizzleDb.schemas.organization)
|
.delete(drizzleDb.schemas.organization)
|
||||||
.where(eq(drizzleDb.schemas.organization.id, org.id))
|
.where(eq(drizzleDb.schemas.organization.id, org.id))
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/us
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/sessions/table-columns";
|
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
|
||||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/accounts/table-columns";
|
import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
|
||||||
import {Session} from "better-auth";
|
import {Session} from "better-auth";
|
||||||
|
|
||||||
export type UserFormProps = {
|
export type UserFormProps = {
|
||||||
@@ -54,8 +54,8 @@ export const UserForm = (props: UserFormProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`Profile updated successfully.`);
|
toast.success(`Profile updated successfully.`);
|
||||||
router.push(`/dashboard/profile`);
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
router.push(`/dashboard/profile`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user