mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24d90bb750 | ||
|
|
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 | ||
|
|
25c7702127 | ||
|
|
b546556d08 | ||
|
|
379a85f6f6 | ||
|
|
92d2c14d6a | ||
|
|
a6c1f7d3f2 | ||
|
|
6fa6187cc4 | ||
|
|
848e7a1609 | ||
|
|
3e8c0a2896 | ||
|
|
11900b1500 | ||
|
|
5668a1411f | ||
|
|
6db60f111d | ||
|
|
5c682addc9 | ||
|
|
24810cc818 | ||
|
|
8caeb11da5 | ||
|
|
5e07b446fc | ||
|
|
760388307c | ||
|
|
eebd8500ee | ||
|
|
12112afa37 | ||
|
|
cddb0a7e05 | ||
|
|
21b317e4f1 |
+9
-15
@@ -1,37 +1,31 @@
|
||||
# Environnement
|
||||
NODE_ENV=production
|
||||
NODE_ENV=development
|
||||
|
||||
# Base de données
|
||||
DATABASE_URL=postgresql://devuser:changeme@db:5432/devdb?schema=public
|
||||
DATABASE_URL=postgresql://devuser:changeme@localhost:5433/devdb?schema=public
|
||||
|
||||
# Projet
|
||||
PROJECT_NAME="Portabase"
|
||||
PROJECT_DESCRIPTION="Portabase is a powerful database manager"
|
||||
PROJECT_URL=http://app.portabase.io
|
||||
PROJECT_URL=http://localhost:8887
|
||||
PROJECT_SECRET=
|
||||
|
||||
# SMTP (email)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
#SMTP_SECURE="true"
|
||||
#Port 587 -> secure: false
|
||||
#Port 465 -> secure: true
|
||||
#By default : true
|
||||
|
||||
# Google
|
||||
AUTH_GOOGLE_ID=
|
||||
AUTH_GOOGLE_SECRET=
|
||||
AUTH_GOOGLE_METHOD=
|
||||
|
||||
# S3
|
||||
S3_ENDPOINT=http://app.s3.portabase.io
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_BUCKET_NAME=portabase
|
||||
S3_PORT=9000
|
||||
S3_USE_SSL=true
|
||||
|
||||
# Storage Type (s3, local)
|
||||
STORAGE_TYPE=local
|
||||
|
||||
# Retention
|
||||
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']
|
||||
@@ -53,11 +53,16 @@ jobs:
|
||||
TAGS2="${{ inputs.image_name2 }}:$REF_NAME"
|
||||
if [[ "${{ inputs.add_latest }}" == "true" ]]; then
|
||||
TAGS="$TAGS,${{ inputs.image_name }}:latest"
|
||||
TAGS2="$TAGS2,${{ inputs.image_name }}:latest"
|
||||
TAGS2="$TAGS2,${{ inputs.image_name2 }}:latest"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $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
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
@@ -70,6 +75,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags }}
|
||||
target: ${{ inputs.target }}
|
||||
@@ -85,6 +91,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.set-tags.outputs.tags2 }}
|
||||
target: ${{ inputs.target }}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
.idea
|
||||
.vscode
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/portabase.iml" filepath="$PROJECT_DIR$/.idea/portabase.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
-12
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/venv" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"WillLuke.nextjs.hasPrompted": true
|
||||
}
|
||||
+2
-2
@@ -26,5 +26,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.2.0-rc.1
|
||||
date-released: "2026-01-18"
|
||||
version: 1.2.5-rc.7
|
||||
date-released: "2026-02-01"
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
[](LICENSE)
|
||||
[](https://hub.docker.com/r/solucetechnologies/portabase)
|
||||
[](https://github.com/Portabase/portabase)
|
||||
[](https://www.buymeacoffee.com/portabase)
|
||||
|
||||
[](https://www.postgresql.org/)
|
||||
[](https://www.mysql.com/)
|
||||
[](https://mariadb.org/)
|
||||
[](https://www.mongodb.com/)
|
||||
[](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://www.buymeacoffee.com/portabase)
|
||||
|
||||
## 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 {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {Page, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
@@ -12,6 +9,8 @@ import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {generateEdgeKey} from "@/utils/edge_key";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
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 }>) {
|
||||
|
||||
@@ -37,17 +36,14 @@ export default async function RoutePage(props: PageParams<{ agentId: string }>)
|
||||
<div className="min-w-full md:min-w-fit ">
|
||||
{capitalizeFirstLetter(agent.name)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/agents/${agent.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentDialog agent={agent as AgentType & { id: string }} typeTrigger={"edit"}/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"}/>
|
||||
</div>
|
||||
</div>
|
||||
</PageTitle>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
|
||||
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {desc, eq, not} from "drizzle-orm";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
import {AgentDialog} from "@/features/agents/components/agent.dialog";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Agents",
|
||||
@@ -25,7 +23,6 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
orderBy: (fields) => desc(fields.lastContact),
|
||||
});
|
||||
|
||||
|
||||
if (!agents) {
|
||||
notFound();
|
||||
}
|
||||
@@ -36,9 +33,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Agents</PageTitle>
|
||||
{agents.length > 0 && (
|
||||
<PageActions>
|
||||
<Link href={"/dashboard/agents/new"}>
|
||||
<Button>+ Create Agent</Button>
|
||||
</Link>
|
||||
<AgentDialog typeTrigger={"create"} />
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -46,12 +41,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
{agents.length > 0 ? (
|
||||
<CardsWithPagination data={agents} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder
|
||||
url={"/dashboard/agents/new"}
|
||||
text={"Create new Agent"}
|
||||
/>
|
||||
<AgentDialog typeTrigger="empty"/>
|
||||
)}
|
||||
</PageContent>
|
||||
</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 {Page, PageActions, PageContent, PageDescription, PageTitle} from "@/features/layout/page";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {GearIcon} from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import {Page, PageContent, PageTitle} from "@/features/layout/page";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} 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 * as drizzleDb from "@/db";
|
||||
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<{
|
||||
@@ -24,6 +25,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
projectId
|
||||
} = await props.params;
|
||||
|
||||
if (!isUuidv4(projectId)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
@@ -51,6 +56,19 @@ export default async function RoutePage(props: PageParams<{
|
||||
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";
|
||||
|
||||
return (
|
||||
@@ -63,10 +81,12 @@ export default async function RoutePage(props: PageParams<{
|
||||
{!isMember && (
|
||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`/dashboard/projects/${proj.id}/edit`}>
|
||||
<GearIcon className="w-7 h-7"/>
|
||||
</Link>
|
||||
<ProjectDialog
|
||||
databases={availableDatabases}
|
||||
organization={org}
|
||||
project={proj as ProjectWith}
|
||||
isEdit={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
@@ -95,4 +115,4 @@ export default async function RoutePage(props: PageParams<{
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {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 {ProjectCard} from "@/components/wrappers/dashboard/projects/project-card/project-card";
|
||||
import {db} from "@/db";
|
||||
@@ -10,6 +7,8 @@ import {notFound} from "next/navigation";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
|
||||
import {Metadata} from "next";
|
||||
import {ProjectDialog} from "@/features/projects/components/project.dialog";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Projects",
|
||||
@@ -36,6 +35,19 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
});
|
||||
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 (
|
||||
<Page>
|
||||
@@ -43,9 +55,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
<PageTitle>Projects</PageTitle>
|
||||
{(projects.length > 0 && !isMember) && (
|
||||
<PageActions>
|
||||
<Link href={`/dashboard/projects/new`}>
|
||||
<Button>+ Create Project</Button>
|
||||
</Link>
|
||||
<ProjectDialog databases={availableDatabases} organization={organization}/>
|
||||
</PageActions>
|
||||
)}
|
||||
</PageHeader>
|
||||
@@ -62,9 +72,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
) : isMember ? (
|
||||
<EmptyStatePlaceholder text="No project available"/>
|
||||
) : (
|
||||
<EmptyStatePlaceholder url="/dashboard/projects/new" text="Create new Project"/>
|
||||
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
|
||||
)}
|
||||
</PageContent>
|
||||
</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 {getOrganizationStorageChannels} from "@/db/services/storage-channel";
|
||||
import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
|
||||
import {
|
||||
EditButtonSettings
|
||||
} from "@/components/wrappers/dashboard/organization/settings/edit-button-settings/edit-button-settings";
|
||||
import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
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 = {
|
||||
title: "Settings",
|
||||
@@ -22,7 +26,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const user = await currentUser();
|
||||
const activeMember = await getActiveMember()
|
||||
|
||||
if (!organization || !activeMember) {
|
||||
if (!organization || !activeMember || !user) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -30,6 +34,22 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
const storageChannels = await getOrganizationStorageChannels(organization.id)
|
||||
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 (
|
||||
<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">
|
||||
{permissions.canManageSettings && organization.slug !== "default" && (
|
||||
<EditButtonSettings/>
|
||||
<EditOrganizationDialog
|
||||
organization={organizationWithMembers}
|
||||
users={users}
|
||||
currentUser={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -62,4 +86,4 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
|
||||
</PageContent>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
@@ -10,6 +9,7 @@ import {withUpdatedAt} from "@/db/utils";
|
||||
import {decryptedDump, getFileExtension} from "./helpers";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {storeBackupFiles} from "@/features/storages/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
@@ -34,6 +34,7 @@ export async function POST(
|
||||
const generatedId = formData.get("generatedId") as string | null;
|
||||
const method = formData.get("method") as string | null;
|
||||
|
||||
|
||||
if (!generatedId || !isUuidv4(generatedId)) {
|
||||
return NextResponse.json(
|
||||
{error: "generatedId is not a valid UUID"},
|
||||
@@ -127,7 +128,8 @@ export async function POST(
|
||||
const fileName = `${uuid}${fileExtension}`;
|
||||
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});
|
||||
|
||||
await sendNotificationsBackupRestore(database, "success_backup");
|
||||
@@ -165,4 +167,3 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {Body} from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
||||
import {Agent} from "@/db/schema/08_agent";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db as dbClient} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {SafeActionResult} from "next-safe-action";
|
||||
import {ZodString} from "zod";
|
||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import type {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/dispatch";
|
||||
@@ -50,6 +46,12 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||
console.log(`Database type not available: ${db.dbms}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const [databaseCreated] = await dbClient
|
||||
.insert(drizzleDb.schemas.database)
|
||||
.values({
|
||||
@@ -77,10 +79,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.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({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
|
||||
@@ -110,25 +108,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
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) {
|
||||
restoreAction = false
|
||||
continue;
|
||||
@@ -141,6 +120,10 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
path: restoration.backupStorage.path,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: restoration.backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {handleDatabases} from "./helpers";
|
||||
import {eventEmitter} from "../../../events/route";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {db} from "@/db";
|
||||
import {EDbmsSchema} from "@/db/schema/types";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {eventEmitter} from "@/features/shared/event";
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
|
||||
@@ -2,8 +2,9 @@ import {EventEmitter} from 'events';
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
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) {
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import {NextResponse} from "next/server";
|
||||
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 {dispatchStorage} from "@/features/storages/dispatch";
|
||||
import {Readable} from "node:stream";
|
||||
@@ -15,17 +12,10 @@ export async function GET(
|
||||
const token = searchParams.get('token');
|
||||
const expires = searchParams.get('expires');
|
||||
const pathFromUrl = searchParams.get('path');
|
||||
const storageId = searchParams.get('storageId');
|
||||
|
||||
if (!pathFromUrl) {
|
||||
return NextResponse.json({error: "Missing file path in 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"})
|
||||
if (!pathFromUrl || !storageId) {
|
||||
return NextResponse.json({error: "Missing search params"}, {status: 404})
|
||||
}
|
||||
|
||||
const input: StorageInput = {
|
||||
@@ -34,17 +24,20 @@ export async function GET(
|
||||
path: pathFromUrl,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: storageId,
|
||||
fileKind: "backups",
|
||||
}
|
||||
};
|
||||
|
||||
console.debug(input);
|
||||
|
||||
const result = await dispatchStorage(input, undefined, localStorageChannel.id);
|
||||
const result = await dispatchStorage(input, undefined, storageId);
|
||||
|
||||
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 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(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = Readable.from(result.file);
|
||||
const fileStream = Readable.from(result.file as Readable);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -1,10 +1,6 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import {auth} from "@/lib/auth/auth";
|
||||
import {headers} from "next/headers";
|
||||
import {checkFileExistsInBucket, getObjectFromClient} from "@/utils/s3-file-management";
|
||||
import {env} from "@/env.mjs";
|
||||
import * as stream from "node:stream";
|
||||
import path from "path";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
@@ -16,28 +12,20 @@ export async function GET(
|
||||
req: Request,
|
||||
{params}: { params: Promise<{ fileName: string }> }
|
||||
) {
|
||||
|
||||
|
||||
const {searchParams} = new URL(req.url);
|
||||
const fileName = (await params).fileName;
|
||||
|
||||
const storageId = searchParams.get('storageId');
|
||||
|
||||
if (!fileName) return NextResponse.json({error: "Missing file parameter"}, {status: 400});
|
||||
|
||||
const session = await auth.api.getSession({headers: await headers()});
|
||||
if (!session) return NextResponse.json({error: "Unauthorized"}, {status: 403});
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
where: eq(drizzleDb.schemas.setting.name, "system"),
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!settings || !settings.storageChannel) {
|
||||
return NextResponse.json({error: "Unable to get settings or no default storage channel"});
|
||||
if (!storageId) {
|
||||
return NextResponse.json({error: "Missing storageId in search params"}, {status: 404})
|
||||
}
|
||||
|
||||
|
||||
const ext = fileName.split(".").pop()?.toLowerCase();
|
||||
const contentType =
|
||||
ext === "png"
|
||||
@@ -58,19 +46,24 @@ export async function GET(
|
||||
action: "get",
|
||||
data: {
|
||||
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(
|
||||
{error: "Invalid file payload"},
|
||||
{status: 500}
|
||||
);
|
||||
}
|
||||
|
||||
const fileStream = Readable.from(result.file);
|
||||
const fileStream = Readable.from(result.file as Readable);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
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:
|
||||
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: solucetechnologies/portabase:1.1.4-rc.2
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# image: solucetechnologies/portabase:latest
|
||||
ports:
|
||||
- '8887:80'
|
||||
environment:
|
||||
|
||||
@@ -62,7 +62,6 @@ RUN pnpm run build
|
||||
|
||||
|
||||
|
||||
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app
|
||||
@@ -70,6 +69,9 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
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/public
|
||||
|
||||
|
||||
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/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --chown=nextjs:nodejs src/db ./src/db
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
USER root
|
||||
|
||||
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"
|
||||
fi
|
||||
|
||||
|
||||
node server.js
|
||||
pnpm run start
|
||||
#node server.js
|
||||
|
||||
exec "$@"
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ const nextConfig: NextConfig = {
|
||||
bodySizeLimit: "10gb",
|
||||
},
|
||||
proxyClientMaxBodySize: '10gb',
|
||||
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
|
||||
+13
-6
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.2.0-rc.1",
|
||||
"version": "1.2.5-rc.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
"dev": "NODE_ENV=development tsx watch server/server.ts",
|
||||
"build": "next build --experimental-build-mode compile",
|
||||
"start": "next start",
|
||||
"start": "NODE_ENV=production tsx server/server.ts",
|
||||
|
||||
"lint": "next lint",
|
||||
"email": "email dev --dir ./src/components/emails",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@t3-oss/env-nextjs": "^0.13.4",
|
||||
"@tanstack/react-query": "^5.76.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
@@ -62,11 +64,13 @@
|
||||
"drizzle-orm": "^0.43.1",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"express": "^5.2.1",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
"minio": "^8.0.5",
|
||||
"motion": "^12.23.24",
|
||||
"next": "16.0.10",
|
||||
"next": "16.1.5",
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
@@ -74,6 +78,7 @@
|
||||
"nodemailer": "^7.0.3",
|
||||
"npm-check-updates": "^18.0.1",
|
||||
"pg": "^8.16.0",
|
||||
"prettier": "^3.8.0",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
@@ -85,6 +90,7 @@
|
||||
"react-twc": "^1.4.2",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.3",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.3",
|
||||
@@ -108,8 +114,9 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@zenstackhq/openapi": "^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",
|
||||
"esbuild": "^0.27.2",
|
||||
"eslint": "^9.39.0",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"eslint-plugin-tailwindcss": "^3.18.0",
|
||||
@@ -121,5 +128,5 @@
|
||||
"typescript": "^5.8.3",
|
||||
"zenstack": "2.14.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0"
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||
}
|
||||
|
||||
Generated
+1947
-2245
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,13 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@prisma/client'
|
||||
- '@prisma/engines'
|
||||
- argon2
|
||||
- bcrypt
|
||||
- cpu-features
|
||||
- esbuild
|
||||
- prisma
|
||||
- protobufjs
|
||||
- sharp
|
||||
- ssh2
|
||||
- unrs-resolver
|
||||
- zenstack
|
||||
|
||||
@@ -54,11 +54,12 @@ function checkRouteExists(pathname: string) {
|
||||
/^\/api\/agent\/[^/]+\/status\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/backup\/?$/,
|
||||
/^\/api\/agent\/[^/]+\/restore\/?$/,
|
||||
/^\/api\/files\/?$/,
|
||||
/^\/api\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/images\/[^/]+\/?$/,
|
||||
/^\/api\/files\/backups\/?$/,
|
||||
/^\/api\/events\/?$/,
|
||||
/^\/api\/init\/?$/,
|
||||
/^\/api\/config\/?$/,
|
||||
/^\/api\/google\/drive\/callback\/?$/,
|
||||
];
|
||||
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,52 @@
|
||||
import next from "next";
|
||||
import express from "express";
|
||||
import {mountApi} from "./api";
|
||||
import http from "http";
|
||||
|
||||
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 app = express();
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.setTimeout(0);
|
||||
res.setTimeout(0);
|
||||
next();
|
||||
});
|
||||
|
||||
mountApi(app);
|
||||
|
||||
app.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');
|
||||
});
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
server.setTimeout(0);
|
||||
server.headersTimeout = 0;
|
||||
server.requestTimeout = 0;
|
||||
server.keepAliveTimeout = 0;
|
||||
|
||||
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"
|
||||
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",
|
||||
"max-h-[90vh] overflow-y-auto",
|
||||
"max-h-[90vh] overflow-y-auto overflow-x-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type ConnectionCircleProps = {
|
||||
date?: Date | null;
|
||||
};
|
||||
|
||||
export const ConnectionCircle = ({date}: ConnectionCircleProps) => {
|
||||
let style = "bg-gray-300 border-gray-400";
|
||||
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
const now = Date.now();
|
||||
const timestamp = date.getTime();
|
||||
const interval_seconds = (now - timestamp) / 1000;
|
||||
|
||||
if (interval_seconds < 55) {
|
||||
style = "bg-green-400 border-green-600";
|
||||
} else if (interval_seconds <= 60) {
|
||||
style = "bg-orange-400 border-orange-600";
|
||||
} else {
|
||||
style = "bg-red-400 border-red-600";
|
||||
}
|
||||
} else {
|
||||
console.warn("Invalid date passed to ConnectionCircle:", date);
|
||||
}
|
||||
|
||||
return <div className={cn("w-5 h-5 rounded-full border-4", style)}/>;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type ConnectionIndicatorProps = {
|
||||
date?: Date | null;
|
||||
};
|
||||
|
||||
export const ConnectionIndicator = ({date}: ConnectionIndicatorProps) => {
|
||||
let style = "bg-gray-300";
|
||||
|
||||
if (date instanceof Date && !isNaN(date.getTime())) {
|
||||
const intervalSeconds = (Date.now() - date.getTime()) / 1000;
|
||||
|
||||
if (intervalSeconds < 55) {
|
||||
style = "bg-green-500";
|
||||
} else if (intervalSeconds <= 60) {
|
||||
style = "bg-orange-400";
|
||||
} else {
|
||||
style = "bg-red-500";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-3 h-3">
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -inset-0.25 rounded-full opacity-60 animate-ping",
|
||||
style
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-3 h-3 rounded-full shadow-sm animate-pulse",
|
||||
style
|
||||
)}
|
||||
style={{
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,20 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Plus} from "lucide-react";
|
||||
import {forwardRef, HTMLAttributes} from "react";
|
||||
|
||||
type EmptyStatePlaceholderProps = {
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
} & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EmptyStatePlaceholder = ({
|
||||
export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
|
||||
url,
|
||||
onClick,
|
||||
text,
|
||||
className,
|
||||
}: EmptyStatePlaceholderProps) => {
|
||||
...props
|
||||
}, ref) => {
|
||||
const Container = (
|
||||
<div
|
||||
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.
|
||||
* 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: {
|
||||
variant: {
|
||||
default: "border-foreground/10 text-foreground bg-card hover:bg-card/80",
|
||||
@@ -177,13 +177,13 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
||||
return (
|
||||
<Badge
|
||||
key={value}
|
||||
className={cn(isAnimating ? "animate-bounce" : "", multiSelectVariants({ variant }))}
|
||||
className={cn(isAnimating ? "animate-bounce" : "", "max-w-full", multiSelectVariants({ variant }))}
|
||||
style={{ animationDuration: `${animation}s` }}
|
||||
>
|
||||
{IconComponent && <IconComponent className="h-4 w-4 mr-2" />}
|
||||
{option?.label}
|
||||
<span className="truncate">{option?.label}</span>
|
||||
<XCircle
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
className="ml-2 h-4 w-4 cursor-pointer shrink-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleOption(value);
|
||||
@@ -196,6 +196,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
||||
<Badge
|
||||
className={cn(
|
||||
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
|
||||
"max-w-full",
|
||||
isAnimating ? "animate-bounce" : "",
|
||||
multiSelectVariants({ variant })
|
||||
)}
|
||||
@@ -203,7 +204,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
|
||||
>
|
||||
{`+ ${selectedValues.length - maxCount} more`}
|
||||
<XCircle
|
||||
className="ml-2 h-4 w-4 cursor-pointer"
|
||||
className="ml-2 h-4 w-4 cursor-pointer shrink-0"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
clearExtraOptions();
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export const NotificationChannelFormSchema = 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"}),
|
||||
});
|
||||
|
||||
|
||||
|
||||
+46
-8
@@ -25,8 +25,8 @@ import {
|
||||
ChannelKind,
|
||||
renderChannelForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
import {storageTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
|
||||
import {notificationTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
|
||||
import {notificationProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
import {
|
||||
ChannelTestButton
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-test-button";
|
||||
@@ -94,7 +94,7 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
||||
});
|
||||
|
||||
const provider = form.watch("provider");
|
||||
const channelTypes = kind == "notification" ? notificationTypes : storageTypes
|
||||
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
|
||||
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
|
||||
|
||||
if (isCreate && !provider) {
|
||||
@@ -106,18 +106,55 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
||||
<Card
|
||||
key={type.value}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3 p-4 cursor-pointer hover:bg-accent/50 hover:border-primary/50 transition-all",
|
||||
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
|
||||
type.preview
|
||||
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
|
||||
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (type.preview) return;
|
||||
form.setValue("provider", type.value as any);
|
||||
form.setValue("config", {});
|
||||
}}
|
||||
>
|
||||
<div className="h-10 w-10 bg-secondary rounded-full flex items-center justify-center">
|
||||
<Icon className="h-6 w-6 text-foreground"/>
|
||||
|
||||
{type.preview && (
|
||||
<div
|
||||
className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
|
||||
transform -rotate-45 translate-x-14 -translate-y-4"
|
||||
style={{backgroundColor: "#FE6702"}}
|
||||
>
|
||||
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
|
||||
type.preview
|
||||
? "bg-muted"
|
||||
: "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-6 w-6",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-sm">{type.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm text-center leading-tight",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</span>
|
||||
</Card>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -213,4 +250,5 @@ export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -46,6 +46,7 @@ export const ChannelTestButton = ({channel, organizationId, kind}: NotifierTestC
|
||||
if (result.success) {
|
||||
toast.success("Successfully connected to storage channel");
|
||||
} else {
|
||||
console.error(result);
|
||||
toast.error("An error occurred while testing the storage channel, check your configuration");
|
||||
}
|
||||
} else {
|
||||
|
||||
+33
-1
@@ -3,7 +3,6 @@ import {FormControl, FormField, FormItem, FormLabel, FormMessage} from "@/compon
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
|
||||
|
||||
type NotifierNtfyFormProps = {
|
||||
form: UseFormReturn<any, any, any>
|
||||
}
|
||||
@@ -12,6 +11,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyTopic"
|
||||
@@ -25,6 +25,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyServerUrl"
|
||||
@@ -39,6 +40,7 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.ntfyToken"
|
||||
@@ -53,6 +55,36 @@ export const NotifierNtfyForm = ({form}: NotifierNtfyFormProps) => {
|
||||
</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},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
@@ -20,16 +20,18 @@ import {
|
||||
import {
|
||||
NotifierWebhookForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
|
||||
import {notificationTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
import {storageTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
import {storageProviders} from "@/components/wrappers/dashboard/admin/channels/helpers/storage";
|
||||
import {ForwardRefExoticComponent, JSX, RefAttributes, SVGProps} from "react";
|
||||
import {LucideProps} from "lucide-react";
|
||||
import {
|
||||
StorageS3Form
|
||||
} 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";
|
||||
|
||||
@@ -45,19 +47,21 @@ export function getChannelTextBasedOnKind(kind: ChannelKind) {
|
||||
}
|
||||
|
||||
|
||||
type ProviderIconTypes = {
|
||||
export type ProviderIconTypes = {
|
||||
value: string
|
||||
label: string
|
||||
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>
|
||||
preview?: boolean
|
||||
} | {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => JSX.Element
|
||||
preview?: boolean
|
||||
}
|
||||
|
||||
const providerIcons: ProviderIconTypes[] = [
|
||||
...notificationTypes,
|
||||
...storageTypes,
|
||||
export const providerIcons: ProviderIconTypes[] = [
|
||||
...notificationProviders,
|
||||
...storageProviders,
|
||||
];
|
||||
|
||||
|
||||
@@ -85,6 +89,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
return <StorageGoogleDriveForm form={form}/>
|
||||
case "local":
|
||||
return <></>
|
||||
default:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {Mail} from "lucide-react";
|
||||
import type { SVGProps } from 'react';
|
||||
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
|
||||
|
||||
export const notificationTypes = [
|
||||
|
||||
export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "smtp", label: "Email", icon: Mail},
|
||||
{value: "slack", label: "Slack", icon: SlackIcon},
|
||||
{value: "discord", label: "Discord", icon: DiscordIcon},
|
||||
@@ -11,6 +13,7 @@ export const notificationTypes = [
|
||||
{value: "gotify", label: "Gotify", icon: GotifyIcon},
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||
]
|
||||
|
||||
|
||||
@@ -481,3 +484,49 @@ export function WebhookIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (<svg xmlns="http://www.w3.org/2000/svg" width={256} height={256} viewBox="0 0 256 239" {...props}>
|
||||
<defs>
|
||||
<path id="logosMicrosoftTeams0"
|
||||
d="M136.93 64.476v12.8a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.79-22.742h21.848c6.008.022 10.872 4.887 10.895 10.894Z"
|
||||
opacity=".2"/>
|
||||
</defs>
|
||||
<defs>
|
||||
<linearGradient id="logosMicrosoftTeams1" x1="17.372%" x2="82.628%" y1="-6.51%" y2="106.51%">
|
||||
<stop offset="0%" stop-color="#5A62C3"/>
|
||||
<stop offset="50%" stop-color="#4D55BD"/>
|
||||
<stop offset="100%" stop-color="#3940AB"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="#5059C9"
|
||||
d="M178.563 89.302h66.125c6.248 0 11.312 5.065 11.312 11.312v60.231c0 22.96-18.613 41.574-41.573 41.574h-.197c-22.96.003-41.576-18.607-41.579-41.568V95.215a5.912 5.912 0 0 1 5.912-5.913Z"/>
|
||||
<circle cx="223.256" cy="50.605" r="26.791" fill="#5059C9"/>
|
||||
<circle cx="139.907" cy="38.698" r="38.698" fill="#7B83EB"/>
|
||||
<path fill="#7B83EB"
|
||||
d="M191.506 89.302H82.355c-6.173.153-11.056 5.276-10.913 11.449v68.697c-.862 37.044 28.445 67.785 65.488 68.692c37.043-.907 66.35-31.648 65.489-68.692v-68.697c.143-6.173-4.74-11.296-10.913-11.449Z"/>
|
||||
<path
|
||||
d="M142.884 89.302v96.268a10.96 10.96 0 0 1-6.787 10.062c-1.3.55-2.697.833-4.108.833H76.68c-.774-1.965-1.488-3.93-2.084-5.953a72.509 72.509 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h60.547Z"
|
||||
opacity=".1"/>
|
||||
<path
|
||||
d="M136.93 89.302v102.222c0 1.411-.283 2.808-.833 4.108a10.96 10.96 0 0 1-10.062 6.787H79.48c-1.012-1.965-1.965-3.93-2.798-5.954a59.049 59.049 0 0 1-2.084-5.953a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M136.93 89.302v90.315c-.045 5.998-4.896 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h54.593Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M130.977 89.302v90.315c-.046 5.998-4.897 10.85-10.895 10.895H74.597a72.508 72.508 0 0 1-3.155-21.076v-68.703c-.143-6.163 4.732-11.278 10.895-11.43h48.64Z"
|
||||
opacity=".2"/>
|
||||
<path
|
||||
d="M142.884 58.523v18.753c-1.012.06-1.965.12-2.977.12c-1.012 0-1.965-.06-2.977-.12a32.674 32.674 0 0 1-5.953-.952a38.698 38.698 0 0 1-26.791-22.742a33.082 33.082 0 0 1-1.905-5.954h29.708c6.007.023 10.872 4.887 10.895 10.895Z"
|
||||
opacity=".1"/>
|
||||
<use href="#logosMicrosoftTeams0" opacity=".2"/>
|
||||
<use href="#logosMicrosoftTeams0" opacity=".2"/>
|
||||
<path d="M130.977 64.476v11.848a38.698 38.698 0 0 1-26.791-22.743h15.896c6.008.023 10.872 4.888 10.895 10.895Z"
|
||||
opacity=".2"/>
|
||||
<path fill="url(#logosMicrosoftTeams1)"
|
||||
d="M10.913 53.581h109.15c6.028 0 10.914 4.886 10.914 10.913v109.151c0 6.027-4.886 10.913-10.913 10.913H10.913C4.886 184.558 0 179.672 0 173.645V64.495C0 58.466 4.886 53.58 10.913 53.58Z"/>
|
||||
<path fill="#FFF" d="M94.208 95.125h-21.82v59.416H58.487V95.125H36.769V83.599h57.439z"/>
|
||||
</svg>)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {Server} from "lucide-react";
|
||||
import type {SVGProps} from "react";
|
||||
import {ProviderIconTypes} from "@/components/wrappers/dashboard/admin/channels/helpers/common";
|
||||
|
||||
export const storageTypes = [
|
||||
export const storageProviders: ProviderIconTypes[] = [
|
||||
{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>) {
|
||||
@@ -15,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 {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import {deleteOrganizationAction} from "@/features/organization/organization.action";
|
||||
|
||||
export type ButtonDeleteFleetProps = {
|
||||
text?: string;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {Input} from "@/components/ui/input";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import {updateOrganizationAction} from "@/features/organization/organization.action";
|
||||
|
||||
type UpdateOrganizationFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
} from "@/components/wrappers/dashboard/admin/settings/email/email-form/email-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {sendEmail} from "@/lib/email/email-helper";
|
||||
import {sendEmail} from "@/lib/email";
|
||||
import {render} from "@react-email/components";
|
||||
import EmailSettingsTest from "@/components/emails/email-settings-test";
|
||||
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 {SettingsStorageSection} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage-section";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {MailboxIcon, Save} from "lucide-react";
|
||||
|
||||
export type SettingsTabsProps = {
|
||||
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 (
|
||||
<div className="h-full mt-3">
|
||||
<Tabs className="h-full" value={tab} onValueChange={handleChangeTab}>
|
||||
<TabsList className='bg-background rounded-none border-b p-0 min-w-48'>
|
||||
<TabsTrigger
|
||||
value="email"
|
||||
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'
|
||||
>
|
||||
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>
|
||||
<Tabs className="h-full gap-4" value={tab} onValueChange={handleChangeTab}>
|
||||
<TabsList>
|
||||
{tabs.map(({icon: Icon, name, value}) => (
|
||||
<TabsTrigger key={value} value={value} className='flex items-center gap-1 px-2.5 sm:px-3'>
|
||||
<Icon/>
|
||||
{name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<TabsContent className="h-full" value="email">
|
||||
<SettingsEmailSection settings={settings}/>
|
||||
</TabsContent>
|
||||
<TabsContent className="h-full" value="storage">
|
||||
<SettingsStorageSection storageChannels={storageChannels} settings={settings}/>
|
||||
</TabsContent>
|
||||
|
||||
{tabs.map(tab => (
|
||||
<TabsContent key={tab.value} value={tab.value} className="h-full">
|
||||
{tab.content}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
+10
-10
@@ -64,15 +64,15 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
||||
await mutation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storageChannelId"
|
||||
render={({field}) => (
|
||||
<FormItem className="flex items-center justify-center">
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-grow min-w-[200px] sm:flex-grow-0 sm:w-64">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-90 h-full mb-0">
|
||||
<SelectValue placeholder="Select a default storage channel"/>
|
||||
<SelectTrigger className="w-full h-full mb-0">
|
||||
<SelectValue placeholder="Select a default storage channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storageChannels.map((channel) => (
|
||||
@@ -80,10 +80,9 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
||||
<div className="flex items-center gap-2">
|
||||
{getChannelIcon(channel.provider)}
|
||||
<span className="font-medium">{channel.name}</span>
|
||||
<span
|
||||
className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
||||
{channel.provider}
|
||||
</span>
|
||||
<span className="text-[9px] uppercase bg-secondary px-1.5 py-0.5 rounded">
|
||||
{channel.provider}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -92,10 +91,11 @@ export const SettingsStorageSection = ({settings, storageChannels}: SettingsStor
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ButtonWithLoading type="submit">
|
||||
<ButtonWithLoading className="flex-shrink-0 w-full sm:w-auto" type="submit">
|
||||
Confirm
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -46,14 +46,14 @@ export function usersListColumns(): ColumnDef<User>[] {
|
||||
<TableCell
|
||||
className="text-right text-muted-foreground">{formatLocalizedDate(row.original.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
{/*{row.original.lastConnectedAt && (*/}
|
||||
{/* <TableRow>*/}
|
||||
{/* <TableCell>Last connected</TableCell>*/}
|
||||
{/* <TableCell className="text-right text-muted-foreground">*/}
|
||||
{/* {formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt, locale)})*/}
|
||||
{/* </TableCell>*/}
|
||||
{/* </TableRow>*/}
|
||||
{/*)}*/}
|
||||
{row.original.lastConnectedAt && (
|
||||
<TableRow>
|
||||
<TableCell>Last connected</TableCell>
|
||||
<TableCell className="text-right text-muted-foreground">
|
||||
{formatLocalizedDate(row.original.lastConnectedAt)} ({timeAgo(row.original.lastConnectedAt)})
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{row.original.lastChangedPasswordAt && (
|
||||
<TableRow>
|
||||
<TableCell>Last password change</TableCell>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {
|
||||
addMemberOrganizationAction
|
||||
} 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 {SignUpUser} from "@/types/auth";
|
||||
import {createUserDb} from "@/db/services/user";
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import {useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
|
||||
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database, ShieldCheck} from "lucide-react";
|
||||
import {Activity, ChevronRight, Copy, Check, Fingerprint, Server, Database} from "lucide-react";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {truncateWords} from "@/utils/text";
|
||||
import {useIsMobile} from "@/hooks/use-mobile";
|
||||
@@ -86,7 +86,7 @@ export const AgentCard = (props: agentCardProps) => {
|
||||
</div>
|
||||
|
||||
<div className="scale-110">
|
||||
<ConnectionCircle date={agent.lastContact}/>
|
||||
<ConnectionIndicator date={agent.lastContact}/>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import {getAccounts, getSession, getSessions} from "@/lib/auth/auth";
|
||||
import {LoggedInButtonClient} from "./logged-in-button";
|
||||
import {SUPPORTED_PROVIDERS} from "../../../../../../portabase.config";
|
||||
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
const sessions = await getSessions();
|
||||
@@ -16,7 +17,7 @@ export const LoggedInButton = async () => {
|
||||
<LoggedInButtonClient
|
||||
user={user}
|
||||
sessions={sessions}
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
currentSession={currentSession.session}
|
||||
accounts={accounts}
|
||||
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {ChevronsUpDown, ChevronUp} from "lucide-react";
|
||||
import {ChevronsUpDown} from "lucide-react";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {SidebarMenuButton} from "@/components/ui/sidebar";
|
||||
import {LoggedInDropdown} from "./logged-in-dropdown";
|
||||
@@ -15,6 +15,7 @@ type LoggedInButtonClientProps = {
|
||||
providers: AuthProviderConfig[]
|
||||
}
|
||||
|
||||
|
||||
export const LoggedInButtonClient = ({
|
||||
user,
|
||||
sessions,
|
||||
@@ -35,14 +36,15 @@ export const LoggedInButtonClient = ({
|
||||
accounts={accounts}
|
||||
providers={providers}
|
||||
>
|
||||
<SidebarMenuButton type="button" className="h-auto justify-between py-2" >
|
||||
<SidebarMenuButton type="button" className="h-auto justify-between py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{user.name[0].toUpperCase()}</AvatarFallback>
|
||||
{user.image && <AvatarImage src={user.image}/>}
|
||||
</Avatar>
|
||||
<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
|
||||
className="text-xs text-muted-foreground max-w-[170px] truncate"
|
||||
title={user.email}
|
||||
|
||||
@@ -26,7 +26,7 @@ type ProfileModalProps = {
|
||||
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers }: ProfileModalProps) => {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Manage your account settings</DialogDescription>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sideba
|
||||
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
|
||||
import {env} from "@/env.mjs";
|
||||
import {LoggedInButton} from "@/components/wrappers/dashboard/common/logged-in/logged-in-button.server";
|
||||
import {UpdateNotification} from "@/features/updates/components/update-notification";
|
||||
|
||||
export function AppSidebar() {
|
||||
const projectName = env.PROJECT_NAME;
|
||||
@@ -31,6 +32,8 @@ export function AppSidebar() {
|
||||
<SidebarMenuCustomMain/>
|
||||
</SidebarContent>
|
||||
|
||||
<UpdateNotification />
|
||||
|
||||
<SidebarMenu className="mb-2">
|
||||
<SidebarMenuItem className="p-2">
|
||||
<LoggedInButton/>
|
||||
|
||||
@@ -75,7 +75,7 @@ export const BackupActionsForm = ({backup, action}: BackupActionsFormProps) => {
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
if (action === "download") {
|
||||
console.log(inner.value)
|
||||
|
||||
const url = inner.value
|
||||
if (typeof url === "string") {
|
||||
window.open(url, "_self");
|
||||
|
||||
@@ -51,9 +51,13 @@ export const downloadBackupAction = userAction.schema(
|
||||
path: backupStorage.path,
|
||||
signedUrl: true,
|
||||
},
|
||||
metadata: {
|
||||
storageId: backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
};
|
||||
|
||||
console.log(input)
|
||||
|
||||
|
||||
const result = await dispatchStorage(input, undefined, backupStorage.storageChannelId);
|
||||
console.log(result);
|
||||
|
||||
@@ -106,8 +106,7 @@ export const ChannelPoliciesForm = ({
|
||||
});
|
||||
|
||||
|
||||
console.log(policiesToUpdate);
|
||||
console.log(policiesToAdd);
|
||||
|
||||
|
||||
const promises = kind === "notification"
|
||||
? [
|
||||
|
||||
@@ -6,8 +6,8 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from "@/
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {OrganizationSchema} from "@/components/wrappers/dashboard/organization/organization.schema";
|
||||
import {createOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
import {CreateOrganizationSchema, CreateOrganizationType} from "@/features/organization/organization.schema";
|
||||
import {createOrganizationAction} from "@/features/organization/organization.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
@@ -32,12 +32,12 @@ export function CreateOrganizationModal({
|
||||
const router = useRouter();
|
||||
|
||||
const form = useZodForm({
|
||||
schema: OrganizationSchema,
|
||||
schema: CreateOrganizationSchema,
|
||||
});
|
||||
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: OrganizationSchema) => {
|
||||
mutationFn: async (values: CreateOrganizationType) => {
|
||||
|
||||
const result = await createOrganizationAction(values);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
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 {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
|
||||
@@ -1,29 +1,39 @@
|
||||
"use client"
|
||||
import {ComboBox} from "@/components/wrappers/common/combobox";
|
||||
import {useSidebar} from "@/components/ui/sidebar";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {CreateOrganizationModal} from "@/components/wrappers/dashboard/organization/create-organisation-modal";
|
||||
import {useState} from "react";
|
||||
|
||||
import * as React from "react"
|
||||
import {Building2, Check, ChevronsUpDown, Plus} from "lucide-react"
|
||||
import {useRouter} from "next/navigation"
|
||||
import {authClient} from "@/lib/auth/auth-client"
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const {state} = useSidebar();
|
||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||
const {data: activeOrganization, refetch: refetchActiveOrga} = authClient.useActiveOrganization();
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
const router = useRouter()
|
||||
const {isMobile, state} = useSidebar()
|
||||
const {data: organizations, isPending: isPendingList, refetch} = authClient.useListOrganizations()
|
||||
const {
|
||||
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 values = organizations.map(org => ({ value: org.slug, label: org.name }));
|
||||
const values = organizations?.map(org => ({ value: org.slug, label: org.name })) ?? [];
|
||||
|
||||
|
||||
|
||||
const onValueChange = async (slug: string) => {
|
||||
await authClient.organization.setActive({ organizationSlug: slug });
|
||||
router.refresh();
|
||||
};
|
||||
const handleSelect = async (slug: string) => {
|
||||
if (activeOrganization?.slug === slug) return
|
||||
await authClient.organization.setActive({organizationSlug: slug})
|
||||
router.refresh()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleReload = () => {
|
||||
refetch();
|
||||
@@ -31,6 +41,22 @@ export function OrganizationCombobox() {
|
||||
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 (
|
||||
<>
|
||||
<CreateOrganizationModal
|
||||
@@ -38,16 +64,96 @@ export function OrganizationCombobox() {
|
||||
onSuccess={handleReload}
|
||||
onOpenChange={setOpenModal}
|
||||
/>
|
||||
{state === "expanded" && (
|
||||
<ComboBox
|
||||
sideBar
|
||||
values={values}
|
||||
defaultValue={activeOrganization?.slug}
|
||||
onValueChangeAction={onValueChange}
|
||||
onAddItemAction={() => setOpenModal(true)}
|
||||
addItemLabel="Create new organization"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className={cn(
|
||||
"data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground transition-all duration-200 ease-in-out",
|
||||
state === "collapsed" && "justify-center p-0"
|
||||
)}
|
||||
>
|
||||
<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 file = event.target.files?.[0];
|
||||
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;
|
||||
}
|
||||
|
||||
const maxSizeInMB = 5;
|
||||
if (file.size > maxSizeInMB * 1024 * 1024) {
|
||||
toast.error(`Image must be smaller than ${maxSizeInMB}MB.`);
|
||||
return;
|
||||
}
|
||||
|
||||
submitImage.mutate(file);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="relative ">
|
||||
|
||||
@@ -68,12 +79,12 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
onClick={() => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*";
|
||||
fileInput.accept = ".jpg,.jpeg,.png,.webp";
|
||||
// @ts-ignore
|
||||
fileInput.onchange = handleImageUpload;
|
||||
fileInput.click();
|
||||
}}
|
||||
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-100 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
|
||||
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
|
||||
>
|
||||
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export const ButtonDeleteProject = (props: ButtonDeleteProjectProps) => {
|
||||
onSuccess: async (result: any) => {
|
||||
if (result.data?.success) {
|
||||
toast.success(result.data.actionSuccess.message);
|
||||
router.push("/");
|
||||
router.push("/dashboard/projects");
|
||||
} else {
|
||||
toast.error(result.data.actionError.message || "Unknown error occurred.");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {useState} from "react";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ConnectionCircle} from "@/components/wrappers/common/connection-circle";
|
||||
import {ConnectionIndicator} from "@/components/wrappers/common/connection-indicator";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
|
||||
@@ -60,7 +60,7 @@ export const DatabaseCard = (props: databaseCardProps) => {
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-3">
|
||||
<div className="scale-100 origin-right">
|
||||
<ConnectionCircle date={database.lastContact}/>
|
||||
<ConnectionIndicator date={database.lastContact}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "lastConnectedAt" timestamp;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "lastConnectedAt" timestamp;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,34 @@
|
||||
"when": 1768733221276,
|
||||
"tag": "0028_graceful_ben_parker",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1768743644834,
|
||||
"tag": "0029_lowly_white_tiger",
|
||||
"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 {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
@@ -19,6 +19,7 @@ export const setting = pgTable("settings", {
|
||||
smtpHost: varchar("smtp_host", {length: 255}),
|
||||
smtpPort: varchar("smtp_port", {length: 255}),
|
||||
smtpUser: varchar("smtp_user", {length: 255}),
|
||||
smtpSecure: boolean("smtp_secure"),
|
||||
defaultStorageChannelId: uuid('default_storage_channel_id')
|
||||
.references(() => storageChannel.id, {onDelete: "set null"}),
|
||||
...timestamps
|
||||
|
||||
@@ -3,7 +3,7 @@ import {boolean, integer, pgEnum, pgTable, text, timestamp, uuid} from "drizzle-
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
import {project} from "./06_project";
|
||||
import {member, OrganizationMember} from "@/db/schema/04_member";
|
||||
import {member} from "@/db/schema/04_member";
|
||||
import {invitation} from "@/db/schema/05_invitation";
|
||||
import {organization} from "@/db/schema/03_organization";
|
||||
import {Account as BetterAuthAccount} from "better-auth";
|
||||
@@ -23,6 +23,7 @@ export const user = pgTable("user", {
|
||||
banned: boolean("banned"),
|
||||
banReason: text("ban_reason"),
|
||||
banExpires: timestamp("ban_expires"),
|
||||
lastConnectedAt: timestamp(),
|
||||
lastChangedPasswordAt: timestamp(),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").default(false),
|
||||
...timestamps
|
||||
|
||||
@@ -7,7 +7,7 @@ import {z} from "zod";
|
||||
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', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { pgEnum } from "drizzle-orm/pg-core";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
import {pgEnum} from "drizzle-orm/pg-core";
|
||||
import {createSelectSchema} from "drizzle-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 typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||
|
||||
|
||||
+2
-1
@@ -22,6 +22,7 @@ export const env = createEnv({
|
||||
SMTP_HOST: z.string().optional(),
|
||||
SMTP_PORT: z.string().optional(),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_SECURE: z.coerce.boolean().default(true),
|
||||
|
||||
AUTH_GOOGLE_ID: z.string().optional(),
|
||||
AUTH_GOOGLE_SECRET: z.string().optional(),
|
||||
@@ -43,7 +44,6 @@ export const env = createEnv({
|
||||
},
|
||||
client: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: z.string().optional(),
|
||||
|
||||
},
|
||||
runtimeEnv: {
|
||||
NEXT_PUBLIC_PROJECT_VERSION: version || "Unknown Version",
|
||||
@@ -60,6 +60,7 @@ export const env = createEnv({
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
|
||||
AUTH_GOOGLE_ID: process.env.AUTH_GOOGLE_ID,
|
||||
AUTH_GOOGLE_SECRET: process.env.AUTH_GOOGLE_SECRET,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
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 {eq, and, ne, count} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AgentSchema = z.object({
|
||||
name: z.string(),
|
||||
name: z.string().nonempty("Name is required"),
|
||||
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>
|
||||
);
|
||||
};
|
||||
+9
-4
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import {Card, CardContent} from "@/components/ui/card";
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
@@ -16,13 +15,14 @@ import {Button} from "@/components/ui/button";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
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 {createAgentAction, updateAgentAction} from "@/components/wrappers/dashboard/agent/agent-form/agent-form.action";
|
||||
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
|
||||
|
||||
export type agentFormProps = {
|
||||
defaultValues?: AgentType;
|
||||
agentId?: string;
|
||||
onSuccess?: (data: any) => void;
|
||||
};
|
||||
|
||||
export const AgentForm = (props: agentFormProps) => {
|
||||
@@ -52,7 +52,12 @@ export const AgentForm = (props: agentFormProps) => {
|
||||
}
|
||||
toast.success(`Success ${isCreate ? "creating" : "updating"} agent`);
|
||||
router.refresh();
|
||||
router.push(`/dashboard/agents/${data.id}`);
|
||||
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess(data);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user