mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
76
Commits
1.2.0
..
1.2.5-rc.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2726e6b18 | ||
|
|
a746ce9169 | ||
|
|
c00b967179 | ||
|
|
f6e749ecf9 | ||
|
|
8f5e0686c8 | ||
|
|
b9f0d9279b | ||
|
|
cd69f0c82f | ||
|
|
884746f3a5 | ||
|
|
9c4d862297 | ||
|
|
cc87cf68b5 | ||
|
|
7c5d57ddc1 | ||
|
|
5653ff18c8 | ||
|
|
83ea444675 | ||
|
|
966edfa85a | ||
|
|
30b9fc7765 | ||
|
|
95a60e9a96 | ||
|
|
9327331adc | ||
|
|
e8baa441cd | ||
|
|
61d142afbd | ||
|
|
46022e8711 | ||
|
|
e59f3c46fd | ||
|
|
7fcb3f2c31 | ||
|
|
a2243585a0 | ||
|
|
6231002043 | ||
|
|
33eb476009 | ||
|
|
ebb0a4c7e5 | ||
|
|
d010ed6bc5 | ||
|
|
1941e29175 | ||
|
|
6e8932ff52 | ||
|
|
f9bfae9b9d | ||
|
|
2fca928cf0 | ||
|
|
8f3a362a3a | ||
|
|
62a6189788 | ||
|
|
2a6d631719 | ||
|
|
4942591d7f | ||
|
|
1760c1b852 | ||
|
|
86d7ab9864 | ||
|
|
f201e4593a | ||
|
|
71129e556e | ||
|
|
0e7ebb79db | ||
|
|
727d9eb57c | ||
|
|
228ff75468 | ||
|
|
80018cac08 | ||
|
|
b2c19e7ed0 | ||
|
|
a64a80910d | ||
|
|
4eeeef3427 | ||
|
|
657586ddff | ||
|
|
797b287899 | ||
|
|
db2d95f2c5 | ||
|
|
16a8f259ab | ||
|
|
ee1cd8d08d | ||
|
|
e53046324c | ||
|
|
dd71977317 | ||
|
|
bee0da1454 | ||
|
|
b60feaa447 | ||
|
|
743e54614a | ||
|
|
bd96713af4 | ||
|
|
8f7f9b457a | ||
|
|
4c0d68eab8 | ||
|
|
8211407cf2 | ||
|
|
5fb6c7d36a | ||
|
|
30b1cf4493 | ||
|
|
f4789ac5fe | ||
|
|
5fd826ae71 | ||
|
|
22ce08f06c | ||
|
|
696c88f03f | ||
|
|
9d5d87e47b | ||
|
|
e32539e02c | ||
|
|
46ba931a6c | ||
|
|
4bac5143d6 | ||
|
|
bcb1674491 | ||
|
|
3cbd9677c2 | ||
|
|
b216ee1a80 | ||
|
|
32ec482449 | ||
|
|
5074748055 | ||
|
|
6fa11f834b |
+6
-1
@@ -2,7 +2,7 @@
|
|||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
# Base de données
|
# Base de données
|
||||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5432/devdb?schema=public
|
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||||
|
|
||||||
# Projet
|
# Projet
|
||||||
PROJECT_NAME="Portabase"
|
PROJECT_NAME="Portabase"
|
||||||
@@ -16,6 +16,10 @@ SMTP_PORT=
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
#SMTP_SECURE="true"
|
||||||
|
#Port 587 -> secure: false
|
||||||
|
#Port 465 -> secure: true
|
||||||
|
#By default : true
|
||||||
|
|
||||||
# Google
|
# Google
|
||||||
AUTH_GOOGLE_ID=
|
AUTH_GOOGLE_ID=
|
||||||
@@ -24,3 +28,4 @@ AUTH_GOOGLE_METHOD=
|
|||||||
|
|
||||||
# Retention
|
# Retention
|
||||||
RETENTION_CRON="* * * * *"
|
RETENTION_CRON="* * * * *"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: # Replace with a single Ko-fi username
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: portabase
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -58,6 +58,11 @@ jobs:
|
|||||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||||
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
|
echo "tags2=$TAGS2" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Set up QEMU (enables multi-arch emulation)
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to Docker Hub account 1
|
- name: Log in to Docker Hub account 1
|
||||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||||
@@ -70,6 +75,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.tags }}
|
tags: ${{ steps.set-tags.outputs.tags }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
@@ -85,6 +91,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ${{ inputs.dockerfile }}
|
file: ${{ inputs.dockerfile }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.set-tags.outputs.tags2 }}
|
tags: ${{ steps.set-tags.outputs.tags2 }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
|
|||||||
+2
-2
@@ -26,5 +26,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.2.0
|
version: 1.2.5-rc.6
|
||||||
date-released: "2026-01-18"
|
date-released: "2026-02-01"
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
[](https://www.buymeacoffee.com/portabase)
|
||||||
|
|
||||||
[](https://www.postgresql.org/)
|
[](https://www.postgresql.org/)
|
||||||
[](https://www.mysql.com/)
|
[](https://www.mysql.com/)
|
||||||
[](https://mariadb.org/)
|
[](https://mariadb.org/)
|
||||||
|
[](https://www.mongodb.com/)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
[](https://github.com/Portabase/portabase)
|
[](https://github.com/Portabase/portabase)
|
||||||
|
|
||||||
@@ -61,6 +63,7 @@ You have 4 ways to install Portabase:
|
|||||||
|
|
||||||
[](https://github.com/Portabase/portabase/graphs/contributors)
|
[](https://github.com/Portabase/portabase/graphs/contributors)
|
||||||
|
|
||||||
|
[](https://www.buymeacoffee.com/portabase)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { PageParams } from "@/types/next";
|
|
||||||
import { Page, PageContent, PageHeader, PageTitle } from "@/features/layout/page";
|
|
||||||
import { AgentForm } from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { db } from "@/db";
|
|
||||||
|
|
||||||
export default async function RoutePage(
|
|
||||||
props: PageParams<{
|
|
||||||
agentId: string;
|
|
||||||
}>
|
|
||||||
) {
|
|
||||||
const { agentId } = await props.params;
|
|
||||||
|
|
||||||
const agent = await db.query.agent.findFirst({
|
|
||||||
where: (fields, { eq }) => eq(fields.id, agentId),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {agent.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<AgentForm defaultValues={agent} agentId={agent.id} />
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
|
||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||||
import {buttonVariants} from "@/components/ui/button";
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
@@ -12,6 +9,8 @@ import {capitalizeFirstLetter} from "@/utils/text";
|
|||||||
import {generateEdgeKey} from "@/utils/edge_key";
|
import {generateEdgeKey} from "@/utils/edge_key";
|
||||||
import {getServerUrl} from "@/utils/get-server-url";
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
|
||||||
|
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||||
|
import {AgentType} from "@/features/agents/agents.schema";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
export default async function RoutePage(props: PageParams<{ agentId: string }>) {
|
||||||
|
|
||||||
@@ -39,10 +38,7 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
|||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<Link className={buttonVariants({variant: "outline"})}
|
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
|
||||||
<GearIcon className="w-7 h-7"/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {AgentForm} from "@/components/wrappers/dashboard/agent/agent-form/agent-form";
|
|
||||||
import {Metadata} from "next";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Agent",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Create new agent</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<AgentForm/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
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 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 {desc, eq, not} from "drizzle-orm";
|
import {desc, eq, not} from "drizzle-orm";
|
||||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
|
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Agents",
|
title: "Agents",
|
||||||
@@ -25,7 +23,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
orderBy: (fields) => desc(fields.lastContact),
|
orderBy: (fields) => desc(fields.lastContact),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
if (!agents) {
|
if (!agents) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
@@ -36,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageTitle>Agents</PageTitle>
|
<PageTitle>Agents</PageTitle>
|
||||||
{agents.length > 0 && (
|
{agents.length > 0 && (
|
||||||
<PageActions>
|
<PageActions>
|
||||||
<Link href={"/dashboard/agents/new"}>
|
<AgentDialog typeTrigger={"create"} />
|
||||||
<Button>+ Create Agent</Button>
|
|
||||||
</Link>
|
|
||||||
</PageActions>
|
</PageActions>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -46,10 +41,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
{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
|
<AgentDialog typeTrigger="empty"/>
|
||||||
url={"/dashboard/agents/new"}
|
|
||||||
text={"Create new Agent"}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
import {notFound} from "next/navigation";
|
|
||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{ projectId: string }>) {
|
|
||||||
const {projectId} = await props.params;
|
|
||||||
|
|
||||||
const proj = await db.query.project.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.project.id, projectId),
|
|
||||||
with: {
|
|
||||||
databases: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proj) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const org = await db.query.organization.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.organization.slug, "default"),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableDatabases = (
|
|
||||||
await db.query.database.findMany({
|
|
||||||
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
|
||||||
with: {
|
|
||||||
agent: true,
|
|
||||||
project: true,
|
|
||||||
backups: true,
|
|
||||||
restorations: true,
|
|
||||||
},
|
|
||||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
// .filter((db) => db.project !== null) as DatabaseWith[];
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Edit {proj.name}</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<ProjectForm
|
|
||||||
organization={org}
|
|
||||||
databases={availableDatabases as DatabaseWith[]}
|
|
||||||
defaultValues={{...proj, databases: proj.databases.map((db) => db.id)}}
|
|
||||||
projectId={proj.id}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
import {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||||
import {buttonVariants} from "@/components/ui/button";
|
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
ButtonDeleteProject
|
ButtonDeleteProject
|
||||||
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
} from "@/components/wrappers/dashboard/projects/button-delete-project/button-delete-project";
|
||||||
@@ -15,6 +12,10 @@ import {eq} from "drizzle-orm";
|
|||||||
import {getActiveMember, 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";
|
||||||
|
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {ProjectWith} from "@/db/schema/06_project";
|
||||||
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
@@ -24,6 +25,10 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
projectId
|
projectId
|
||||||
} = await props.params;
|
} = await props.params;
|
||||||
|
|
||||||
|
if (!isUuidv4(projectId)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
const organization = await getOrganization({});
|
||||||
const activeMember = await getActiveMember()
|
const activeMember = await getActiveMember()
|
||||||
|
|
||||||
@@ -51,6 +56,19 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
redirect("/dashboard/projects");
|
redirect("/dashboard/projects");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const availableDatabases = (
|
||||||
|
await db.query.database.findMany({
|
||||||
|
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
|
||||||
|
with: {
|
||||||
|
agent: true,
|
||||||
|
project: true,
|
||||||
|
backups: true,
|
||||||
|
restorations: true,
|
||||||
|
},
|
||||||
|
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||||
|
})
|
||||||
|
) as DatabaseWith[];
|
||||||
|
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -63,10 +81,12 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
{!isMember && (
|
{!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">
|
||||||
<Link className={buttonVariants({variant: "outline"})}
|
<ProjectDialog
|
||||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
databases={availableDatabases}
|
||||||
<GearIcon className="w-7 h-7"/>
|
organization={org}
|
||||||
</Link>
|
project={proj as ProjectWith}
|
||||||
|
isEdit={true}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {ProjectForm} from "@/components/wrappers/dashboard/projects/project-form/project-form";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import {DatabaseWith} from "@/db/schema/07_database";
|
|
||||||
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{}>) {
|
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableDatabases = (
|
|
||||||
await db.query.database.findMany({
|
|
||||||
where: (db, {isNull}) => isNull(db.projectId),
|
|
||||||
with: {
|
|
||||||
agent: true,
|
|
||||||
project: true,
|
|
||||||
backups: true,
|
|
||||||
restorations: true,
|
|
||||||
},
|
|
||||||
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
|
||||||
})
|
|
||||||
).filter((db) => db.project == null) as DatabaseWith[];
|
|
||||||
|
|
||||||
const org = await db.query.organization.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!org) notFound();
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>Create new project</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<ProjectForm databases={availableDatabases} organization={org}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {PageParams} from "@/types/next";
|
import {PageParams} from "@/types/next";
|
||||||
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 {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
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";
|
||||||
@@ -10,6 +7,8 @@ import {notFound} from "next/navigation";
|
|||||||
import {getActiveMember, 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";
|
import {Metadata} from "next";
|
||||||
|
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Projects",
|
title: "Projects",
|
||||||
@@ -36,6 +35,19 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
});
|
});
|
||||||
const isMember = activeMember?.role === "member";
|
const isMember = activeMember?.role === "member";
|
||||||
|
|
||||||
|
const availableDatabases = (
|
||||||
|
await db.query.database.findMany({
|
||||||
|
where: (db, {isNull}) => isNull(db.projectId),
|
||||||
|
with: {
|
||||||
|
agent: true,
|
||||||
|
project: true,
|
||||||
|
backups: true,
|
||||||
|
restorations: true,
|
||||||
|
},
|
||||||
|
orderBy: (db, {desc}) => [desc(db.createdAt)],
|
||||||
|
})
|
||||||
|
).filter((db) => db.project == null) as DatabaseWith[];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -43,9 +55,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
<PageTitle>Projects</PageTitle>
|
<PageTitle>Projects</PageTitle>
|
||||||
{(projects.length > 0 && !isMember) && (
|
{(projects.length > 0 && !isMember) && (
|
||||||
<PageActions>
|
<PageActions>
|
||||||
<Link href={`/dashboard/projects/new`}>
|
<ProjectDialog databases={availableDatabases} organization={organization}/>
|
||||||
<Button>+ Create Project</Button>
|
|
||||||
</Link>
|
|
||||||
</PageActions>
|
</PageActions>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -62,7 +72,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
) : isMember ? (
|
) : isMember ? (
|
||||||
<EmptyStatePlaceholder text="No project available"/>
|
<EmptyStatePlaceholder text="No project available"/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
|
||||||
)}
|
)}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import {PageParams} from "@/types/next";
|
|
||||||
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
|
||||||
import {notFound} from "next/navigation";
|
|
||||||
import {OrganizationForm} from "@/components/wrappers/dashboard/organization/organization-form/organization-form";
|
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
|
||||||
import {db} from "@/db";
|
|
||||||
import {isNull} from "drizzle-orm";
|
|
||||||
import {currentUser} from "@/lib/auth/current-user";
|
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
|
||||||
|
|
||||||
}>) {
|
|
||||||
const user = await currentUser();
|
|
||||||
|
|
||||||
const organization = await getOrganization({});
|
|
||||||
|
|
||||||
const users = await db.query.user.findMany({
|
|
||||||
where: (fields) => isNull(fields.deletedAt)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !users || !organization || organization.slug == "default") {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return(
|
|
||||||
<Page>
|
|
||||||
<PageHeader>
|
|
||||||
<PageTitle>
|
|
||||||
Edit {organization.name}
|
|
||||||
</PageTitle>
|
|
||||||
</PageHeader>
|
|
||||||
<PageContent>
|
|
||||||
<OrganizationForm currentUser={user} users={users} defaultValues={organization}/>
|
|
||||||
</PageContent>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,9 +9,13 @@ import {getOrganizationChannels} from "@/db/services/notification-channel";
|
|||||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||||
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||||
import {
|
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||||
EditButtonSettings
|
import {Button} from "@/components/ui/button";
|
||||||
} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings";
|
import {GearIcon} from "@radix-ui/react-icons";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {isNull} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
@@ -22,7 +26,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
const activeMember = await getActiveMember()
|
const activeMember = await getActiveMember()
|
||||||
|
|
||||||
if (!organization || !activeMember) {
|
if (!organization || !activeMember || !user) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,6 +34,22 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||||
const permissions = computeOrganizationPermissions(activeMember);
|
const permissions = computeOrganizationPermissions(activeMember);
|
||||||
|
|
||||||
|
const users = await db.query.user.findMany({
|
||||||
|
where: (fields) => isNull(fields.deletedAt)
|
||||||
|
});
|
||||||
|
|
||||||
|
const organizationWithMembers = await db.query.organization.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.organization.id, organization.id),
|
||||||
|
with: {
|
||||||
|
members: {
|
||||||
|
with: {
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organizationWithMembers) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -41,7 +61,11 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
|||||||
<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">
|
||||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||||
<EditButtonSettings/>
|
<EditOrganizationDialog
|
||||||
|
organization={organizationWithMembers}
|
||||||
|
users={users}
|
||||||
|
currentUser={user}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {v4 as uuidv4} from "uuid";
|
import {v4 as uuidv4} from "uuid";
|
||||||
import {eventEmitter} from "../../../events/route";
|
|
||||||
import * as drizzleDb from "@/db";
|
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";
|
||||||
@@ -10,6 +9,7 @@ import {withUpdatedAt} from "@/db/utils";
|
|||||||
import {decryptedDump, getFileExtension} from "./helpers";
|
import {decryptedDump, getFileExtension} from "./helpers";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -34,6 +34,7 @@ export async function POST(
|
|||||||
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;
|
||||||
|
|
||||||
|
|
||||||
if (!generatedId || !isUuidv4(generatedId)) {
|
if (!generatedId || !isUuidv4(generatedId)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "generatedId is not a valid UUID"},
|
{error: "generatedId is not a valid UUID"},
|
||||||
@@ -127,7 +128,8 @@ export async function POST(
|
|||||||
const fileName = `${uuid}${fileExtension}`;
|
const fileName = `${uuid}${fileExtension}`;
|
||||||
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
const buffer = Buffer.from(await decryptedFile.arrayBuffer());
|
||||||
|
|
||||||
await storeBackupFiles(backup, database, buffer, fileName)
|
|
||||||
|
const storageResults = await storeBackupFiles(backup, database, buffer, fileName)
|
||||||
eventEmitter.emit('modification', {update: true});
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
await sendNotificationsBackupRestore(database, "success_backup");
|
await sendNotificationsBackupRestore(database, "success_backup");
|
||||||
@@ -165,4 +167,3 @@ export async function POST(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {eventEmitter} from "../../../events/route";
|
|
||||||
import * as drizzleDb from "@/db";
|
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";
|
||||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
|
||||||
export type BodyResultRestore = {
|
export type BodyResultRestore = {
|
||||||
generatedId: string
|
generatedId: string
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {Body} from "./route";
|
import {Body} from "./route";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
|
||||||
import {Agent} from "@/db/schema/08_agent";
|
import {Agent} from "@/db/schema/08_agent";
|
||||||
import {Database} from "@/db/schema/07_database";
|
import {Database} from "@/db/schema/07_database";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db as dbClient} from "@/db";
|
import {db as dbClient} from "@/db";
|
||||||
import {and, eq, inArray} from "drizzle-orm";
|
import {and, eq, inArray} from "drizzle-orm";
|
||||||
import {EDbmsSchema} from "@/db/schema/types";
|
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
|
||||||
import {SafeActionResult} from "next-safe-action";
|
|
||||||
import {ZodString} from "zod";
|
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import type {StorageInput} from "@/features/storages/types";
|
import type {StorageInput} from "@/features/storages/types";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
@@ -50,6 +46,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||||
|
console.log(`Database type not available: ${db.dbms}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const [databaseCreated] = await dbClient
|
const [databaseCreated] = await dbClient
|
||||||
.insert(drizzleDb.schemas.database)
|
.insert(drizzleDb.schemas.database)
|
||||||
.values({
|
.values({
|
||||||
@@ -77,10 +79,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
// const backup = await dbClient.query.backup.findFirst({
|
|
||||||
// where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
|
||||||
// })
|
|
||||||
|
|
||||||
const activeBackup = await dbClient.query.backup.findFirst({
|
const activeBackup = await dbClient.query.backup.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||||
@@ -110,25 +108,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
restoreAction = true
|
restoreAction = true
|
||||||
|
|
||||||
|
|
||||||
// const backupToRestore = await dbClient.query.backup.findFirst({
|
|
||||||
// where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
|
||||||
// with: {
|
|
||||||
// database: {
|
|
||||||
// with: {
|
|
||||||
// project: true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// })
|
|
||||||
|
|
||||||
// const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
|
||||||
// if (!settings) {
|
|
||||||
// return NextResponse.json(
|
|
||||||
// {error: "Unable to find settings"},
|
|
||||||
// {status: 500}
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
||||||
restoreAction = false
|
restoreAction = false
|
||||||
continue;
|
continue;
|
||||||
@@ -141,6 +120,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
path: restoration.backupStorage.path,
|
path: restoration.backupStorage.path,
|
||||||
signedUrl: true,
|
signedUrl: true,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: restoration.backupStorage.storageChannelId,
|
||||||
|
fileKind: "backups"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {handleDatabases} from "./helpers";
|
import {handleDatabases} from "./helpers";
|
||||||
import {eventEmitter} from "../../../events/route";
|
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {EDbmsSchema} from "@/db/schema/types";
|
import {EDbmsSchema} from "@/db/schema/types";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
|
||||||
export type databaseAgent = {
|
export type databaseAgent = {
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import {EventEmitter} from 'events';
|
|||||||
import {auth} from "@/lib/auth/auth";
|
import {auth} from "@/lib/auth/auth";
|
||||||
import {headers} from "next/headers";
|
import {headers} from "next/headers";
|
||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
|
||||||
export const eventEmitter = new EventEmitter();
|
// export const eventEmitter = new EventEmitter();
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {db} from "@/db";
|
|
||||||
import {eq} from "drizzle-orm";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
import type {StorageInput} from "@/features/storages/types";
|
import type {StorageInput} from "@/features/storages/types";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
import {Readable} from "node:stream";
|
import {Readable} from "node:stream";
|
||||||
@@ -15,17 +12,10 @@ export async function GET(
|
|||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const expires = searchParams.get('expires');
|
const expires = searchParams.get('expires');
|
||||||
const pathFromUrl = searchParams.get('path');
|
const pathFromUrl = searchParams.get('path');
|
||||||
|
const storageId = searchParams.get('storageId');
|
||||||
|
|
||||||
if (!pathFromUrl) {
|
if (!pathFromUrl || !storageId) {
|
||||||
return NextResponse.json({error: "Missing file path in search params"}, {status: 404})
|
return NextResponse.json({error: "Missing search params"}, {status: 404})
|
||||||
}
|
|
||||||
|
|
||||||
const localStorageChannel = await db.query.storageChannel.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.storageChannel.provider, "local"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!localStorageChannel) {
|
|
||||||
return NextResponse.json({error: "No local storage channel found"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const input: StorageInput = {
|
const input: StorageInput = {
|
||||||
@@ -34,17 +24,20 @@ export async function GET(
|
|||||||
path: pathFromUrl,
|
path: pathFromUrl,
|
||||||
signedUrl: true,
|
signedUrl: true,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: storageId,
|
||||||
|
fileKind: "backups",
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.debug(input);
|
console.debug(input);
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, localStorageChannel.id);
|
const result = await dispatchStorage(input, undefined, storageId);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return NextResponse.json({error: "Enable to get file from local storage channel, an error occurred !"})
|
return NextResponse.json({error: "Enable to get file from provided storage channel, an error occurred !"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fileName = path.basename(pathFromUrl);
|
const fileName = path.basename(pathFromUrl);
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -64,14 +57,14 @@ export async function GET(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.file || !Buffer.isBuffer(result.file)) {
|
if (!result.file || !(result.file instanceof Readable)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Invalid file payload"},
|
{error: "Invalid file payload"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = Readable.from(result.file);
|
const fileStream = Readable.from(result.file as Readable);
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {auth} from "@/lib/auth/auth";
|
import {auth} from "@/lib/auth/auth";
|
||||||
import {headers} from "next/headers";
|
import {headers} from "next/headers";
|
||||||
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
|
||||||
import {env} from "@/env.mjs";
|
|
||||||
import * as stream from "node:stream";
|
|
||||||
import path from "path";
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
@@ -16,28 +12,20 @@ export async function GET(
|
|||||||
req: Request,
|
req: Request,
|
||||||
{params}: { params: Promise<{ fileName: string }> }
|
{params}: { params: Promise<{ fileName: string }> }
|
||||||
) {
|
) {
|
||||||
|
const {searchParams} = new URL(req.url);
|
||||||
|
|
||||||
const fileName = (await params).fileName;
|
const fileName = (await params).fileName;
|
||||||
|
const storageId = searchParams.get('storageId');
|
||||||
|
|
||||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||||
|
|
||||||
const session = await auth.api.getSession({headers: await headers()});
|
const session = await auth.api.getSession({headers: await headers()});
|
||||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||||
|
|
||||||
const settings = await db.query.setting.findFirst({
|
|
||||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
|
||||||
with: {
|
|
||||||
storageChannel: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!settings || !settings.storageChannel) {
|
if (!storageId) {
|
||||||
return NextResponse.json({error: "Unable to get settings or no default storage channel"});
|
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||||
const contentType =
|
const contentType =
|
||||||
ext === "png"
|
ext === "png"
|
||||||
@@ -58,19 +46,24 @@ export async function GET(
|
|||||||
action: "get",
|
action: "get",
|
||||||
data: {
|
data: {
|
||||||
path: path,
|
path: path,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: storageId,
|
||||||
|
fileKind: "images"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, settings.storageChannel.id);
|
const result = await dispatchStorage(input, undefined, storageId);
|
||||||
|
|
||||||
if (!result.file || !Buffer.isBuffer(result.file)) {
|
if (!result.file || !(result.file instanceof Readable)) {
|
||||||
|
console.error(`An error occurred while getting file :`, result);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{error: "Invalid file payload"},
|
{error: "Invalid file payload"},
|
||||||
{status: 500}
|
{status: 500}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = Readable.from(result.file);
|
const fileStream = Readable.from(result.file as Readable);
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const code = url.searchParams.get("code");
|
||||||
|
|
||||||
|
const success = Boolean(code);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>${success ? "Success" : "Failed"}</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
status: success ? 200 : 400,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,11 @@ 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.4-rc.2
|
# image: solucetechnologies/portabase:latest
|
||||||
ports:
|
ports:
|
||||||
- '8887:80'
|
- '8887:80'
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ RUN pnpm run build
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM base AS prod
|
FROM base AS prod
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -70,6 +69,9 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
@@ -81,14 +83,18 @@ RUN mkdir -p /app/private/uploads
|
|||||||
RUN chown -R nextjs:nodejs /app/private
|
RUN chown -R nextjs:nodejs /app/private
|
||||||
RUN chown -R nextjs:nodejs /app/public
|
RUN chown -R nextjs:nodejs /app/public
|
||||||
|
|
||||||
|
|
||||||
COPY --from=builder /app/next.config.ts ./
|
COPY --from=builder /app/next.config.ts ./
|
||||||
|
COPY --from=builder /app/server ./server
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
|
||||||
COPY --from=builder /app/portabase.config.ts ./
|
COPY --from=builder /app/portabase.config.ts ./
|
||||||
COPY --from=builder /app/drizzle.config.ts ./
|
COPY --from=builder /app/drizzle.config.ts ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
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
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ else
|
|||||||
echo "[WARN] No TZ provided, using default container timezone"
|
echo "[WARN] No TZ provided, using default container timezone"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
pnpm run start
|
||||||
node server.js
|
#node server.js
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ const nextConfig: NextConfig = {
|
|||||||
bodySizeLimit: "10gb",
|
bodySizeLimit: "10gb",
|
||||||
},
|
},
|
||||||
proxyClientMaxBodySize: '10gb',
|
proxyClientMaxBodySize: '10gb',
|
||||||
|
|
||||||
},
|
},
|
||||||
async headers() {
|
async headers() {
|
||||||
return [
|
return [
|
||||||
|
|||||||
+12
-6
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.2.0",
|
"version": "1.2.5-rc.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "NODE_ENV=development tsx watch server/server.ts",
|
||||||
"build": "next build --experimental-build-mode compile",
|
"build": "next build --experimental-build-mode compile",
|
||||||
"start": "next start",
|
"start": "NODE_ENV=production tsx server/server.ts",
|
||||||
|
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"email": "email dev --dir ./src/components/emails",
|
"email": "email dev --dir ./src/components/emails",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"@t3-oss/env-nextjs": "^0.13.4",
|
"@t3-oss/env-nextjs": "^0.13.4",
|
||||||
"@tanstack/react-query": "^5.76.1",
|
"@tanstack/react-query": "^5.76.1",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@zenstackhq/runtime": "2.14.2",
|
"@zenstackhq/runtime": "2.14.2",
|
||||||
@@ -62,11 +64,13 @@
|
|||||||
"drizzle-orm": "^0.43.1",
|
"drizzle-orm": "^0.43.1",
|
||||||
"drizzle-zod": "^0.7.1",
|
"drizzle-zod": "^0.7.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"googleapis": "^170.1.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next": "16.0.10",
|
"next": "16.1.5",
|
||||||
"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",
|
||||||
@@ -86,6 +90,7 @@
|
|||||||
"react-twc": "^1.4.2",
|
"react-twc": "^1.4.2",
|
||||||
"react-use-measure": "^2.1.7",
|
"react-use-measure": "^2.1.7",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
@@ -109,8 +114,9 @@
|
|||||||
"@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",
|
||||||
"baseline-browser-mapping": "^2.8.32",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "^0.31.1",
|
||||||
|
"esbuild": "^0.27.2",
|
||||||
"eslint": "^9.39.0",
|
"eslint": "^9.39.0",
|
||||||
"eslint-config-next": "^16.0.1",
|
"eslint-config-next": "^16.0.1",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||||
@@ -122,5 +128,5 @@
|
|||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"zenstack": "2.14.2"
|
"zenstack": "2.14.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.27.0"
|
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1945
-2246
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,13 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
- '@prisma/client'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- argon2
|
||||||
|
- bcrypt
|
||||||
|
- cpu-features
|
||||||
|
- esbuild
|
||||||
|
- prisma
|
||||||
|
- protobufjs
|
||||||
|
- sharp
|
||||||
|
- ssh2
|
||||||
|
- unrs-resolver
|
||||||
- zenstack
|
- zenstack
|
||||||
|
|||||||
@@ -54,11 +54,12 @@ function checkRouteExists(pathname: string) {
|
|||||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||||
/^\/api\/files\/?$/,
|
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||||
/^\/api\/images\/[^/]+\/?$/,
|
/^\/api\/files\/backups\/?$/,
|
||||||
/^\/api\/events\/?$/,
|
/^\/api\/events\/?$/,
|
||||||
/^\/api\/init\/?$/,
|
/^\/api\/init\/?$/,
|
||||||
/^\/api\/config\/?$/,
|
/^\/api\/config\/?$/,
|
||||||
|
/^\/api\/google\/drive\/callback\/?$/,
|
||||||
];
|
];
|
||||||
return routePatterns.some((pattern) => pattern.test(pathname));
|
return routePatterns.some((pattern) => pattern.test(pathname));
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
import express from "express";
|
||||||
|
import router from "../src/features/api/router";
|
||||||
|
|
||||||
|
export function mountApi(app: express.Express) {
|
||||||
|
app.use("/services/v1", router);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import next from "next";
|
||||||
|
import express from "express";
|
||||||
|
import {mountApi} from "./api";
|
||||||
|
|
||||||
|
const port = Number(process.env.PORT) || 8887;
|
||||||
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
const nextApp = next({
|
||||||
|
dev,
|
||||||
|
hostname: "0.0.0.0",
|
||||||
|
turbopack: dev,
|
||||||
|
port
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = nextApp.getRequestHandler();
|
||||||
|
|
||||||
|
await nextApp.prepare();
|
||||||
|
|
||||||
|
const server = express();
|
||||||
|
|
||||||
|
mountApi(server);
|
||||||
|
|
||||||
|
server.use((req, res, next) => {
|
||||||
|
if (req.path.startsWith('/services/v1')) return next();
|
||||||
|
handle(req, res).catch((err) => {
|
||||||
|
console.error('Next.js App Router error:', err);
|
||||||
|
res.status(500).send('Internal Server Error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "0.0.0.0", () => {
|
||||||
|
console.log(
|
||||||
|
`NEXT APP → http://localhost:${port}`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`API → http://localhost:${port}/services/v1`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
@@ -58,7 +58,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
"max-h-[90vh] overflow-y-auto",
|
"max-h-[90vh] overflow-y-auto overflow-x-hidden",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {cn} from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import {Plus} from "lucide-react";
|
import {Plus} from "lucide-react";
|
||||||
|
import {forwardRef, HTMLAttributes} from "react";
|
||||||
|
|
||||||
type EmptyStatePlaceholderProps = {
|
type EmptyStatePlaceholderProps = {
|
||||||
url?: string;
|
url?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
text: string;
|
text: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
} & HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
export const EmptyStatePlaceholder = ({
|
export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
|
||||||
url,
|
url,
|
||||||
onClick,
|
onClick,
|
||||||
text,
|
text,
|
||||||
className,
|
className,
|
||||||
}: EmptyStatePlaceholderProps) => {
|
...props
|
||||||
|
}, ref) => {
|
||||||
const Container = (
|
const Container = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,5 +39,7 @@ export const EmptyStatePlaceholder = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className={cn(className)}>{Container}</div>;
|
return <div className={cn(className)} ref={ref} {...props}>{Container}</div>;
|
||||||
};
|
});
|
||||||
|
|
||||||
|
EmptyStatePlaceholder.displayName = "EmptyStatePlaceholder";
|
||||||
@@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|||||||
* Variants for the multi-select component to handle different styles.
|
* Variants for the multi-select component to handle different styles.
|
||||||
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
|
||||||
*/
|
*/
|
||||||
const multiSelectVariants = cva("m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300", {
|
const multiSelectVariants = cva("m-1 transition ease-in-out duration-300", {
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "border-foreground/10 text-foreground bg-card hover:bg-card/80",
|
default: "border-foreground/10 text-foreground bg-card hover:bg-card/80",
|
||||||
@@ -177,13 +177,13 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
|||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
key={value}
|
key={value}
|
||||||
className={cn(isAnimating ? "animate-bounce" : "", multiSelectVariants({ variant }))}
|
className={cn(isAnimating ? "animate-bounce" : "", "max-w-full", multiSelectVariants({ variant }))}
|
||||||
style={{ animationDuration: `${animation}s` }}
|
style={{ animationDuration: `${animation}s` }}
|
||||||
>
|
>
|
||||||
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />}
|
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />}
|
||||||
{option?.label}
|
<span className="truncate">{option?.label}</span>
|
||||||
<XCircle
|
<XCircle
|
||||||
className="ml-2 h-4 w-4 cursor-pointer"
|
className="ml-2 h-4 w-4 cursor-pointer shrink-0"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
toggleOption(value);
|
toggleOption(value);
|
||||||
@@ -196,6 +196,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
|||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
|
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
|
||||||
|
"max-w-full",
|
||||||
isAnimating ? "animate-bounce" : "",
|
isAnimating ? "animate-bounce" : "",
|
||||||
multiSelectVariants({ variant })
|
multiSelectVariants({ variant })
|
||||||
)}
|
)}
|
||||||
@@ -203,7 +204,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
|||||||
>
|
>
|
||||||
{`+ ${selectedValues.length - maxCount} more`}
|
{`+ ${selectedValues.length - maxCount} more`}
|
||||||
<XCircle
|
<XCircle
|
||||||
className="ml-2 h-4 w-4 cursor-pointer"
|
className="ml-2 h-4 w-4 cursor-pointer shrink-0"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
clearExtraOptions();
|
clearExtraOptions();
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ export const NotificationChannelFormSchema = ChannelFormSchema.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
export const StorageChannelFormSchema = ChannelFormSchema.extend({
|
||||||
provider: z.enum(["local", "s3"], {required_error: "Provider is required"}),
|
provider: z.enum(["local", "s3", "google-drive"], {required_error: "Provider is required"}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-14
@@ -106,10 +106,10 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
|||||||
<Card
|
<Card
|
||||||
key={type.value}
|
key={type.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all",
|
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
|
||||||
type.preview
|
type.preview
|
||||||
? "cursor-not-allowed bg-gray-200 border-gray-300 opacity-70"
|
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
|
||||||
: "cursor-pointer hover:bg-accent/50 hover:border-primary/50"
|
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50"
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (type.preview) return;
|
if (type.preview) return;
|
||||||
@@ -117,28 +117,40 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
|||||||
form.setValue("config", {});
|
form.setValue("config", {});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
||||||
{type.preview && (
|
{type.preview && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
|
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 right-0 w-38 h-6 flex items-center justify-center
|
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
|
||||||
transform -rotate-45 translate-x-14 -translate-y-4"
|
transform -rotate-45 translate-x-14 -translate-y-4"
|
||||||
style={{backgroundColor: "#FE6702"}}
|
style={{backgroundColor: "#FE6702"}}
|
||||||
>
|
>
|
||||||
<span className="text-white text-[8px] pr-3 font-medium text-center w-full">coming soon</span>
|
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={cn(
|
<div
|
||||||
"h-10 w-10 rounded-full flex items-center justify-center",
|
className={cn(
|
||||||
type.preview ? "bg-gray-400" : "bg-secondary"
|
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
|
||||||
)}>
|
type.preview
|
||||||
<Icon className={cn("h-6 w-6 text-foreground", type.preview && "text-gray-700")}/>
|
? "bg-muted"
|
||||||
|
: "bg-accent text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn(
|
<span
|
||||||
"font-medium text-sm align-middle text-center",
|
className={cn(
|
||||||
type.preview ? "text-gray-500" : "text-foreground"
|
"font-medium text-sm text-center leading-tight",
|
||||||
)}>
|
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
{type.label}
|
{type.label}
|
||||||
</span>
|
</span>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -239,3 +251,4 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
|||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
@@ -46,6 +46,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success("Successfully connected to storage channel");
|
toast.success("Successfully connected to storage channel");
|
||||||
} else {
|
} else {
|
||||||
|
console.error(result);
|
||||||
toast.error("An error occurred while testing the storage channel, check your configuration");
|
toast.error("An error occurred while testing the storage channel, check your configuration");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+33
-1
@@ -3,7 +3,6 @@ import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/compon
|
|||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {Separator} from "@/components/ui/separator";
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
|
||||||
|
|
||||||
type NotifierNtfyFormProps = {
|
type NotifierNtfyFormProps = {
|
||||||
form: UseFormReturn<any, any, any>
|
form: UseFormReturn<any, any, any>
|
||||||
}
|
}
|
||||||
@@ -12,6 +11,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1"/>
|
<Separator className="my-1"/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.ntfyTopic"
|
name="config.ntfyTopic"
|
||||||
@@ -25,6 +25,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.ntfyServerUrl"
|
name="config.ntfyServerUrl"
|
||||||
@@ -39,6 +40,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.ntfyToken"
|
name="config.ntfyToken"
|
||||||
@@ -53,6 +55,36 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Separator className="my-1"/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.ntfyUsername"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Basic Auth Username (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="username"/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.ntfyPassword"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Basic Auth Password (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="password" placeholder="password"/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {UseFormReturn} from "react-hook-form";
|
||||||
|
import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Separator} from "@/components/ui/separator";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {PasswordInput} from "@/components/ui/password-input";
|
||||||
|
import {
|
||||||
|
googleDriveRefreshTokenAction
|
||||||
|
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive/helpers";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
type StorageGoogleDriveFormProps = {
|
||||||
|
form: UseFormReturn<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||||
|
const refreshToken = form.watch("config.refreshToken");
|
||||||
|
const isConnected = Boolean(refreshToken);
|
||||||
|
|
||||||
|
const handleConnect = () => {
|
||||||
|
const clientId = form.getValues("config.clientId");
|
||||||
|
const clientSecret = form.getValues("config.clientSecret");
|
||||||
|
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
form.setError("config.clientId", {message: "Client ID and Secret are required"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = encodeURIComponent("https://www.googleapis.com/auth/drive.file");
|
||||||
|
const oauthUrl =
|
||||||
|
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||||
|
`?client_id=${clientId}` +
|
||||||
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=code` +
|
||||||
|
`&scope=${scope}` +
|
||||||
|
`&access_type=offline` +
|
||||||
|
`&prompt=consent`;
|
||||||
|
|
||||||
|
const oauthWindow = window.open(oauthUrl, "_blank", "width=500,height=600");
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
if (!oauthWindow || oauthWindow.closed) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthWindow.location.href.startsWith(redirectUri)) {
|
||||||
|
const code = new URL(oauthWindow.location.href).searchParams.get("code");
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const result = await googleDriveRefreshTokenAction({
|
||||||
|
code,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
redirectUri,
|
||||||
|
});
|
||||||
|
const inner = result?.data;
|
||||||
|
|
||||||
|
if (inner?.success) {
|
||||||
|
toast.success(inner.actionSuccess?.message);
|
||||||
|
|
||||||
|
if (!inner.value) {
|
||||||
|
form.setError("config", {message: "OAuth succeeded but no refresh token returned"});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setValue("config.refreshToken", inner.value, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
toast.error(inner?.actionError?.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
oauthWindow.close();
|
||||||
|
clearInterval(interval);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cross-origin errors until redirect
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Separator className="my-1"/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.clientId"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="xxxx.apps.googleusercontent.com"/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.clientSecret"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client Secret</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="config.folderId"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Folder ID</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" onClick={handleConnect}>
|
||||||
|
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isConnected && (
|
||||||
|
<span className="text-sm text-green-600 font-medium">
|
||||||
|
Google Drive connected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
"use server"
|
||||||
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
|
||||||
|
|
||||||
|
export const googleDriveRefreshTokenAction = userAction.schema(
|
||||||
|
z.object({
|
||||||
|
code: z.string(),
|
||||||
|
clientId: z.string(),
|
||||||
|
clientSecret: z.string(),
|
||||||
|
redirectUri: z.string(),
|
||||||
|
})).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
|
||||||
|
const {code, clientId, clientSecret, redirectUri} = parsedInput;
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code: code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await tokenRes.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: tokens.refresh_token,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Refresh token successfully fetched",
|
||||||
|
messageParams: {code: code},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "An error occurred",
|
||||||
|
status: 404,
|
||||||
|
messageParams: {code: code},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
@@ -29,6 +29,9 @@ import {LucideProps} from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
StorageS3Form
|
StorageS3Form
|
||||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form";
|
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/s3.form";
|
||||||
|
import {
|
||||||
|
StorageGoogleDriveForm
|
||||||
|
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/storages/forms/google-drive.form";
|
||||||
|
|
||||||
export type ChannelKind = "notification" | "storage";
|
export type ChannelKind = "notification" | "storage";
|
||||||
|
|
||||||
@@ -86,6 +89,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
|||||||
return <NotifierWebhookForm form={form}/>;
|
return <NotifierWebhookForm form={form}/>;
|
||||||
case "s3":
|
case "s3":
|
||||||
return <StorageS3Form form={form}/>
|
return <StorageS3Form form={form}/>
|
||||||
|
case "google-drive":
|
||||||
|
return <StorageGoogleDriveForm form={form}/>
|
||||||
case "local":
|
case "local":
|
||||||
return <></>
|
return <></>
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/
|
|||||||
|
|
||||||
export const storageProviders: ProviderIconTypes[] = [
|
export const storageProviders: ProviderIconTypes[] = [
|
||||||
{value: "local", label: "Local", icon: Server},
|
{value: "local", label: "Local", icon: Server},
|
||||||
{value: "s3", label: "s3", icon: S3Icon},
|
{value: "s3", label: "S3", icon: S3Icon},
|
||||||
|
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
||||||
|
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon, preview: true},
|
||||||
|
{value: "gcs", label: "Google Cloud Storage", icon: GCSIcon, preview: true},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
||||||
@@ -16,3 +19,58 @@ export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GoogleDriveIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={256} height={199} {...props} viewBox="0 0 256 229"><path fill="#0066da" d="m19.354 196.034l11.29 19.5c2.346 4.106 5.718 7.332 9.677 9.678q17.009-21.591 23.68-33.137q6.77-11.717 16.641-36.655q-26.604-3.502-40.32-3.502q-13.165 0-40.322 3.502c0 4.545 1.173 9.09 3.519 13.196z"/><path fill="#ea4335" d="M215.681 225.212c3.96-2.346 7.332-5.572 9.677-9.677l4.692-8.064l22.434-38.855a26.57 26.57 0 0 0 3.518-13.196q-27.315-3.502-40.247-3.502q-13.899 0-40.248 3.502q9.754 25.075 16.422 36.655q6.724 11.683 23.752 33.137"/><path fill="#00832d" d="M128.001 73.311q19.68-23.768 27.125-36.655q5.996-10.377 13.196-33.137C164.363 1.173 159.818 0 155.126 0h-54.25C96.184 0 91.64 1.32 87.68 3.519q9.16 26.103 15.544 37.154q7.056 12.213 24.777 32.638"/><path fill="#2684fc" d="M175.36 155.42H80.642l-40.32 69.792c3.958 2.346 8.503 3.519 13.195 3.519h148.968c4.692 0 9.238-1.32 13.196-3.52z"/><path fill="#00ac47" d="M128.001 73.311L87.681 3.52c-3.96 2.346-7.332 5.571-9.678 9.677L3.519 142.224A26.57 26.57 0 0 0 0 155.42h80.642z"/><path fill="#ffba00" d="m215.242 77.71l-37.243-64.514c-2.345-4.106-5.718-7.331-9.677-9.677l-40.32 69.792l47.358 82.109h80.496c0-4.546-1.173-9.09-3.519-13.196z"/></svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function BlobIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width={256} height={199} {...props} viewBox="0 0 48 48">
|
||||||
|
<path fill="#2979ff"
|
||||||
|
d="M32.66,7H15.34c-0.715,0-1.375,0.381-1.732,1l-8.66,15c-0.357,0.619-0.357,1.381,0,2l8.66,15c0.357,0.619,1.018,1,1.732,1H32.66c0.715,0,1.375-0.381,1.732-1l8.66-15c0.357-0.619,0.357-1.381,0-2l-8.66-15C34.035,7.381,33.375,7,32.66,7z"></path>
|
||||||
|
<path fill="#fff"
|
||||||
|
d="M32,35H16c-0.552,0-1-0.448-1-1V14c0-0.552,0.448-1,1-1h13l4,4v17C33,34.552,32.552,35,32,35z"></path>
|
||||||
|
<path fill="#b1cfff" d="M29,13v3c0,0.552,0.448,1,1,1h3L29,13z"></path>
|
||||||
|
<path fill="#03a9f4"
|
||||||
|
d="M22.265 23.831H20.93V19.58l-1.312.383v-1.011l2.524-.873h.122V23.831zM28.702 21.48c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446-.245.097-.515.146-.81.146-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081v-1.051c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446C26.126 18.049 26.397 18 26.695 18c.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758.099.305.149.665.149 1.078V21.48zM27.37 20.26c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063-.105 0-.2.021-.283.063-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208.084.042.179.063.284.063.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V20.26zM23.308 29.571c0 .416-.049.776-.146 1.081-.097.303-.234.556-.411.756-.176.2-.387.349-.632.446C21.874 31.951 21.604 32 21.309 32c-.298 0-.57-.049-.816-.146-.246-.097-.457-.246-.633-.446-.176-.2-.315-.453-.413-.756-.099-.305-.147-.665-.147-1.081V28.52c0-.413.049-.773.146-1.078.097-.305.234-.558.411-.758.176-.2.387-.349.632-.446.245-.097.516-.146.814-.146.295 0 .565.049.811.146.246.097.458.246.635.446.176.2.313.453.412.758s.149.665.149 1.078V29.571zM21.977 28.35c0-.229-.016-.421-.047-.579-.032-.155-.076-.283-.134-.381-.058-.097-.129-.167-.212-.209-.083-.042-.176-.063-.282-.063s-.2.021-.283.063c-.083.042-.153.112-.209.209-.057.097-.1.225-.13.381-.03.158-.045.35-.045.579v1.383c0 .234.014.431.045.589.03.158.075.286.133.381.058.097.129.166.213.208s.179.063.284.063c.103 0 .195-.021.278-.063.083-.042.154-.111.212-.208.058-.095.101-.223.132-.381.03-.158.046-.354.046-.589V28.35zM27.659 31.921h-1.335V27.67l-1.312.383v-1.011l2.524-.873h.122V31.921z"></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GCSIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
id="standard_product_icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g id="bounding_box">
|
||||||
|
<rect width="512" height="512" fill="none"/>
|
||||||
|
</g>
|
||||||
|
<g id="art">
|
||||||
|
<path
|
||||||
|
fill="#34a853"
|
||||||
|
d="M442,277.9H70c-8.8,0-16,7.2-16,16v148.1c0,8.8,7.2,16,16,16h107.2c1.5.5,3.1.7,4.8.7s3.3-.3,4.8-.7h255.2c8.8,0,16-7.2,16-16v-148.1c0-8.8-7.2-16-16-16ZM86,309.9h80v116.1h-80v-116.1ZM426,425.9h-228v-116.1h228v116.1Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#fbbc04"
|
||||||
|
d="M442,54H70c-8.8,0-16,7.2-16,16v148.8c0,8.8,7.2,16,16,16h372c8.8,0,16-7.2,16-16V70c0-8.8-7.2-16-16-16ZM86,86h80v116.8h-80v-116.8ZM426,202.8h-228v-116.8h228v116.8Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#ea4335"
|
||||||
|
d="M442,234.8h-16V86H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.8c0,8.8-7.2,16-16,16Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#4285f4"
|
||||||
|
d="M442,457.9h-16v-148.1H54v-16c0-8.8,7.2-16,16-16h372c8.8,0,16,7.2,16,16v148.1c0,8.8-7.2,16-16,16Z"
|
||||||
|
/>
|
||||||
|
<circle fill="#ea4335" cx="349" cy="144.4" r="37"/>
|
||||||
|
<circle fill="#4285f4" cx="349" cy="367.9" r="37"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ import {Trash2} from "lucide-react";
|
|||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
import {deleteOrganizationAction} from "@/features/organization/organization.action";
|
||||||
|
|
||||||
export type ButtonDeleteFleetProps = {
|
export type ButtonDeleteFleetProps = {
|
||||||
text?: string;
|
text?: string;
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ import {Input} from "@/components/ui/input";
|
|||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||||
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
import {updateOrganizationAction} from "@/features/organization/organization.action";
|
||||||
|
|
||||||
type UpdateOrganizationFormProps = {
|
type UpdateOrganizationFormProps = {
|
||||||
onSuccessAction?: () => void;
|
onSuccessAction?: () => void;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
|
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {sendEmail} from "@/lib/email/email-helper";
|
import {sendEmail} from "@/lib/email";
|
||||||
import {render} from "@react-email/components";
|
import {render} from "@react-email/components";
|
||||||
import EmailSettingsTest from "@/components/emails/email-settings-test";
|
import EmailSettingsTest from "@/components/emails/email-settings-test";
|
||||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {Setting} from "@/db/schema/01_setting";
|
|||||||
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
import {SettingsEmailSection} from "@/components/wrappers/dashboard/admin/settings/email/settings-email-section";
|
||||||
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
import {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
||||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
|
import {MailboxIcon, Save} from "lucide-react";
|
||||||
|
|
||||||
export type SettingsTabsProps = {
|
export type SettingsTabsProps = {
|
||||||
settings: Setting
|
settings: Setting
|
||||||
@@ -30,29 +31,44 @@ export const SettingsTabs = ({settings, storageChannels}: SettingsTabsProps) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'System Email',
|
||||||
|
value: 'email',
|
||||||
|
icon: MailboxIcon,
|
||||||
|
content: (
|
||||||
|
<SettingsEmailSection settings={settings}/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Default Storage',
|
||||||
|
value: 'storage',
|
||||||
|
icon: Save,
|
||||||
|
content: (
|
||||||
|
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full mt-3">
|
<div className="h-full mt-3">
|
||||||
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
|
<Tabs className="h-full gap-4" value={tab} onValueChange={handleChangeTab}>
|
||||||
<TabsList className='bg-background rounded-none border-b p-0 min-w-48'>
|
<TabsList>
|
||||||
<TabsTrigger
|
{tabs.map(({icon: Icon, name, value}) => (
|
||||||
value="email"
|
<TabsTrigger key={value} value={value} className='flex items-center gap-1 px-2.5 sm:px-3'>
|
||||||
className='bg-background data-[state=active]:border-primary dark:data-[state=active]:border-primary h-full rounded-none border-0 border-b-2 border-transparent data-[state=active]:shadow-none'
|
<Icon/>
|
||||||
>
|
{name}
|
||||||
Email
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="storage"
|
|
||||||
className='bg-background data-[state=active]:border-primary dark:data-[state=active]:border-primary h-full rounded-none border-0 border-b-2 border-transparent data-[state=active]:shadow-none'
|
|
||||||
>
|
|
||||||
Default storage
|
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent className="h-full" value="email">
|
|
||||||
<SettingsEmailSection settings={settings}/>
|
{tabs.map(tab => (
|
||||||
</TabsContent>
|
<TabsContent key={tab.value} value={tab.value} className="h-full">
|
||||||
<TabsContent className="h-full" value="storage">
|
{tab.content}
|
||||||
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -64,14 +64,14 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
await mutation.mutateAsync(values);
|
await mutation.mutateAsync(values);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="storageChannelId"
|
name="storageChannelId"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center justify-center">
|
<FormItem className="flex-grow min-w-[200px] sm:flex-grow-0 sm:w-64">
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger className="w-90 h-full mb-0">
|
<SelectTrigger className="w-full h-full mb-0">
|
||||||
<SelectValue placeholder="Select a default storage channel" />
|
<SelectValue placeholder="Select a default storage channel" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -80,8 +80,7 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getChannelIcon(channel.provider)}
|
{getChannelIcon(channel.provider)}
|
||||||
<span className="font-medium">{channel.name}</span>
|
<span className="font-medium">{channel.name}</span>
|
||||||
<span
|
<span className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
||||||
className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
|
||||||
{channel.provider}
|
{channel.provider}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,10 +91,11 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ButtonWithLoading type="submit">
|
<ButtonWithLoading className="flex-shrink-0 w-full sm:w-auto" type="submit">
|
||||||
Confirm
|
Confirm
|
||||||
</ButtonWithLoading>
|
</ButtonWithLoading>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {userAction} from "@/lib/safe-actions/actions";
|
|||||||
import {
|
import {
|
||||||
addMemberOrganizationAction
|
addMemberOrganizationAction
|
||||||
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
} from "@/components/wrappers/dashboard/admin/organizations/organization/details/add-member.action";
|
||||||
import {sendEmail} from "@/lib/email/email-helper";
|
import {sendEmail} from "@/lib/email";
|
||||||
import EmailCreateUser from "@/components/emails/email-create-user";
|
import EmailCreateUser from "@/components/emails/email-create-user";
|
||||||
import {SignUpUser} from "@/types/auth";
|
import {SignUpUser} from "@/types/auth";
|
||||||
import {createUserDb} from "@/db/services/user";
|
import {createUserDb} from "@/db/services/user";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
|||||||
import {LoggedInButtonClient} from "./logged-in-button";
|
import {LoggedInButtonClient} from "./logged-in-button";
|
||||||
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
||||||
|
|
||||||
|
|
||||||
export const LoggedInButton = async () => {
|
export const LoggedInButton = async () => {
|
||||||
const user = await currentUser();
|
const user = await currentUser();
|
||||||
const sessions = await getSessions();
|
const sessions = await getSessions();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {ChevronsUpDown, ChevronUp} from "lucide-react";
|
import {ChevronsUpDown} from "lucide-react";
|
||||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||||
@@ -15,6 +15,7 @@ type LoggedInButtonClientProps = {
|
|||||||
providers: AuthProviderConfig[]
|
providers: AuthProviderConfig[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const LoggedInButtonClient = ({
|
export const LoggedInButtonClient = ({
|
||||||
user,
|
user,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -42,7 +43,8 @@ export const LoggedInButtonClient = ({
|
|||||||
{user.image && <AvatarImage src={user.image}/>}
|
{user.image && <AvatarImage src={user.image}/>}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col items-start">
|
<div className="flex flex-col items-start">
|
||||||
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
<span
|
||||||
|
className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
|
||||||
<span
|
<span
|
||||||
className="text-xs text-muted-foreground max-w-[170px] truncate"
|
className="text-xs text-muted-foreground max-w-[170px] truncate"
|
||||||
title={user.email}
|
title={user.email}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sideba
|
|||||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||||
import {env} from "@/env.mjs";
|
import {env} from "@/env.mjs";
|
||||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
|
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
|
||||||
|
import {UpdateNotification} from "@/features/updates/components/update-notification";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
const projectName = env.PROJECT_NAME;
|
const projectName = env.PROJECT_NAME;
|
||||||
@@ -31,6 +32,8 @@ export function AppSidebar() {
|
|||||||
<SidebarMenuCustomMain/>
|
<SidebarMenuCustomMain/>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
|
<UpdateNotification />
|
||||||
|
|
||||||
<SidebarMenu className="mb-2">
|
<SidebarMenu className="mb-2">
|
||||||
<SidebarMenuItem className="p-2">
|
<SidebarMenuItem className="p-2">
|
||||||
<LoggedInButton/>
|
<LoggedInButton/>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
|||||||
if (inner?.success) {
|
if (inner?.success) {
|
||||||
toast.success(inner.actionSuccess?.message);
|
toast.success(inner.actionSuccess?.message);
|
||||||
if (action === "download") {
|
if (action === "download") {
|
||||||
console.log(inner.value)
|
|
||||||
const url = inner.value
|
const url = inner.value
|
||||||
if (typeof url === "string") {
|
if (typeof url === "string") {
|
||||||
window.open(url, "_self");
|
window.open(url, "_self");
|
||||||
|
|||||||
@@ -51,9 +51,13 @@ export const downloadBackupAction = userAction.schema(
|
|||||||
path: backupStorage.path,
|
path: backupStorage.path,
|
||||||
signedUrl: true,
|
signedUrl: true,
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
storageId: backupStorage.storageChannelId,
|
||||||
|
fileKind: "backups"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(input)
|
|
||||||
|
|
||||||
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
|
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
|
||||||
console.log(result);
|
console.log(result);
|
||||||
|
|||||||
@@ -106,8 +106,7 @@ export const ChannelPoliciesForm = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
console.log(policiesToUpdate);
|
|
||||||
console.log(policiesToAdd);
|
|
||||||
|
|
||||||
const promises = kind === "notification"
|
const promises = kind === "notification"
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/
|
|||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
import {OrganizationSchema} from "@/components/wrappers/dashboard/organization/organization.schema";
|
import {CreateOrganizationSchema, CreateOrganizationType} from "@/features/organization/organization.schema";
|
||||||
import {createOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
import {createOrganizationAction} from "@/features/organization/organization.action";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
@@ -32,12 +32,12 @@ export function CreateOrganizationModal({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useZodForm({
|
const form = useZodForm({
|
||||||
schema: OrganizationSchema,
|
schema: CreateOrganizationSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (values: OrganizationSchema) => {
|
mutationFn: async (values: CreateOrganizationType) => {
|
||||||
|
|
||||||
const result = await createOrganizationAction(values);
|
const result = await createOrganizationAction(values);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
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 "@/features/organization/organization.action";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import {ComboBox} from "@/components/wrappers/common/combobox";
|
|
||||||
import {useSidebar} from "@/components/ui/sidebar";
|
import * as React from "react"
|
||||||
import {useRouter} from "next/navigation";
|
import {Building2, Check, ChevronsUpDown, Plus} from "lucide-react"
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {useRouter} from "next/navigation"
|
||||||
import {CreateOrganizationModal} from "@/components/wrappers/dashboard/organization/create-organisation-modal";
|
import {authClient} from "@/lib/auth/auth-client"
|
||||||
import {useState} from "react";
|
import {cn} from "@/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
SidebarMenuButton, useSidebar,
|
||||||
|
} from "@/components/ui/sidebar"
|
||||||
|
import {CreateOrganizationModal} from "@/components/wrappers/dashboard/organization/create-organisation-modal"
|
||||||
|
import {Skeleton} from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
|
||||||
export function OrganizationCombobox() {
|
export function OrganizationCombobox() {
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
const {state} = useSidebar();
|
const {isMobile, state} = useSidebar()
|
||||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
|
||||||
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
|
const {
|
||||||
const [openModal, setOpenModal] = useState(false);
|
data: activeOrganization,
|
||||||
|
isPending: isPendingActive,
|
||||||
|
refetch: refetchActiveOrga
|
||||||
|
} = authClient.useActiveOrganization()
|
||||||
|
const [openModal, setOpenModal] = React.useState(false)
|
||||||
|
const [isOpen, setIsOpen] = React.useState(false)
|
||||||
|
|
||||||
// if (!organizations) return null;
|
const handleSelect = async (slug: string) => {
|
||||||
|
if (activeOrganization?.slug === slug) return
|
||||||
// const values = organizations.map(org => ({ value: org.slug, label: org.name }));
|
await authClient.organization.setActive({organizationSlug: slug})
|
||||||
const values = organizations?.map(org => ({ value: org.slug, label: org.name })) ?? [];
|
router.refresh()
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
const onValueChange = async (slug: string) => {
|
|
||||||
await authClient.organization.setActive({ organizationSlug: slug });
|
|
||||||
router.refresh();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReload = () => {
|
const handleReload = () => {
|
||||||
refetch();
|
refetch();
|
||||||
@@ -31,6 +41,22 @@ export function OrganizationCombobox() {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const orgs = organizations || []
|
||||||
|
|
||||||
|
if (isPendingList || isPendingActive) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuButton size="lg" className="pointer-events-none">
|
||||||
|
<Skeleton className="size-8 rounded-lg shrink-0"/>
|
||||||
|
<div className="flex flex-col gap-1.5 flex-1 overflow-hidden">
|
||||||
|
<Skeleton className="h-4 w-24"/>
|
||||||
|
<Skeleton className="h-3 w-16"/>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="size-4 shrink-0"/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CreateOrganizationModal
|
<CreateOrganizationModal
|
||||||
@@ -38,16 +64,96 @@ export function OrganizationCombobox() {
|
|||||||
onSuccess={handleReload}
|
onSuccess={handleReload}
|
||||||
onOpenChange={setOpenModal}
|
onOpenChange={setOpenModal}
|
||||||
/>
|
/>
|
||||||
{state === "expanded" && (
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<ComboBox
|
<DropdownMenuTrigger asChild>
|
||||||
sideBar
|
<SidebarMenuButton
|
||||||
values={values}
|
size="lg"
|
||||||
defaultValue={activeOrganization?.slug}
|
className={cn(
|
||||||
onValueChangeAction={onValueChange}
|
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground transition-all duration-200 ease-in-out",
|
||||||
onAddItemAction={() => setOpenModal(true)}
|
state === "collapsed" && "justify-center p-0"
|
||||||
addItemLabel="Create new organization"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex aspect-square size-8 items-center justify-center rounded-lg text-white shadow-sm transition-transform duration-200",
|
||||||
|
activeOrganization?.logo ? "bg-transparent" : "bg-orange-500",
|
||||||
|
isOpen && "scale-105"
|
||||||
|
)}>
|
||||||
|
{activeOrganization?.logo ? (
|
||||||
|
<img src={activeOrganization.logo} alt={activeOrganization.name}
|
||||||
|
className="size-8 rounded-lg object-cover"/>
|
||||||
|
) : (
|
||||||
|
<Building2 className="size-5"/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="truncate font-semibold tracking-tight">
|
||||||
|
{activeOrganization?.name || "Select Organization"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto size-4 opacity-50 group-data-[collapsible=icon]:hidden"/>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="min-w-[var(--radix-popper-anchor-width)] rounded-xl border-2 border-border bg-popover shadow-none"
|
||||||
|
align="start"
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
{orgs.map((org) => {
|
||||||
|
const isActive = activeOrganization?.id === org.id;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={org.id}
|
||||||
|
onClick={() => handleSelect(org.slug)}
|
||||||
|
className={cn(
|
||||||
|
"group gap-2 p-1 cursor-pointer rounded-lg mb-1 last:mb-0 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-orange-500/10 text-orange-600 dark:text-orange-400 border border-orange-500/20"
|
||||||
|
: "focus:bg-accent hover:bg-accent/50 border border-transparent"
|
||||||
|
)}>
|
||||||
|
<div className={cn(
|
||||||
|
"flex size-9 items-center justify-center rounded-md border shadow-sm transition-all group-hover:shadow-md",
|
||||||
|
org.logo ? "bg-transparent border-transparent" : "",
|
||||||
|
isActive && !org.logo ? "bg-orange-500 text-white border-orange-500/30" : "bg-muted/50 border-border"
|
||||||
|
)}>
|
||||||
|
{org.logo ? (
|
||||||
|
<img src={org.logo} alt={org.name} className="size-9 rounded-md object-cover"/>
|
||||||
|
) : (
|
||||||
|
<Building2 className={cn(
|
||||||
|
"size-5",
|
||||||
|
isActive ? "text-white" : "text-muted-foreground"
|
||||||
|
)}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm max-w-42.5 truncate font-medium leading-none",
|
||||||
|
isActive ? "text-orange-600 dark:text-orange-400" : ""
|
||||||
|
)}>{org.name}</span>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className="ml-auto flex size-5 items-center justify-center rounded-full bg-orange-500 shadow-sm">
|
||||||
|
<Check className="size-3 text-white" strokeWidth={3}/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<DropdownMenuSeparator className="my-2 bg-border/50"/>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-3 p-2.5 cursor-pointer text-muted-foreground focus:text-foreground group rounded-lg"
|
||||||
|
onClick={() => setOpenModal(true)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex size-9 items-center justify-center rounded-md border border-dashed border-muted-foreground/30 bg-background transition-colors group-hover:border-primary/50 group-hover:bg-primary/5">
|
||||||
|
<Plus className="size-4"/>
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">Create new organization</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
import {z} from "zod";
|
|
||||||
|
|
||||||
|
|
||||||
export const OrganizationFormSchema = z.object({
|
|
||||||
name: z.string().min(5, 'Name must be at least 5 characters long').max(40, 'Name must be at most 40 characters long'),
|
|
||||||
slug: z.string()
|
|
||||||
.regex(/^[a-zA-Z0-9_-]*$/, 'Slug can only contain letters, numbers, underscores, and hyphens')
|
|
||||||
.min(5, 'Slug must be at least 5 characters long')
|
|
||||||
.max(20, 'Slug must be at most 20 characters long'),
|
|
||||||
users: z.array(z.string()),
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
export type OrganizationFormType = z.infer<typeof OrganizationFormSchema>;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const OrganizationSchema = z.object({
|
|
||||||
name: z.string().min(5, "Name must be at least 5 characters long").max(40, "Name must be at most 40 characters long"),
|
|
||||||
// slug: z
|
|
||||||
// .string()
|
|
||||||
// .regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens")
|
|
||||||
// .min(5, "Slug must be at least 5 characters long")
|
|
||||||
// .max(20, "Slug must be at most 20 characters long"),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import {buttonVariants} from "@/components/ui/button";
|
|
||||||
import {GearIcon} from "@radix-ui/react-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {usePathname} from "next/navigation";
|
|
||||||
|
|
||||||
export type EditButtonSettings = {}
|
|
||||||
|
|
||||||
export const EditButtonSettings= (props:EditButtonSettings) => {
|
|
||||||
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return(
|
|
||||||
<Link className={buttonVariants({variant: "outline"})}
|
|
||||||
href={`${pathname}/edit/`}
|
|
||||||
>
|
|
||||||
<GearIcon className="w-7 h-7"/>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -48,14 +48,25 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
|||||||
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!file.type.includes("image")) {
|
|
||||||
toast.error("File not an image");
|
const allowedFormats = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
|
||||||
|
if (!allowedFormats.includes(file.type)) {
|
||||||
|
toast.error("Only JPG, PNG or WebP images are allowed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxSizeInMB = 5;
|
||||||
|
if (file.size > maxSizeInMB * 1024 * 1024) {
|
||||||
|
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
submitImage.mutate(file);
|
submitImage.mutate(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative ">
|
<div className="relative ">
|
||||||
|
|
||||||
@@ -68,7 +79,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const fileInput = document.createElement("input");
|
const fileInput = document.createElement("input");
|
||||||
fileInput.type = "file";
|
fileInput.type = "file";
|
||||||
fileInput.accept = "image/*";
|
fileInput.accept = ".jpg,.jpeg,.png,.webp";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
fileInput.onchange = handleImageUpload;
|
fileInput.onchange = handleImageUpload;
|
||||||
fileInput.click();
|
fileInput.click();
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
|
|||||||
onSuccess: async (result: any) => {
|
onSuccess: async (result: any) => {
|
||||||
if (result.data?.success) {
|
if (result.data?.success) {
|
||||||
toast.success(result.data.actionSuccess.message);
|
toast.success(result.data.actionSuccess.message);
|
||||||
router.push("/");
|
router.push("/dashboard/projects");
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.data.actionError.message || "Unknown error occurred.");
|
toast.error(result.data.actionError.message || "Unknown error occurred.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."dbms_status" ADD VALUE 'mongodb';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "settings" ADD COLUMN "smtp_secure" boolean;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'google-drive';
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -211,6 +211,27 @@
|
|||||||
"when": 1768743644834,
|
"when": 1768743644834,
|
||||||
"tag": "0029_lowly_white_tiger",
|
"tag": "0029_lowly_white_tiger",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 30,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1768763883339,
|
||||||
|
"tag": "0030_dizzy_morlocks",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1768931603846,
|
||||||
|
"tag": "0031_chemical_edwin_jarvis",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769203863305,
|
||||||
|
"tag": "0032_sparkling_thunderbolt_ross",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
|
import {boolean, pgTable, timestamp, uuid, varchar} from "drizzle-orm/pg-core";
|
||||||
import {typeStorageEnum} from "./types";
|
import {typeStorageEnum} from "./types";
|
||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
@@ -19,6 +19,7 @@ export const setting = pgTable("settings", {
|
|||||||
smtpHost: varchar("smtp_host", {length: 255}),
|
smtpHost: varchar("smtp_host", {length: 255}),
|
||||||
smtpPort: varchar("smtp_port", {length: 255}),
|
smtpPort: varchar("smtp_port", {length: 255}),
|
||||||
smtpUser: varchar("smtp_user", {length: 255}),
|
smtpUser: varchar("smtp_user", {length: 255}),
|
||||||
|
smtpSecure: boolean("smtp_secure"),
|
||||||
defaultStorageChannelId: uuid('default_storage_channel_id')
|
defaultStorageChannelId: uuid('default_storage_channel_id')
|
||||||
.references(() => storageChannel.id, {onDelete: "set null"}),
|
.references(() => storageChannel.id, {onDelete: "set null"}),
|
||||||
...timestamps
|
...timestamps
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {z} from "zod";
|
|||||||
import {database} from "@/db/schema/07_database";
|
import {database} from "@/db/schema/07_database";
|
||||||
|
|
||||||
|
|
||||||
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3']);
|
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive']);
|
||||||
|
|
||||||
export const storageChannel = pgTable('storage_channel', {
|
export const storageChannel = pgTable('storage_channel', {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { pgEnum } from "drizzle-orm/pg-core";
|
|||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql"]);
|
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mongodb"]);
|
||||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -22,6 +22,7 @@ export const env = createEnv({
|
|||||||
SMTP_HOST: z.string().optional(),
|
SMTP_HOST: z.string().optional(),
|
||||||
SMTP_PORT: z.string().optional(),
|
SMTP_PORT: z.string().optional(),
|
||||||
SMTP_USER: z.string().optional(),
|
SMTP_USER: z.string().optional(),
|
||||||
|
SMTP_SECURE: z.coerce.boolean().default(true),
|
||||||
|
|
||||||
AUTH_GOOGLE_ID: z.string().optional(),
|
AUTH_GOOGLE_ID: z.string().optional(),
|
||||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||||
@@ -43,7 +44,6 @@ export const env = createEnv({
|
|||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||||
|
|
||||||
},
|
},
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||||
@@ -60,6 +60,7 @@ export const env = createEnv({
|
|||||||
SMTP_HOST: process.env.SMTP_HOST,
|
SMTP_HOST: process.env.SMTP_HOST,
|
||||||
SMTP_PORT: process.env.SMTP_PORT,
|
SMTP_PORT: process.env.SMTP_PORT,
|
||||||
SMTP_USER: process.env.SMTP_USER,
|
SMTP_USER: process.env.SMTP_USER,
|
||||||
|
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||||
|
|
||||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
import {ActionError, userAction} from "@/lib/safe-actions/actions";
|
import {ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||||
import {AgentSchema} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
|
import {AgentSchema} from "@/features/agents/agents.schema";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {eq, and, ne, count} from "drizzle-orm";
|
import {eq, and, ne, count} from "drizzle-orm";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const AgentSchema = z.object({
|
export const AgentSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string().nonempty("Name is required"),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {AgentForm} from "@/features/agents/components/agent.form";
|
||||||
|
import {Button, buttonVariants} from "@/components/ui/button";
|
||||||
|
import {Plus} from "lucide-react";
|
||||||
|
import {AgentType} from "@/features/agents/agents.schema";
|
||||||
|
import {GearIcon} from "@radix-ui/react-icons";
|
||||||
|
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||||
|
import {useState} from "react";
|
||||||
|
|
||||||
|
type AgentDialogProps = {
|
||||||
|
agent?: AgentType & { id: string };
|
||||||
|
typeTrigger: "edit" | "empty" | "create";
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const isEdit = !!agent;
|
||||||
|
|
||||||
|
const getTrigger = () => {
|
||||||
|
switch (typeTrigger) {
|
||||||
|
case "edit":
|
||||||
|
return (
|
||||||
|
<div className={buttonVariants({variant: "outline", className: "cursor-pointer"})}>
|
||||||
|
<GearIcon className="w-7 h-7"/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "empty":
|
||||||
|
return <EmptyStatePlaceholder text="Create new Agent"/>;
|
||||||
|
case "create":
|
||||||
|
return <Button><Plus className="mr-2 h-4 w-4"/> Create Agent</Button>;
|
||||||
|
default:
|
||||||
|
return <Button><Plus className="mr-2 h-4 w-4"/> Create Agent</Button>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{getTrigger()}</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? `Edit ${agent.name}` : "Create new agent"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<AgentForm
|
||||||
|
onSuccess={() => setOpen(false)}
|
||||||
|
defaultValues={agent}
|
||||||
|
agentId={agent?.id}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-3
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Card, CardContent} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
@@ -16,13 +15,14 @@ import {Button} from "@/components/ui/button";
|
|||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
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 {AgentSchema, AgentType} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.schema";
|
import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {createAgentAction, updateAgentAction} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.action";
|
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
|
||||||
|
|
||||||
export type agentFormProps = {
|
export type agentFormProps = {
|
||||||
defaultValues?: AgentType;
|
defaultValues?: AgentType;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
onSuccess?: (data: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AgentForm = (props: agentFormProps) => {
|
export const AgentForm = (props: agentFormProps) => {
|
||||||
@@ -52,7 +52,12 @@ export const AgentForm = (props: agentFormProps) => {
|
|||||||
}
|
}
|
||||||
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
|
if (props.onSuccess) {
|
||||||
|
props.onSuccess(data);
|
||||||
|
} else {
|
||||||
router.push(`/dashboard/agents/${data.id}`);
|
router.push(`/dashboard/agents/${data.id}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import {Request, Response, NextFunction} from "express";
|
||||||
|
|
||||||
|
export function loggingMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
console.log(`[API - SERVICES - V1] Received ${req.method} request : ${req.url} at ${new Date().toISOString()}`);
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import express, { Router } from "express";
|
||||||
|
import uploadRouter from "./upload";
|
||||||
|
import { loggingMiddleware } from "@/features/api/middleware";
|
||||||
|
|
||||||
|
const router: Router = express.Router();
|
||||||
|
|
||||||
|
router.use(loggingMiddleware);
|
||||||
|
router.use("/upload", uploadRouter);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import {Backup, DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
|
import type {StorageInput, StorageResult} from "@/features/storages/types";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {eq} from "drizzle-orm";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
import forge from "node-forge";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save stream to a temporary file and upload to all storage providers in parallel.
|
||||||
|
*/
|
||||||
|
export default async function uploadTempFileToProviders(
|
||||||
|
backup: Backup,
|
||||||
|
database: DatabaseWith,
|
||||||
|
// inputStream: NodeJS.ReadableStream,
|
||||||
|
tmpPath: string,
|
||||||
|
fileName: string
|
||||||
|
): Promise<StorageResult[]> {
|
||||||
|
|
||||||
|
const stats = fs.statSync(tmpPath);
|
||||||
|
const fileSize = stats.size;
|
||||||
|
console.log(tmpPath);
|
||||||
|
|
||||||
|
const settings = await db.query.setting.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||||
|
with: {storageChannel: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultPolicy = settings?.storageChannel
|
||||||
|
? [{
|
||||||
|
id: null,
|
||||||
|
storageChannelId: settings.storageChannel.id,
|
||||||
|
enabled: settings.storageChannel.enabled,
|
||||||
|
}]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const enabledPolicies = database.storagePolicies?.filter(p => p.enabled) ?? [];
|
||||||
|
const policies = enabledPolicies.length ? enabledPolicies : defaultPolicy;
|
||||||
|
|
||||||
|
if (!policies.length) {
|
||||||
|
await db.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({status: "failed"}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
fs.existsSync(tmpPath) && fs.unlinkSync(tmpPath);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePath = `backups/${database.project?.slug}/${fileName}`;
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
policies.map(async (policy) => {
|
||||||
|
const fileStream = fs.createReadStream(tmpPath);
|
||||||
|
|
||||||
|
const [backupStorage] = await db.insert(drizzleDb.schemas.backupStorage)
|
||||||
|
.values({
|
||||||
|
backupId: backup.id,
|
||||||
|
storageChannelId: policy.storageChannelId,
|
||||||
|
status: "pending",
|
||||||
|
path: storagePath,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const input: StorageInput = {
|
||||||
|
action: "upload",
|
||||||
|
data: {path: storagePath, file: fileStream, size: fileSize},
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: StorageResult;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = policy.id
|
||||||
|
? await dispatchStorage(input, policy.id)
|
||||||
|
: await dispatchStorage(input, undefined, policy.storageChannelId);
|
||||||
|
} catch (err: any) {
|
||||||
|
result = {success: false, provider: null, error: err.message};
|
||||||
|
}
|
||||||
|
await db.update(drizzleDb.schemas.backupStorage)
|
||||||
|
.set(withUpdatedAt({status: result.success ? "success" : "failed"}))
|
||||||
|
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const backupStatus = results.some(r => r.success) ? "success" : "failed";
|
||||||
|
await db.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({
|
||||||
|
fileSize: fileSize,
|
||||||
|
status: backupStatus
|
||||||
|
}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
|
||||||
|
fs.existsSync(tmpPath) && fs.unlinkSync(tmpPath);
|
||||||
|
|
||||||
|
console.log(results);
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
if (!backupStatus) {
|
||||||
|
await sendNotificationsBackupRestore(database, "error_backup");
|
||||||
|
}
|
||||||
|
await sendNotificationsBackupRestore(database, "success_backup");
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function createDecryptionStream(
|
||||||
|
encryptedAesKeyHex: string,
|
||||||
|
ivHex: string
|
||||||
|
) {
|
||||||
|
const privateKeyPem = fs.readFileSync(
|
||||||
|
"private/keys/server_private.pem",
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
|
||||||
|
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
|
||||||
|
const encryptedBytes = forge.util.hexToBytes(encryptedAesKeyHex);
|
||||||
|
const aesKeyBytes = privateKey.decrypt(encryptedBytes, "RSA-OAEP", {
|
||||||
|
md: forge.md.sha256.create(),
|
||||||
|
mgf1: {md: forge.md.sha256.create()},
|
||||||
|
});
|
||||||
|
|
||||||
|
const aesKey = Buffer.from(aesKeyBytes, "binary");
|
||||||
|
const iv = Buffer.from(ivHex, "hex");
|
||||||
|
|
||||||
|
return crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import {promisify} from "util";
|
||||||
|
import {pipeline} from "stream";
|
||||||
|
|
||||||
|
const pipelineAsync = promisify(pipeline);
|
||||||
|
const TMP_DIR = path.join(process.cwd(), "private/uploads/tmp");
|
||||||
|
fs.mkdirSync(TMP_DIR, { recursive: true });
|
||||||
|
|
||||||
|
export async function saveStreamToTempFile(stream: NodeJS.ReadableStream, fileName: string): Promise<string> {
|
||||||
|
const tmpPath = path.join(TMP_DIR, fileName);
|
||||||
|
const writeStream = fs.createWriteStream(tmpPath);
|
||||||
|
await pipelineAsync(stream, writeStream);
|
||||||
|
return tmpPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function getFileExtension(dbType: string) {
|
||||||
|
switch (dbType) {
|
||||||
|
case "postgresql":
|
||||||
|
return ".dump";
|
||||||
|
case "mysql":
|
||||||
|
return ".sql";
|
||||||
|
default:
|
||||||
|
return ".dump";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import express, {Request, Response, Router} from "express";
|
||||||
|
import {v4 as uuidv4} from "uuid";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
|
import uploadTempFileToProviders, {createDecryptionStream} from "@/features/api/upload/helpers/common";
|
||||||
|
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||||
|
import {Backup} from "@/db/schema/07_database";
|
||||||
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
|
import {eventEmitter} from "@/features/shared/event";
|
||||||
|
import {getFileExtension, saveStreamToTempFile} from "@/features/api/upload/helpers/file";
|
||||||
|
|
||||||
|
const router: Router = express.Router();
|
||||||
|
|
||||||
|
router.post("/:agentId", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const agentId = req.params.agentId as string | undefined;
|
||||||
|
const generatedId = req.headers["x-generated-id"] as string | undefined;
|
||||||
|
const status = req.headers["x-status"] as string | undefined;
|
||||||
|
const encryptedAesKeyHex = req.headers["x-aes-key"] as string | undefined;
|
||||||
|
const ivHex = req.headers["x-iv"] as string | undefined;
|
||||||
|
const method = (req.headers["x-method"] as string) ?? "manual";
|
||||||
|
const extension = req.headers["x-extension"] as string | undefined;
|
||||||
|
|
||||||
|
|
||||||
|
if (!generatedId || !encryptedAesKeyHex || !ivHex || !agentId || !status) {
|
||||||
|
return res.status(400).json({error: "Missing required headers/params"});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUuidv4(generatedId)) {
|
||||||
|
return res.status(400).json({error: "generatedId is not a valid UUID"});
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = await db.query.agent.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
return res.status(404).json({error: "Agent not found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const database = await db.query.database.findFirst({
|
||||||
|
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||||
|
with: {
|
||||||
|
project: true,
|
||||||
|
storagePolicies: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!database) {
|
||||||
|
return res.status(404).json({error: "Database not found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup: Backup | null | undefined = null;
|
||||||
|
|
||||||
|
if (method === "automatic") {
|
||||||
|
[backup] = await db
|
||||||
|
.insert(drizzleDb.schemas.backup)
|
||||||
|
.values({
|
||||||
|
status: "ongoing",
|
||||||
|
databaseId: database.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
backup = await db.query.backup.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, database.id),
|
||||||
|
eq(drizzleDb.schemas.backup.status, "ongoing")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!backup) {
|
||||||
|
return res.status(404).json({error: "Backup not found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (status === "success") {
|
||||||
|
|
||||||
|
const decipher = createDecryptionStream(encryptedAesKeyHex, ivHex);
|
||||||
|
|
||||||
|
const fileExt = extension || getFileExtension(database.dbms);
|
||||||
|
const fileName = `${uuidv4()}${fileExt}`;
|
||||||
|
|
||||||
|
const decryptedStream = req.pipe(decipher);
|
||||||
|
|
||||||
|
const tmpPath = await saveStreamToTempFile(decryptedStream, fileName);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (!tmpPath) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Unable to save tmp backup file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadTempFileToProviders(backup, database, tmpPath, fileName);
|
||||||
|
|
||||||
|
return res.json({success: true});
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set(withUpdatedAt({status: 'failed'}))
|
||||||
|
.where(eq(drizzleDb.schemas.backup.id, backup.id));
|
||||||
|
|
||||||
|
eventEmitter.emit('modification', {update: true});
|
||||||
|
|
||||||
|
await sendNotificationsBackupRestore(database, "error_backup");
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
error: "Backup successfully updated with status failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Upload error:", err);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: "Upload failed",
|
||||||
|
detail: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import type {EventPayload, DispatchResult} from '../types';
|
import type {EventPayload, DispatchResult} from '../types';
|
||||||
|
|
||||||
export async function sendNtfy(
|
export async function sendNtfy(
|
||||||
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string },
|
config: { ntfyServerUrl?: string; ntfyTopic: string; ntfyToken?: string, ntfyUsername?: string, ntfyPassword?: string },
|
||||||
payload: EventPayload
|
payload: EventPayload
|
||||||
): Promise<DispatchResult> {
|
): Promise<DispatchResult> {
|
||||||
const {ntfyServerUrl, ntfyTopic, ntfyToken} = config;
|
const {ntfyServerUrl, ntfyTopic, ntfyToken, ntfyUsername, ntfyPassword} = config;
|
||||||
|
|
||||||
const baseUrl = (ntfyServerUrl || "https://ntfy.sh").replace(/\/$/, "");
|
const baseUrl = (ntfyServerUrl || "https://ntfy.sh").replace(/\/$/, "");
|
||||||
|
|
||||||
@@ -24,6 +24,11 @@ export async function sendNtfy(
|
|||||||
headers['Authorization'] = `Bearer ${ntfyToken}`;
|
headers['Authorization'] = `Bearer ${ntfyToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ntfyUsername && ntfyPassword) {
|
||||||
|
const credentials = btoa(`${ntfyUsername}:${ntfyPassword}`);
|
||||||
|
headers['Authorization'] = `Basic ${credentials}`;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${baseUrl}`, {
|
const res = await fetch(`${baseUrl}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {OrganizationForm} from "@/features/organization/components/organization.form";
|
||||||
|
import {ReactNode, useState} from "react";
|
||||||
|
import {Button, buttonVariants} from "@/components/ui/button";
|
||||||
|
import {GearIcon} from "@radix-ui/react-icons";
|
||||||
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
|
import {User} from "@/db/schema/02_user";
|
||||||
|
import {User as BetterAuthUser} from "better-auth";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
|
type EditOrganizationDialogProps = {
|
||||||
|
organization: OrganizationWithMembers;
|
||||||
|
users: User[];
|
||||||
|
currentUser: BetterAuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditOrganizationDialog = ({
|
||||||
|
organization,
|
||||||
|
users,
|
||||||
|
currentUser,
|
||||||
|
}: EditOrganizationDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<div className={buttonVariants({variant: "outline", className: "cursor-pointer"})}>
|
||||||
|
<GearIcon className="w-7 h-7"/>
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit {organization.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<OrganizationForm
|
||||||
|
onSuccess={() => {
|
||||||
|
setOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
}}
|
||||||
|
defaultValues={organization}
|
||||||
|
users={users}
|
||||||
|
currentUser={currentUser}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+11
-6
@@ -16,13 +16,13 @@ import {useMutation} from "@tanstack/react-query";
|
|||||||
import {useRouter} from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
|
||||||
import {
|
import {
|
||||||
OrganizationFormSchema,
|
UpdateOrganizationSchema,
|
||||||
OrganizationFormType
|
UpdateOrganizationType
|
||||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
} from "@/features/organization/organization.schema";
|
||||||
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
|
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
import {
|
import {
|
||||||
updateOrganizationAction
|
updateOrganizationAction
|
||||||
} from "@/components/wrappers/dashboard/organization/organization.action";
|
} from "@/features/organization/organization.action";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {User as BetterAuthUser} from "better-auth";
|
import {User as BetterAuthUser} from "better-auth";
|
||||||
import {User} from "@/db/schema/02_user";
|
import {User} from "@/db/schema/02_user";
|
||||||
@@ -32,6 +32,7 @@ export type organizationFormProps = {
|
|||||||
defaultValues?: OrganizationWithMembers;
|
defaultValues?: OrganizationWithMembers;
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUser: BetterAuthUser;
|
currentUser: BetterAuthUser;
|
||||||
|
onSuccess?: (data: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrganizationForm = (props: organizationFormProps) => {
|
export const OrganizationForm = (props: organizationFormProps) => {
|
||||||
@@ -64,12 +65,12 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const form = useZodForm({
|
const form = useZodForm({
|
||||||
schema: OrganizationFormSchema,
|
schema: UpdateOrganizationSchema,
|
||||||
defaultValues: formattedDefaultValues,
|
defaultValues: formattedDefaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (values: OrganizationFormType) => updateOrganizationAction({
|
mutationFn: (values: UpdateOrganizationType) => updateOrganizationAction({
|
||||||
data: values,
|
data: values,
|
||||||
organizationId: props.defaultValues?.id ?? ""
|
organizationId: props.defaultValues?.id ?? ""
|
||||||
}),
|
}),
|
||||||
@@ -78,7 +79,11 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
|||||||
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
|
toast.success(result.data.actionSuccess?.message || "Organization updated successfully.");
|
||||||
refetch()
|
refetch()
|
||||||
refetchActiveOrga()
|
refetchActiveOrga()
|
||||||
|
if (props.onSuccess) {
|
||||||
|
props.onSuccess(result.data.value);
|
||||||
|
} else {
|
||||||
router.push("/dashboard/settings");
|
router.push("/dashboard/settings");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
|
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
|
||||||
+3
-6
@@ -1,12 +1,9 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
import {OrganizationSchema} from "@/components/wrappers/dashboard/organization/organization.schema";
|
import {CreateOrganizationSchema, UpdateOrganizationSchema} from "@/features/organization/organization.schema";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {
|
|
||||||
OrganizationFormSchema
|
|
||||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {and, eq, inArray, or} from "drizzle-orm";
|
import {and, eq, inArray, or} from "drizzle-orm";
|
||||||
import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
|
import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
|
||||||
@@ -14,7 +11,7 @@ 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";
|
||||||
|
|
||||||
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
export const createOrganizationAction = userAction.schema(CreateOrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
||||||
try {
|
try {
|
||||||
const slug = slugify(parsedInput.name);
|
const slug = slugify(parsedInput.name);
|
||||||
if (!await checkSlugOrganization(slug)) {
|
if (!await checkSlugOrganization(slug)) {
|
||||||
@@ -69,7 +66,7 @@ export const createOrganizationAction = userAction.schema(OrganizationSchema).ac
|
|||||||
export const updateOrganizationAction = userAction
|
export const updateOrganizationAction = userAction
|
||||||
.schema(
|
.schema(
|
||||||
z.object({
|
z.object({
|
||||||
data: OrganizationFormSchema,
|
data: UpdateOrganizationSchema,
|
||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const CreateOrganizationSchema = z.object({
|
||||||
|
name: z.string().min(5, "Name must be at least 5 characters long").max(40, "Name must be at most 40 characters long"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const UpdateOrganizationSchema = z.object({
|
||||||
|
name: z.string().min(5, 'Name must be at least 5 characters long').max(40, 'Name must be at most 40 characters long'),
|
||||||
|
slug: z.string()
|
||||||
|
.regex(/^[a-zA-Z0-9_-]*$/, 'Slug can only contain letters, numbers, underscores, and hyphens')
|
||||||
|
.min(5, 'Slug must be at least 5 characters long')
|
||||||
|
.max(20, 'Slug must be at most 20 characters long'),
|
||||||
|
users: z.array(z.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateOrganizationType = z.infer<typeof CreateOrganizationSchema>;
|
||||||
|
export type UpdateOrganizationType = z.infer<typeof UpdateOrganizationSchema>;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {ProjectForm} from "@/features/projects/components/project.form";
|
||||||
|
import {ReactNode, useState} from "react";
|
||||||
|
import {Button, buttonVariants} from "@/components/ui/button";
|
||||||
|
import {Plus} from "lucide-react";
|
||||||
|
import {DatabaseWith} from "@/db/schema/07_database";
|
||||||
|
import {Organization} from "@/db/schema/03_organization";
|
||||||
|
import {ProjectWith} from "@/db/schema/06_project";
|
||||||
|
import {GearIcon} from "@radix-ui/react-icons";
|
||||||
|
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||||
|
|
||||||
|
type ProjectDialogProps = {
|
||||||
|
databases: DatabaseWith[];
|
||||||
|
organization: Organization;
|
||||||
|
project?: ProjectWith;
|
||||||
|
isEdit?: boolean;
|
||||||
|
isEmpty?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectDialog = ({
|
||||||
|
databases,
|
||||||
|
organization,
|
||||||
|
project,
|
||||||
|
isEdit = false,
|
||||||
|
isEmpty = false
|
||||||
|
}: ProjectDialogProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<div>
|
||||||
|
{isEdit ?
|
||||||
|
<div className={buttonVariants({variant: "outline", className: "cursor-pointer"})}>
|
||||||
|
<GearIcon className="w-7 h-7"/>
|
||||||
|
</div>
|
||||||
|
:
|
||||||
|
<>
|
||||||
|
{isEmpty ?
|
||||||
|
<EmptyStatePlaceholder text="Create new Project"/>
|
||||||
|
:
|
||||||
|
<Button><Plus className="mr-2 h-4 w-4"/> Create Project</Button>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? `Edit ${project?.name}` : "Create new project"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ProjectForm
|
||||||
|
onSuccess={() => setOpen(false)}
|
||||||
|
databases={databases}
|
||||||
|
organization={organization}
|
||||||
|
defaultValues={project ? {
|
||||||
|
...project,
|
||||||
|
databases: project.databases.map(db => db.id)
|
||||||
|
} : undefined}
|
||||||
|
projectId={project?.id}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
+14
-4
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
import { FormControl, FormDescription, 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 { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { ProjectSchema, ProjectType } from "@/components/wrappers/dashboard/projects/project-form/project-form.schema";
|
import { ProjectSchema, ProjectType } from "@/features/projects/projects.schema";
|
||||||
import { createProjectAction, updateProjectAction } from "@/components/wrappers/dashboard/projects/project-form/project-form.action";
|
import { createProjectAction, updateProjectAction } from "@/features/projects/projects.action";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
|
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -19,15 +18,22 @@ export type projectFormProps = {
|
|||||||
databases: DatabaseWith[];
|
databases: DatabaseWith[];
|
||||||
organization: Organization;
|
organization: Organization;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
onSuccess?: (data: any) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const ProjectForm = (props: projectFormProps) => {
|
export const ProjectForm = (props: projectFormProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isCreate = !Boolean(props.defaultValues);
|
const isCreate = !Boolean(props.defaultValues);
|
||||||
const formatDatabasesList = (databases: DatabaseWith[]) => {
|
const formatDatabasesList = (databases: DatabaseWith[]) => {
|
||||||
return databases.map((database) => ({
|
return databases.map((database) => ({
|
||||||
value: database.id,
|
value: database.id,
|
||||||
label: `${database.name} (${database.agentDatabaseId}) | ${database.agent?.name}`,
|
label: `${database.name} | ${database.agent?.name}`,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +71,11 @@ export const ProjectForm = (props: projectFormProps) => {
|
|||||||
if (project.data.success) {
|
if (project.data.success) {
|
||||||
project.data.actionSuccess && toast.success(project.data.actionSuccess.message);
|
project.data.actionSuccess && toast.success(project.data.actionSuccess.message);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
if (props.onSuccess) {
|
||||||
|
props.onSuccess(project.data.value);
|
||||||
|
} else {
|
||||||
router.push(`/dashboard/projects/${project.data.value!.id}`);
|
router.push(`/dashboard/projects/${project.data.value!.id}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
project.data.actionError && toast.error(project.data.actionError.message || "Unknown error occurred.");
|
project.data.actionError && toast.error(project.data.actionError.message || "Unknown error occurred.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
+1
-3
@@ -1,7 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import {userAction} from "@/lib/safe-actions/actions";
|
import {userAction} from "@/lib/safe-actions/actions";
|
||||||
import {ProjectSchema} from "@/components/wrappers/dashboard/projects/project-form/project-form.schema";
|
import {ProjectSchema} from "@/features/projects/projects.schema";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
@@ -119,13 +119,11 @@ export const updateProjectAction = userAction
|
|||||||
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databasesToRemove)).execute();
|
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databasesToRemove)).execute();
|
||||||
|
|
||||||
}
|
}
|
||||||
// const slug = slugify(parsedInput.data.name);
|
|
||||||
|
|
||||||
const [updatedProject] = await db
|
const [updatedProject] = await db
|
||||||
.update(drizzleDb.schemas.project)
|
.update(drizzleDb.schemas.project)
|
||||||
.set({
|
.set({
|
||||||
name: parsedInput.data.name,
|
name: parsedInput.data.name,
|
||||||
// slug: slug,
|
|
||||||
})
|
})
|
||||||
.where(eq(drizzleDb.schemas.project.id, parsedInput.projectId))
|
.where(eq(drizzleDb.schemas.project.id, parsedInput.projectId))
|
||||||
.returning();
|
.returning();
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const ProjectSchema = z.object({
|
export const ProjectSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string().nonempty("Name is required"),
|
||||||
databases: z.array(z.string()),
|
databases: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
|
export const eventEmitter = new EventEmitter();
|
||||||
@@ -89,7 +89,8 @@ export async function dispatchStorage(
|
|||||||
return await dispatchViaProvider(
|
return await dispatchViaProvider(
|
||||||
channel.provider as StorageProviderKind,
|
channel.provider as StorageProviderKind,
|
||||||
channel.config,
|
channel.config,
|
||||||
input
|
input,
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import {Backup, DatabaseWith} from "@/db/schema/07_database";
|
import {Backup, DatabaseWith} from "@/db/schema/07_database";
|
||||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||||
import type {StorageInput, StorageResult} from "@/features/storages/types";
|
import type {
|
||||||
|
StorageGetInput,
|
||||||
|
StorageInput,
|
||||||
|
StorageMetaData,
|
||||||
|
StorageResult,
|
||||||
|
StorageUploadInput
|
||||||
|
} from "@/features/storages/types";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {withUpdatedAt} from "@/db/utils";
|
import {withUpdatedAt} from "@/db/utils";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {createHash} from "crypto";
|
import {createHash} from "crypto";
|
||||||
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
function computeChecksum(buffer: Buffer): string {
|
function computeChecksum(buffer: Buffer): string {
|
||||||
return createHash("sha256").update(buffer).digest("hex");
|
return createHash("sha256").update(buffer).digest("hex");
|
||||||
@@ -114,3 +122,34 @@ export async function storeBackupFiles(
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export async function generateFileUrl(input: { data: StorageGetInput | StorageUploadInput, metadata?: StorageMetaData }): Promise<string | null> {
|
||||||
|
const fileName = path.basename(input.data.path);
|
||||||
|
const baseUrl = getServerUrl();
|
||||||
|
const metadata = input.metadata;
|
||||||
|
|
||||||
|
if (!metadata){
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = new URLSearchParams({
|
||||||
|
storageId: metadata.storageId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (metadata.fileKind === "backups") {
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const expiresAt = Date.now() + 60 * 1000;
|
||||||
|
const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex");
|
||||||
|
|
||||||
|
params.set("path", input.data.path);
|
||||||
|
params.set("token", token);
|
||||||
|
params.set("expires", expiresAt.toString());
|
||||||
|
return `${baseUrl}/api/files/${metadata.fileKind}/?${params.toString()}`
|
||||||
|
}else if (metadata.fileKind === "images") {
|
||||||
|
return `${baseUrl}/api/files/${metadata.fileKind}/${fileName}?${params.toString()}`
|
||||||
|
}else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {drive_v3, google} from "googleapis";
|
||||||
|
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
|
||||||
|
import Drive = drive_v3.Drive;
|
||||||
|
import {getServerUrl} from "@/utils/get-server-url";
|
||||||
|
|
||||||
|
export async function getGoogleDriveClient(config: GoogleDriveConfig): Promise<Drive> {
|
||||||
|
const baseUrl = getServerUrl();
|
||||||
|
|
||||||
|
const oauth2Client = new google.auth.OAuth2(
|
||||||
|
config.clientId,
|
||||||
|
config.clientSecret,
|
||||||
|
baseUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
oauth2Client.setCredentials({
|
||||||
|
refresh_token: config.refreshToken
|
||||||
|
});
|
||||||
|
|
||||||
|
return google.drive({
|
||||||
|
version: "v3",
|
||||||
|
auth: oauth2Client
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function findFileByName(
|
||||||
|
drive: drive_v3.Drive,
|
||||||
|
name: string,
|
||||||
|
folderId: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const res = await drive.files.list({
|
||||||
|
q: `name='${name}' and '${folderId}' in parents and trashed=false`,
|
||||||
|
fields: "files(id)",
|
||||||
|
pageSize: 1,
|
||||||
|
supportsAllDrives: true,
|
||||||
|
includeItemsFromAllDrives: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.files?.[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function ensureFolderPath(client: any, path: string, rootFolderId: string): Promise<string> {
|
||||||
|
const parts = path.split("/").filter(Boolean); // ["backups", "project-1"]
|
||||||
|
let parentId = rootFolderId;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const res = await client.files.list({
|
||||||
|
q: `'${parentId}' in parents and name='${part}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
|
||||||
|
fields: "files(id, name)",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
includeItemsFromAllDrives: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.files && res.data.files.length > 0) {
|
||||||
|
parentId = res.data.files[0].id!;
|
||||||
|
} else {
|
||||||
|
const folder = await client.files.create({
|
||||||
|
requestBody: {
|
||||||
|
name: part,
|
||||||
|
mimeType: "application/vnd.google-apps.folder",
|
||||||
|
parents: [parentId],
|
||||||
|
},
|
||||||
|
fields: "id",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
});
|
||||||
|
parentId = folder.data.id!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function resolveFilePath(client: any, fullPath: string, rootFolderId: string): Promise<string | null> {
|
||||||
|
const parts = fullPath.split("/").filter(Boolean);
|
||||||
|
const fileName = parts.pop()!;
|
||||||
|
let parentId = rootFolderId;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const res = await client.files.list({
|
||||||
|
q: `'${parentId}' in parents and name='${part}' and mimeType='application/vnd.google-apps.folder' and trashed=false`,
|
||||||
|
fields: "files(id, name)",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
includeItemsFromAllDrives: true,
|
||||||
|
});
|
||||||
|
if (res.data.files && res.data.files.length > 0) {
|
||||||
|
parentId = res.data.files[0].id!;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileRes = await client.files.list({
|
||||||
|
q: `'${parentId}' in parents and name='${fileName}' and trashed=false`,
|
||||||
|
fields: "files(id, name)",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
includeItemsFromAllDrives: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fileRes.data.files?.[0]?.id || null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"use server"
|
||||||
|
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../../types';
|
||||||
|
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
|
||||||
|
import {
|
||||||
|
ensureFolderPath,
|
||||||
|
findFileByName,
|
||||||
|
getGoogleDriveClient, resolveFilePath
|
||||||
|
} from "@/features/storages/providers/google-drive/helpers";
|
||||||
|
import {Readable} from "node:stream";
|
||||||
|
import {generateFileUrl} from "@/features/storages/helpers";
|
||||||
|
|
||||||
|
export async function uploadGoogleDrive(
|
||||||
|
config: GoogleDriveConfig,
|
||||||
|
input: { data: StorageUploadInput, metadata?: StorageMetaData },
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const client = await getGoogleDriveClient(config);
|
||||||
|
|
||||||
|
const fullPath = input.data.path;
|
||||||
|
const pathParts = fullPath.split("/").filter(Boolean);
|
||||||
|
const fileName = pathParts.pop()!;
|
||||||
|
const folderPath = pathParts.join("/");
|
||||||
|
|
||||||
|
const folderId = folderPath
|
||||||
|
? await ensureFolderPath(client, folderPath, config.folderId)
|
||||||
|
: config.folderId;
|
||||||
|
|
||||||
|
const existing = await findFileByName(client, fileName, folderId);
|
||||||
|
if (existing) return {success: false, provider: "google-drive", error: "File already exists"};
|
||||||
|
|
||||||
|
let fileStream: Readable;
|
||||||
|
const file = input.data.file;
|
||||||
|
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||||
|
fileStream = Readable.from(file);
|
||||||
|
} else if ((file as any).pipe) {
|
||||||
|
fileStream = file as Readable;
|
||||||
|
} else {
|
||||||
|
throw new Error("Unsupported file type for streaming upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.files.create({
|
||||||
|
requestBody: {name: fileName, parents: [folderId]},
|
||||||
|
media: {body: fileStream},
|
||||||
|
fields: "id",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
if (input.data.url) {
|
||||||
|
const url = await generateFileUrl(input);
|
||||||
|
if (!url) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "google-drive",
|
||||||
|
response: "Unable to get url file"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: 'google-drive',
|
||||||
|
url: url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: 'google-drive',
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGoogleDrive(
|
||||||
|
config: GoogleDriveConfig,
|
||||||
|
input: { data: StorageGetInput, metadata: StorageMetaData },
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const client = await getGoogleDriveClient(config);
|
||||||
|
|
||||||
|
const fileId = await resolveFilePath(client, input.data.path, config.folderId);
|
||||||
|
if (!fileId) return {success: false, provider: "google-drive", error: "File not found"};
|
||||||
|
|
||||||
|
const res = await client.files.get(
|
||||||
|
{fileId, alt: "media", supportsAllDrives: true},
|
||||||
|
{responseType: "stream"}
|
||||||
|
);
|
||||||
|
|
||||||
|
const stream = res.data as Readable;
|
||||||
|
|
||||||
|
|
||||||
|
if (input.data.signedUrl) {
|
||||||
|
const url = await generateFileUrl(input);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "google-drive",
|
||||||
|
response: "Unable to get url"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "google-drive",
|
||||||
|
file: stream,
|
||||||
|
url: url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "google-drive",
|
||||||
|
file: stream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGoogleDrive(
|
||||||
|
config: GoogleDriveConfig,
|
||||||
|
input: { data: StorageDeleteInput, metadata?: StorageMetaData },
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const client = await getGoogleDriveClient(config);
|
||||||
|
const fileId = await resolveFilePath(client, input.data.path, config.folderId);
|
||||||
|
if (!fileId) return {success: false, provider: "google-drive", error: "File not found"};
|
||||||
|
|
||||||
|
await client.files.delete({fileId, supportsAllDrives: true});
|
||||||
|
|
||||||
|
return {success: true, provider: "google-drive"};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingGoogleDrive(config: GoogleDriveConfig): Promise<StorageResult> {
|
||||||
|
try {
|
||||||
|
const drive = await getGoogleDriveClient(config);
|
||||||
|
const name = `ping-${Date.now()}.txt`;
|
||||||
|
const buffer = Buffer.from("ping");
|
||||||
|
|
||||||
|
const file = await drive.files.create({
|
||||||
|
requestBody: {name, parents: [config.folderId]},
|
||||||
|
media: {mimeType: "text/plain", body: Readable.from(buffer)},
|
||||||
|
fields: "id",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await drive.files.get({fileId: file.data.id!, supportsAllDrives: true});
|
||||||
|
await drive.files.delete({fileId: file.data.id!, supportsAllDrives: true});
|
||||||
|
|
||||||
|
return {success: true, provider: "google-drive", response: "Google Drive storage OK"};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {success: false, provider: "google-drive", response: err.message};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
export type GoogleDriveConfig = {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
refreshToken: string;
|
||||||
|
folderId: string;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user