Compare commits

..
97 Commits
Author SHA1 Message Date
charlesgauthereau 24d90bb750 chore(release): 1.2.5-rc.7 2026-02-01 21:14:35 +01:00
charlesgauthereau c2726e6b18 chore(release): 1.2.5-rc.6 2026-02-01 20:22:38 +01:00
charlesgauthereau a746ce9169 chore(release): 1.2.5-rc.5 2026-02-01 19:59:23 +01:00
charlesgauthereau c00b967179 chore(release): 1.2.5-rc.4 2026-02-01 19:56:02 +01:00
charlesgauthereau f6e749ecf9 chore(release): 1.2.5-rc.3 2026-02-01 19:51:16 +01:00
charlesgauthereau 8f5e0686c8 chore(release): 1.2.5-rc.3 2026-02-01 19:16:42 +01:00
charlesgauthereau b9f0d9279b chore(release): 1.2.5-rc.2 2026-02-01 18:25:00 +01:00
charlesgauthereau cd69f0c82f chore(release): 1.2.5-rc.1 2026-02-01 18:24:53 +01:00
charlesgauthereau 884746f3a5 chore(release): 1.2.5-rc.2 2026-02-01 18:24:22 +01:00
charlesgauthereau 9c4d862297 fix: refactoring, adding express api. 2026-02-01 15:53:50 +01:00
charlesgauthereau cc87cf68b5 fix: refactoring, adding express api. 2026-01-31 22:15:01 +01:00
charlesgauthereau 7c5d57ddc1 Merge branch 'main' into feature
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-29 23:48:18 +01:00
charlesgauthereau 5653ff18c8 chore(release): 1.2.4 2026-01-29 23:47:56 +01:00
Charles GTEandGitHub 83ea444675 Merge pull request #52 from Portabase/fix/dialogs
fix/dialogs
2026-01-29 23:46:53 +01:00
charlesgauthereau 966edfa85a fix: some fix responsive 2026-01-29 23:46:04 +01:00
charlesgauthereau 30b9fc7765 fix: server/client error on some dialogs. 2026-01-29 23:11:33 +01:00
charlesgauthereau 95a60e9a96 fix: server/client error on some dialogs. 2026-01-29 19:10:59 +01:00
Killian LarcherandGitHub 9327331adc Merge pull request #51 from Portabase/feat/github-updater
fix: check if update is available based on auto-detected version channel.
2026-01-28 21:43:47 +01:00
killian-larcher e8baa441cd fix: check if update is available based on auto-detected version channel. 2026-01-28 21:40:55 +01:00
Killian LarcherandGitHub 61d142afbd Merge pull request #50 from Portabase/feat/github-updater
fix: check if update is available based on auto-detected version channel
2026-01-28 20:23:06 +01:00
killian-larcher 46022e8711 fix: check if update is available based on auto-detected version channel. 2026-01-28 20:22:14 +01:00
Killian LarcherandGitHub e59f3c46fd Merge pull request #49 from Portabase/feat/organization-dropdown
fix: adjust organisation dropdown size.
2026-01-28 19:49:23 +01:00
killian-larcher 7fcb3f2c31 fix: adjust organisation dropdown size. 2026-01-28 19:48:40 +01:00
Killian LarcherandGitHub a2243585a0 Merge pull request #48 from Portabase/feat/github-updater
Feat/GitHub updater
2026-01-28 19:18:22 +01:00
killian-larcher 6231002043 fix: error in package.json 2026-01-28 19:17:31 +01:00
killian-larcher 33eb476009 Merge branch 'main' into feat/github-updater
# Conflicts:
#	CITATION.cff
#	package.json
2026-01-28 19:14:33 +01:00
Killian LarcherandGitHub ebb0a4c7e5 Merge pull request #47 from Portabase/feat/modal-page
Feat/modal page
2026-01-28 19:07:55 +01:00
Théo LAGACHE d010ed6bc5 chore(release): 1.2.4-rc.8 2026-01-28 16:55:43 +01:00
Théo LAGACHE 1941e29175 chore(release): 1.2.4-rc.7 2026-01-28 16:39:53 +01:00
Théo LAGACHE 6e8932ff52 chore(release): 1.2.4-rc.6 2026-01-28 16:39:46 +01:00
Théo LAGACHE f9bfae9b9d chore(release): 1.2.4-rc.5 2026-01-28 15:56:24 +01:00
Théo LAGACHE 2fca928cf0 chore(release): 1.2.4-rc.4 2026-01-28 15:55:57 +01:00
Théo LAGACHE 8f3a362a3a chore(release): 1.2.4-rc.3 2026-01-28 15:55:28 +01:00
Théo LAGACHE 62a6189788 chore(release): 1.2.4-rc.3 2026-01-28 15:54:59 +01:00
Théo LAGACHE 2a6d631719 chore(release): 1.2.4-rc.1 2026-01-28 15:54:03 +01:00
Théo LAGACHEandGitHub 4942591d7f Merge pull request #45 from Portabase/feat/organization-dropdown
update: new org combobox
2026-01-28 13:57:08 +01:00
Théo LAGACHE 1760c1b852 update: new org combobox 2026-01-28 13:51:14 +01:00
charlesgauthereau 86d7ab9864 chore(release): 1.2.4-rc.2 2026-01-24 19:34:56 +01:00
charlesgauthereau f201e4593a Merge branch 'main' into feature 2026-01-24 19:34:49 +01:00
charlesgauthereau 71129e556e chore(release): 1.2.4-rc.1 2026-01-24 19:06:47 +01:00
Charles GTEandGitHub 0e7ebb79db Merge pull request #42 from Portabase/fix/ntfy
fix: added support for Basic Auth ntfy instance
2026-01-24 19:05:46 +01:00
charlesgauthereau 727d9eb57c fix: added support for Basic Auth ntfy instance 2026-01-24 19:04:49 +01:00
charlesgauthereau 228ff75468 chore(release): 1.2.3 2026-01-24 13:18:01 +01:00
Charles GTEandGitHub 80018cac08 Merge pull request #40 from Portabase/feat/google-drive-storage
feat/google-drive-storage
2026-01-24 13:17:06 +01:00
charlesgauthereau b2c19e7ed0 fix: refactoring of storage method 2026-01-24 13:14:38 +01:00
charlesgauthereau a64a80910d feat: Working version with google drive storage. 2026-01-24 12:13:55 +01:00
charlesgauthereau 4eeeef3427 feat: start working on google drive storage 2026-01-24 00:26:09 +01:00
charlesgauthereau 657586ddff feat: start working on google drive storage 2026-01-24 00:26:05 +01:00
Charles GTEandGitHub 797b287899 chore: README.md 2026-01-22 10:40:09 +01:00
Charles GTEandGitHub db2d95f2c5 Create FUNDING.yml
create and added buy me a coffee sponsor
2026-01-22 10:31:22 +01:00
charlesgauthereau 16a8f259ab chore(release): 1.2.2 2026-01-21 21:29:33 +01:00
Charles GTEandGitHub ee1cd8d08d Merge pull request #36 from Portabase/feat/arm-architecture
feat/arm-architecture
2026-01-21 21:22:45 +01:00
charlesgauthereau e53046324c fix: docker-compose.prod.yml 2026-01-21 21:21:51 +01:00
charlesgauthereau dd71977317 chore(release): 1.2.2-rc.2 2026-01-21 21:00:58 +01:00
charlesgauthereau bee0da1454 chore(release): 1.2.2-rc.2 2026-01-21 20:56:58 +01:00
charlesgauthereau b60feaa447 feat: added support for arm and amd docker images. 2026-01-21 20:56:23 +01:00
charlesgauthereau 743e54614a chore(release): 1.2.2-rc.1 2026-01-21 11:41:17 +01:00
charlesgauthereau bd96713af4 fix: some refactoring 2026-01-21 11:41:00 +01:00
Charles GTEandGitHub 8f7f9b457a Merge pull request #33 from Portabase/feat/avatar-upload
fix: Upload avatar image error for s3 storage
2026-01-21 11:38:47 +01:00
charlesgauthereau 4c0d68eab8 fix: Upload avatar image error for s3 storage 2026-01-21 11:36:50 +01:00
Charles GTEandGitHub 8211407cf2 chore: README.md 2026-01-20 22:59:47 +01:00
charlesgauthereau 5fb6c7d36a chore(release): 1.2.1 2026-01-20 22:53:08 +01:00
Charles GTEandGitHub 30b1cf4493 Merge pull request #31 from Portabase/feat/add-mongodb
feat/add-mongodb
2026-01-20 22:51:36 +01:00
charlesgauthereau f4789ac5fe fix: attribute default storage at startup in init. Adding support for mongodb. 2026-01-20 22:47:23 +01:00
charlesgauthereau 5fd826ae71 fix: attribute default storage at startup in init. 2026-01-20 20:31:42 +01:00
charlesgauthereau 22ce08f06c Merge branch 'feature' 2026-01-20 19:50:40 +01:00
charlesgauthereau 696c88f03f chore(release): 1.2.1-rc.3 2026-01-20 19:43:59 +01:00
charlesgauthereau 9d5d87e47b fix: smtp error for SMTP_SECURE false. Added in .env 2026-01-20 19:41:49 +01:00
charlesgauthereau e32539e02c chore(release): 1.2.1-rc.2 2026-01-19 13:58:35 +01:00
charlesgauthereau 46ba931a6c fix: Retention cron error. 2026-01-19 13:58:04 +01:00
charlesgauthereau 4bac5143d6 feat: working on mongodb 2026-01-18 21:19:19 +01:00
charlesgauthereau bcb1674491 feat: adding support for mongodb 2026-01-18 20:20:10 +01:00
Killian LarcherandGitHub 3cbd9677c2 Merge pull request #28 from Portabase/feat/coming-soon-storage-preview
feat(ui): add preview for upcoming storage provider
2026-01-18 16:08:41 +01:00
killianlarcher b216ee1a80 feat(ui): add preview for upcoming storage provider 2026-01-18 16:00:13 +01:00
charlesgauthereau 32ec482449 Merge branch 'feature' 2026-01-18 15:30:30 +01:00
charlesgauthereau 5074748055 chore(release): 1.2.1-rc.1 2026-01-18 15:30:14 +01:00
charlesgauthereau 6fa11f834b fix: retention delete backup cron action 2026-01-18 15:29:29 +01:00
charlesgauthereau 25c7702127 chore(release): 1.2.0 2026-01-18 15:21:37 +01:00
charlesgauthereau b546556d08 chore(release): 1.2.0 2026-01-18 15:04:11 +01:00
charlesgauthereau 379a85f6f6 chore: .env.example 2026-01-18 15:03:54 +01:00
Charles GTEandGitHub 92d2c14d6a Merge pull request #27 from Portabase/feat/storage
Feat/storage
2026-01-18 15:01:52 +01:00
charlesgauthereau a6c1f7d3f2 chore: Merging errors due to merge with main. 2026-01-18 15:00:53 +01:00
charlesgauthereau 6fa6187cc4 Merge remote-tracking branch 'origin/main' into feat/storage
# Conflicts:
#	src/components/wrappers/dashboard/admin/channels/channel/channel-form/channel-form.tsx
#	src/components/wrappers/dashboard/admin/notifications/helpers.tsx
#	src/db/migrations/meta/0020_snapshot.json
#	src/db/migrations/meta/_journal.json
2026-01-18 14:40:26 +01:00
charlesgauthereau 848e7a1609 chore: .gitignore 2026-01-18 14:35:39 +01:00
Charles GTEandGitHub 3e8c0a2896 Delete .vscode directory 2026-01-18 14:20:12 +01:00
Charles GTEandGitHub 11900b1500 Delete .idea directory 2026-01-18 14:19:16 +01:00
Killian LarcherandGitHub 5668a1411f Merge pull request #26 from Portabase/feat/coming-soon-preview
feat(ui): add preview for upcoming notification provider
2026-01-18 12:37:29 +01:00
killianlarcher 6db60f111d feat(ui): add preview for upcoming notification provider 2026-01-18 12:36:12 +01:00
Killian LarcherandGitHub 5c682addc9 Merge pull request #25 from Portabase/fix/profile
fix(ui) : improve profile modal
2026-01-18 10:29:38 +01:00
Killian LarcherandGitHub 24810cc818 Merge pull request #24 from Portabase/feat/last-connected
feat : add last login date
2026-01-18 10:29:19 +01:00
Killian LarcherandGitHub 8caeb11da5 Merge pull request #21 from Portabase/feat/connection-indicator
feat(ui): add pulse effect to connection indicator
2026-01-18 10:28:52 +01:00
Killian LarcherandGitHub 5e07b446fc Merge pull request #23 from Portabase/fix/development-conf
fix: update configuration for local development
2026-01-18 10:28:37 +01:00
killianlarcher 760388307c fix(ui): reduce opacity on avatar hover and add bottom padding to profile modal 2026-01-17 20:40:57 +01:00
killianlarcher eebd8500ee feat: add lastConnectedAt to user schema and display it in user settings 2026-01-17 20:27:06 +01:00
killianlarcher 12112afa37 feat(ui):resize connection indicator and add pulse effect for all use cases. 2026-01-17 20:06:01 +01:00
killianlarcher cddb0a7e05 feat(ui): add pulse effect to connection indicator 2026-01-17 19:36:28 +01:00
killianlarcher 21b317e4f1 fix: Update .env.example and docker-compose.yml for local development configuration 2026-01-17 17:44:31 +01:00
137 changed files with 13699 additions and 3235 deletions
+9 -15
View File
@@ -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="* * * * *"
+15
View File
@@ -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']
+8 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.idea
.vscode
# dependencies
/node_modules
/.pnp
-8
View File
@@ -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
-8
View File
@@ -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>
-12
View File
@@ -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
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
-3
View File
@@ -1,3 +0,0 @@
{
"WillLuke.nextjs.hasPrompted": true
}
+2 -2
View File
@@ -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"
+3
View File
@@ -14,10 +14,12 @@
[![License: Apache](https://img.shields.io/badge/License-apache-yellow.svg)](LICENSE)
[![Docker Pulls](https://img.shields.io/docker/pulls/solucetechnologies/portabase?color=brightgreen)](https://hub.docker.com/r/solucetechnologies/portabase)
[![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey)](https://github.com/Portabase/portabase)
[![Support Portabase](https://img.shields.io/badge/Support-Portabase-orange)](https://www.buymeacoffee.com/portabase)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-336791?logo=postgresql&logoColor=white)](https://www.postgresql.org/)
[![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white)](https://mariadb.org/)
[![MongoDB](https://img.shields.io/badge/-MongoDB-13aa52?logo=mongodb&logoColor=white)](https://www.mongodb.com/)
[![Self Hosted](https://img.shields.io/badge/self--hosted-yes-brightgreen)](https://github.com/Portabase/portabase)
[![Open Source](https://img.shields.io/badge/open%20source-❤️-red)](https://github.com/Portabase/portabase)
@@ -61,6 +63,7 @@ You have 4 ways to install Portabase:
[![Contributors](https://contrib.rocks/image?repo=Portabase/portabase)](https://github.com/Portabase/portabase/graphs/contributors)
[!["Support Portabase"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](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>
)
}
}
+4 -3
View File
@@ -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 -1
View File
@@ -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
+11 -28
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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) {
+23
View File
@@ -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",
},
}
);
}
+5 -5
View File
@@ -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:
+8 -2
View File
@@ -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
+2 -2
View File
@@ -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 "$@"
-1
View File
@@ -46,7 +46,6 @@ const nextConfig: NextConfig = {
bodySizeLimit: "10gb",
},
proxyClientMaxBodySize: '10gb',
},
async headers() {
return [
+13 -6
View File
@@ -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"
}
+1947 -2245
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -1,2 +1,13 @@
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- argon2
- bcrypt
- cpu-features
- esbuild
- prisma
- protobufjs
- sharp
- ssh2
- unrs-resolver
- zenstack
+3 -2
View File
@@ -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

+6
View File
@@ -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);
}
+52
View File
@@ -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();
+1 -1
View File
@@ -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();
@@ -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"}),
});
@@ -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>
);
};
};
@@ -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 {
@@ -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>
)}
/>
</>
)
}
@@ -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>
</>
);
};
@@ -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>
);
}
@@ -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;
@@ -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>
@@ -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>
</>
);
}
)
}
@@ -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>;
@@ -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>
@@ -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
+28
View File
@@ -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
}
]
}
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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(),
+4 -4
View File
@@ -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
View File
@@ -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,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,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>
);
};
@@ -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}`);
}
},
});
+6
View File
@@ -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();
}
+10
View File
@@ -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;
+133
View File
@@ -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