mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6548945b2f | ||
|
|
4b1d0aba74 | ||
|
|
c726346258 | ||
|
|
0677f14ac5 | ||
|
|
d346f5cdc4 | ||
|
|
1ce08448ec | ||
|
|
77fb1095f1 | ||
|
|
29dd597c7a | ||
|
|
7943f3fd4b | ||
|
|
a63c29b943 | ||
|
|
f4d72090a3 | ||
|
|
8b379067a1 | ||
|
|
896b65f2a5 | ||
|
|
79d725cd12 | ||
|
|
dfa5b42467 | ||
|
|
e6b63a4570 | ||
|
|
eb13fce8e9 | ||
|
|
f70d86f394 | ||
|
|
8f44071069 | ||
|
|
79832f0d0b | ||
|
|
f89b8d3ac3 | ||
|
|
40247efddd |
+2
-2
@@ -82,5 +82,5 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
|
||||
# Default to false
|
||||
#TUSD_BEHIND_PROXY=true
|
||||
|
||||
#
|
||||
#SKIP_ONBOARDING=false
|
||||
#Skip the onboarding on fresh instance
|
||||
#SKIP_ONBOARDING=true
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
tags: ${{ steps.prep.outputs.image }}
|
||||
target: ${{ inputs.target }}
|
||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=build-${{ matrix.platform }},ignore-error=true
|
||||
|
||||
- name: Save image name for manifest
|
||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
gcs-data/
|
||||
gcs-storage/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -28,6 +30,8 @@ yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
**/.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.20.2
|
||||
version: 1.21.0
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -111,3 +111,5 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflo
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {notFound} from "next/navigation";
|
||||
import {db} from "@/db";
|
||||
import {and, asc, eq, inArray} from "drizzle-orm";
|
||||
|
||||
import {and, asc, eq, inArray, not} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {listOrganizations} from "@/lib/auth/auth";
|
||||
import {Metadata} from "next";
|
||||
@@ -23,7 +24,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
|
||||
const organizationIds = organizations.map(project => project.id);
|
||||
|
||||
const agents = await db.query.agent.findMany({});
|
||||
const agents = await db.query.agent.findMany({
|
||||
where: not(eq(drizzleDb.schemas.agent.isArchived, true)),
|
||||
});
|
||||
|
||||
const projects = await db.query.project.findMany({
|
||||
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: docker/dockerfile/Dockerfile
|
||||
# target: prod
|
||||
image: portabase/portabase:1
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/dockerfile/Dockerfile
|
||||
target: prod
|
||||
# image: portabase/portabase:1
|
||||
ports:
|
||||
- "8887:80"
|
||||
environment:
|
||||
|
||||
@@ -55,9 +55,102 @@ services:
|
||||
|
||||
# DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;
|
||||
|
||||
gcs:
|
||||
image: fsouza/fake-gcs-server:latest
|
||||
container_name: fake-gcs
|
||||
ports:
|
||||
- "4443:4443"
|
||||
command:
|
||||
- "-scheme"
|
||||
- "http"
|
||||
- "-port"
|
||||
- "4443"
|
||||
- "-public-host"
|
||||
- "localhost:4443"
|
||||
- "-external-url"
|
||||
- "http://localhost:4443"
|
||||
- "-backend"
|
||||
- "filesystem"
|
||||
- "-filesystem-root"
|
||||
- "/storage"
|
||||
volumes:
|
||||
- gcs-data:/data
|
||||
- gcs-storage:/storage
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
gcs-init:
|
||||
image: curlimages/curl:latest
|
||||
depends_on:
|
||||
- gcs
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command: >
|
||||
'until curl -fsS http://gcs:4443/storage/v1/b >/dev/null; do sleep 1; done;
|
||||
curl -fsS http://gcs:4443/storage/v1/b/portabase >/dev/null
|
||||
|| curl -fsS -X POST "http://gcs:4443/storage/v1/b?project=local"
|
||||
-H "Content-Type: application/json"
|
||||
-d "{\"name\":\"portabase\"}"'
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
# curl http://localhost:4443/storage/v1/b
|
||||
# projectId: "test-project",
|
||||
# bucketName: "portabase",
|
||||
# clientEmail: "fake@test.iam.gserviceaccount.com",
|
||||
# privateKey: "unused-when-emulated",
|
||||
|
||||
rustfs:
|
||||
image: rustfs/rustfs:latest
|
||||
container_name: rustfs
|
||||
environment:
|
||||
- RUSTFS_ROOT_USER=admin
|
||||
- RUSTFS_ROOT_PASSWORD=root-password
|
||||
- RUSTFS_ACCESS_KEY=access-key
|
||||
- RUSTFS_SECRET_KEY=secret-key
|
||||
- RUSTFS_VOLUMES=/data
|
||||
- RUSTFS_ADDRESS=:9000
|
||||
- RUSTFS_CONSOLE_ENABLE=true
|
||||
- RUSTFS_CONSOLE_ADDRESS=:9001
|
||||
volumes:
|
||||
- rustfs-data:/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-sf", "http://localhost:9000/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "9001:9001"
|
||||
- "9000:9000"
|
||||
extra_hosts:
|
||||
- "localhost:host-gateway"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
rustfs-init:
|
||||
image: rustfs/rc:latest
|
||||
container_name: rustfs-init
|
||||
depends_on:
|
||||
rustfs:
|
||||
condition: service_healthy
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
until (/usr/bin/rc alias set rustfs http://rustfs:9000 access-key secret-key) do echo '...waiting...' && sleep 1; done;
|
||||
/usr/bin/rc mb rustfs/portabase;
|
||||
echo 'RustFS initialization complete';
|
||||
tail -f /dev/null
|
||||
"
|
||||
networks:
|
||||
- portabase
|
||||
|
||||
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
azurite-data:
|
||||
gcs-data:
|
||||
gcs-storage:
|
||||
rustfs-data:
|
||||
|
||||
networks:
|
||||
portabase:
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS base
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS runtime-base
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
bash \
|
||||
tzdata \
|
||||
libc6 \
|
||||
nginx \
|
||||
gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bookworm-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-18 \
|
||||
postgresql-client-18 \
|
||||
postgresql-contrib-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||
|
||||
|
||||
FROM runtime-base AS build-base
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
g++ \
|
||||
make \
|
||||
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
|
||||
automake \
|
||||
libtool \
|
||||
git \
|
||||
nginx \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
RUN mkdir -p /etc/apt/keyrings \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \
|
||||
&& echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
postgresql-18 \
|
||||
postgresql-client-18 \
|
||||
postgresql-contrib-18 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="/usr/lib/postgresql/18/bin:${PATH}"
|
||||
|
||||
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||
|
||||
ARG TUSD_VERSION=2.8.0
|
||||
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
|
||||
COPY --from=tusd-base /tusddist/tusd /tusd
|
||||
|
||||
|
||||
FROM base AS build-env
|
||||
FROM build-base AS build-env
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
@@ -83,7 +89,7 @@ COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm run build
|
||||
|
||||
FROM base AS prod
|
||||
FROM runtime-base AS prod
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
@@ -105,7 +111,6 @@ COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||
COPY --chown=1001:1001 src/db ./src/db
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||
RUN chmod +x /usr/local/bin/tusd
|
||||
|
||||
+6
-38
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.20.2",
|
||||
"version": "1.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
@@ -21,57 +21,44 @@
|
||||
"@better-auth/core": "1.6.11",
|
||||
"@better-auth/passkey": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@onboardjs/react": "1.0.0-rc.5",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-email/components": "^0.0.41",
|
||||
"@t3-oss/env-nextjs": "^0.13.11",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@types/nodemailer": "^6.4.23",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@zenstackhq/runtime": "2.14.2",
|
||||
"argon2": "^0.43.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.11",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"dockerode": "^4.0.12",
|
||||
"dotenv": "^16.6.1",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.8.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"googleapis": "^170.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.553.0",
|
||||
@@ -81,33 +68,22 @@
|
||||
"next-safe-action": "^7.10.8",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.4.0",
|
||||
"nodemailer": "8.0.5",
|
||||
"npm-check-updates": "^18.3.1",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prettier": "^3.8.3",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "9.7.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-email": "^4.3.2",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-qr-code": "^2.0.21",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-twc": "^1.5.1",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"recharts": "^2.15.4",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"swiper": "^12.1.4",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"uuid": "^11.1.1",
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -118,29 +94,21 @@
|
||||
"@release-it/conventional-changelog": "^10.0.6",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
||||
"@types/node": "^22.19.19",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@zenstackhq/openapi": "^2.22.1",
|
||||
"@zenstackhq/tanstack-query": "^2.22.2",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"esbuild": "^0.27.7",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.6",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"framer-motion": "^12.40.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "8.5.10",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
"release-it": "^19.2.4",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tsx": "^4.22.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"zenstack": "2.14.2"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
||||
}
|
||||
|
||||
Generated
+1421
-3598
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'postgresql-cluster' BEFORE 'mysql';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_storage_kind" ADD VALUE 'google-cloud-storage' BEFORE 'blob';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."dbms_status" ADD VALUE 'docker-volume';
|
||||
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
@@ -470,6 +470,27 @@
|
||||
"when": 1782474105293,
|
||||
"tag": "0066_milky_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1782573099089,
|
||||
"tag": "0067_clammy_stephen_strange",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1782575192480,
|
||||
"tag": "0068_busy_zeigeist",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1782937885439,
|
||||
"tag": "0069_worried_alex_wilder",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {relations} from "drizzle-orm";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive', 'blob']);
|
||||
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive', 'google-cloud-storage', 'blob']);
|
||||
|
||||
export const storageChannel = pgTable('storage_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
|
||||
import {createSelectSchema} from "drizzle-zod";
|
||||
import {z} from "zod";
|
||||
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql"]);
|
||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "postgresql-cluster", "mysql", "mariadb", "mongodb", "sqlite", "redis", "valkey", "firebird", "mssql", "docker-volume"]);
|
||||
export const statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||
|
||||
|
||||
@@ -1,297 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm,
|
||||
} from "@/components/ui/form";
|
||||
import { ButtonWithLoading } from "@/components/common/button-with-loading";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {ButtonWithLoading} from "@/components/common/button-with-loading";
|
||||
import {Input} from "@/components/ui/input";
|
||||
|
||||
import {
|
||||
addStorageChannelAction,
|
||||
updateStorageChannelAction,
|
||||
addStorageChannelAction, updateStorageChannelAction
|
||||
} from "@/features/channel/components/storages/channel.action";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {useEffect} from "react";
|
||||
import {cn} from "@/lib/utils";
|
||||
import {Card} from "@/components/ui/card";
|
||||
import {ArrowLeft} from "lucide-react";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {
|
||||
NotificationChannelFormSchema,
|
||||
NotificationChannelFormType,
|
||||
StorageChannelFormSchema,
|
||||
StorageChannelFormType,
|
||||
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
|
||||
} from "@/features/channel/schemas/channel-form.schema";
|
||||
import {
|
||||
ChannelKind,
|
||||
renderChannelForm,
|
||||
ChannelKind,
|
||||
renderChannelForm
|
||||
} from "@/features/channel/components/channels-helpers";
|
||||
import { storageProviders } from "@/features/channel/components/channels-storage-helper";
|
||||
import { notificationProviders } from "@/features/channel/components/channels-notification-helper";
|
||||
import { ChannelTestButton } from "@/features/channel/components/channel-test-button";
|
||||
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
|
||||
import {notificationProviders} from "@/features/channel/components/channels-notification-helper";
|
||||
import {
|
||||
addNotificationChannelAction,
|
||||
updateNotificationChannelAction,
|
||||
ChannelTestButton
|
||||
} from "@/features/channel/components/channel-test-button";
|
||||
import {
|
||||
addNotificationChannelAction, updateNotificationChannelAction
|
||||
} from "@/features/channel/components/notifications/channel.action";
|
||||
|
||||
|
||||
type NotifierFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
defaultValues?: NotificationChannelWith | StorageChannelWith;
|
||||
adminView?: boolean;
|
||||
kind: ChannelKind;
|
||||
onSuccessAction?: () => void;
|
||||
organization?: OrganizationWithMembers;
|
||||
defaultValues?: NotificationChannelWith | StorageChannelWith
|
||||
adminView?: boolean
|
||||
kind: ChannelKind
|
||||
};
|
||||
|
||||
export const ChannelForm = ({
|
||||
onSuccessAction,
|
||||
organization,
|
||||
defaultValues,
|
||||
kind,
|
||||
}: NotifierFormProps) => {
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(defaultValues);
|
||||
export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
|
||||
|
||||
const form = useZodForm({
|
||||
schema:
|
||||
kind == "notification"
|
||||
? NotificationChannelFormSchema
|
||||
: StorageChannelFormSchema,
|
||||
// @ts-expect-error — defaultValues type is a union of notification/storage channel types
|
||||
defaultValues: { ...defaultValues },
|
||||
});
|
||||
const router = useRouter();
|
||||
const isCreate = !Boolean(defaultValues);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues ? { ...defaultValues } : { enabled: true });
|
||||
}, [defaultValues]);
|
||||
const form = useZodForm({
|
||||
schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
|
||||
// @ts-expect-error — defaultValues type is a union of notification/storage channel types
|
||||
defaultValues: {...defaultValues},
|
||||
});
|
||||
|
||||
const mutationAddNotificationChannel = useMutation({
|
||||
mutationFn: async (
|
||||
values: NotificationChannelFormType | StorageChannelFormType,
|
||||
) => {
|
||||
const payload = {
|
||||
data: values,
|
||||
...(organization && { organizationId: organization.id }),
|
||||
...(defaultValues && { id: defaultValues.id }),
|
||||
};
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
|
||||
}, [defaultValues]);
|
||||
|
||||
let result: any;
|
||||
const mutationAddNotificationChannel = useMutation({
|
||||
mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
|
||||
|
||||
if (kind === "notification") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = isCreate
|
||||
? await addNotificationChannelAction(payload)
|
||||
: await updateNotificationChannelAction(payload);
|
||||
} else if (kind === "storage") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = isCreate
|
||||
? await addStorageChannelAction(payload)
|
||||
: await updateStorageChannelAction(payload);
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
data: values,
|
||||
...(organization && {organizationId: organization.id}),
|
||||
...((defaultValues && {id: defaultValues.id}))
|
||||
};
|
||||
|
||||
const inner = result?.data;
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
router.refresh();
|
||||
return true;
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
let result: any;
|
||||
|
||||
const saveBeforeConnect = async (): Promise<boolean> => {
|
||||
const valid = await form.trigger();
|
||||
if (!valid) return false;
|
||||
return Boolean(
|
||||
await mutationAddNotificationChannel.mutateAsync(form.getValues()),
|
||||
);
|
||||
};
|
||||
if (kind === "notification") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
|
||||
} else if (kind === "storage") {
|
||||
// @ts-expect-error — payload type varies between notification and storage
|
||||
result = isCreate ? await addStorageChannelAction(payload) : await updateStorageChannelAction(payload);
|
||||
} else {
|
||||
toast.error("An error occurred");
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = form.watch("provider");
|
||||
const channelTypes =
|
||||
kind == "notification" ? notificationProviders : storageProviders;
|
||||
const selectedProviderDetails = channelTypes.find(
|
||||
(t) => t.value === provider,
|
||||
);
|
||||
const inner = result?.data;
|
||||
|
||||
if (isCreate && !provider) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
||||
{channelTypes
|
||||
.filter((p) => p.value != "local")
|
||||
.map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className={cn(
|
||||
"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", {});
|
||||
}}
|
||||
>
|
||||
{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
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
isCreate && onSuccessAction?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const provider = form.watch("provider");
|
||||
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
|
||||
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
|
||||
|
||||
if (isCreate && !provider) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
||||
{channelTypes.filter(p => p.value != "local").map((type) => {
|
||||
const Icon = type.icon;
|
||||
return (
|
||||
<Card
|
||||
key={type.value}
|
||||
className={cn(
|
||||
"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", {});
|
||||
}}
|
||||
>
|
||||
|
||||
{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>
|
||||
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={cn(
|
||||
"font-medium text-sm text-center leading-tight",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground"
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</span>
|
||||
</Card>
|
||||
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationAddNotificationChannel.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{selectedProviderDetails && (
|
||||
<div
|
||||
className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
|
||||
<selectedProviderDetails.icon className="h-5 w-5"/>
|
||||
</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 className="flex-1">
|
||||
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm text-center leading-tight",
|
||||
type.preview ? "text-muted-foreground" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{type.label}
|
||||
</span>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("provider", undefined as any);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationAddNotificationChannel.mutateAsync(values);
|
||||
isCreate && onSuccessAction?.();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
|
||||
{selectedProviderDetails && (
|
||||
<div className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
|
||||
<selectedProviderDetails.icon className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
Configuring {selectedProviderDetails?.label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isCreate ? "New Channel" : "Edit Channel"}
|
||||
</p>
|
||||
</div>
|
||||
{isCreate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("provider", undefined as any);
|
||||
form.setValue("config", undefined);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Change
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={`e.g. ${selectedProviderDetails?.label} Channel`}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<input type="hidden" {...field} value={field.value || ""} />
|
||||
)}
|
||||
/>
|
||||
|
||||
{renderChannelForm(provider, form, saveBeforeConnect)}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<div>
|
||||
{(!isCreate || kind == "storage") && (
|
||||
<ChannelTestButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channel={form.getValues()}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Channel Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder={`e.g. ${selectedProviderDetails?.label} Channel`}
|
||||
value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSuccessAction?.();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading
|
||||
isPending={mutationAddNotificationChannel.isPending}
|
||||
>
|
||||
{isCreate ? "Add" : "Save"} Channel
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({field}) => (
|
||||
<input type="hidden" {...field} value={field.value || ""}/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{renderChannelForm(provider, form)}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<div>
|
||||
{(!isCreate || kind == "storage") && (
|
||||
<ChannelTestButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
channel={form.getValues()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onSuccessAction?.();
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<ButtonWithLoading isPending={mutationAddNotificationChannel.isPending}>
|
||||
{isCreate ? "Add" : "Save"} Channel
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
import {
|
||||
StorageBlobForm
|
||||
} from "@/features/channel/components/storages/az-blob.form";
|
||||
import {
|
||||
StorageGoogleCloudStorageForm
|
||||
} from "@/features/channel/components/storages/google-cloud-storage/google-cloud-storage.form";
|
||||
|
||||
|
||||
export type ChannelKind = "notification" | "storage";
|
||||
|
||||
@@ -83,11 +87,7 @@ export const getChannelIcon = (type: string) => {
|
||||
}
|
||||
|
||||
|
||||
export const renderChannelForm = (
|
||||
provider: string | undefined,
|
||||
form: UseFormReturn<any>,
|
||||
onSaveBeforeConnect?: () => Promise<boolean>,
|
||||
) => {
|
||||
export const renderChannelForm = (provider: string | undefined, form: UseFormReturn<any>) => {
|
||||
switch (provider) {
|
||||
case "smtp":
|
||||
return <NotifierSmtpForm form={form}/>;
|
||||
@@ -112,7 +112,9 @@ export const renderChannelForm = (
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
return <StorageGoogleDriveForm form={form} onSaveBeforeConnect={onSaveBeforeConnect}/>
|
||||
return <StorageGoogleDriveForm form={form}/>
|
||||
case "google-cloud-storage":
|
||||
return <StorageGoogleCloudStorageForm form={form}/>
|
||||
case "blob":
|
||||
return <StorageBlobForm form={form}/>
|
||||
case "local":
|
||||
|
||||
@@ -7,7 +7,7 @@ export const storageProviders: ProviderIconTypes[] = [
|
||||
{value: "s3", label: "S3", icon: S3Icon},
|
||||
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
||||
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon},
|
||||
{value: "gcs", label: "Google Cloud Storage", icon: GCSIcon, preview: true},
|
||||
{value: "google-cloud-storage", label: "Google Cloud Storage", icon: GCSIcon},
|
||||
]
|
||||
|
||||
export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
||||
|
||||
@@ -9,53 +9,117 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
type StorageBlobFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageBlobForm = ({ form }: StorageBlobFormProps) => {
|
||||
const storedMode = form.watch("config.authMode");
|
||||
// Derive mode for legacy records saved before authMode existed: if no
|
||||
// connection string but account credentials are present, open Account Details.
|
||||
const derivedMode: "connectionString" | "accountKey" =
|
||||
!form.watch("config.connectionString") &&
|
||||
(form.watch("config.accountName") || form.watch("config.accountKey"))
|
||||
? "accountKey"
|
||||
: "connectionString";
|
||||
const authMode: "connectionString" | "accountKey" = storedMode ?? derivedMode;
|
||||
|
||||
const handleModeChange = (value: string) => {
|
||||
form.setValue("config.authMode", value, { shouldValidate: false });
|
||||
// Clear errors of the now-inactive mode so stale messages don't linger.
|
||||
if (value === "connectionString") {
|
||||
form.clearErrors(["config.accountName", "config.accountKey"]);
|
||||
} else {
|
||||
form.clearErrors(["config.connectionString"]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. mystorageaccount" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Key</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. base64-encoded-key" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.connectionString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection String</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. DefaultEndpointsProtocol=https;..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Tabs value={authMode} onValueChange={handleModeChange} className="w-full">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="connectionString">Connection String</TabsTrigger>
|
||||
<TabsTrigger value="accountKey">Account Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="connectionString" className="flex flex-col gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.connectionString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connection String *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. DefaultEndpointsProtocol=https;..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="accountKey" className="flex flex-col gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. mystorageaccount"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.accountKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Account Key *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. base64-encoded-key"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endpointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. https://myaccount.blob.core.windows.net"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.containerName"
|
||||
@@ -63,20 +127,11 @@ export const StorageBlobForm = ({ form }: StorageBlobFormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Container Name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. backups-prod" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.endpointUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://myaccount.blob.core.windows.net" />
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. backups-prod"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const BlobChannelConfigSchema = z.object({
|
||||
accountName: z.string().min(1, "Account name is required"),
|
||||
authMode: z.enum(["connectionString", "accountKey"]).default("connectionString"),
|
||||
accountName: z.string().optional(),
|
||||
accountKey: z.string().optional(),
|
||||
connectionString: z.string().optional(),
|
||||
containerName: z.string().min(1, "Container name is required"),
|
||||
@@ -9,7 +10,29 @@ export const BlobChannelConfigSchema = z.object({
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.string().url("Endpoint URL must be a valid URL").optional(),
|
||||
),
|
||||
}).refine(
|
||||
(data) => data.accountKey || data.connectionString,
|
||||
{message: "Either account key or connection string is required"}
|
||||
);
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.authMode === "connectionString") {
|
||||
if (!data.connectionString) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["connectionString"],
|
||||
message: "Connection string is required",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!data.accountName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["accountName"],
|
||||
message: "Account name is required",
|
||||
});
|
||||
}
|
||||
if (!data.accountKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["accountKey"],
|
||||
message: "Account key is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type StorageGoogleCloudStorageFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const StorageGoogleCloudStorageForm = ({
|
||||
form,
|
||||
}: StorageGoogleCloudStorageFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} placeholder="e.g. my-gcp-project" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.bucketName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bucket name *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ""} placeholder="e.g. backups-prod" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. service-account@my-gcp-project.iam.gserviceaccount.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.apiEndpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Endpoint URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
placeholder="e.g. http://localhost:4443"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.privateKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Private key *</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
rows={5}
|
||||
placeholder="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const GoogleCloudStorageChannelConfigSchema = z.object({
|
||||
projectId: z.string().trim().min(1, "Project ID is required"),
|
||||
bucketName: z.string().trim().min(1, "Bucket name is required"),
|
||||
clientEmail: z.email("Client email must be a valid email").trim(),
|
||||
privateKey: z.string().trim().min(1, "Private key is required"),
|
||||
apiEndpoint: z.preprocess(
|
||||
(v) => (v === "" ? undefined : v),
|
||||
z.string().url("Endpoint URL must be a valid URL").optional(),
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import {Storage} from "@google-cloud/storage";
|
||||
|
||||
import {Readable} from "node:stream";
|
||||
import {GoogleCloudStorageConfig} from "@/features/channel/components/storages/google-cloud-storage/types";
|
||||
import {
|
||||
StorageCopyInput,
|
||||
StorageDeleteInput,
|
||||
StorageGetInput,
|
||||
StorageMetaData,
|
||||
StorageResult,
|
||||
StorageUploadInput
|
||||
} from "@/features/storages/types";
|
||||
|
||||
async function getGoogleCloudStorageClient(config: GoogleCloudStorageConfig) {
|
||||
return new Storage({
|
||||
projectId: config.projectId,
|
||||
...(config.apiEndpoint ? {apiEndpoint: config.apiEndpoint} : {}),
|
||||
credentials: {
|
||||
client_email: config.clientEmail,
|
||||
private_key: config.privateKey.replace(/\\n/g, "\n"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const BASE_DIR = "";
|
||||
|
||||
async function ensureBucket(config: GoogleCloudStorageConfig) {
|
||||
const client = await getGoogleCloudStorageClient(config);
|
||||
const [exists] = await client.bucket(config.bucketName).exists();
|
||||
if (!exists) throw new Error(`Bucket "${config.bucketName}" does not exist`);
|
||||
}
|
||||
|
||||
export async function uploadGoogleCloudStorage(
|
||||
config: GoogleCloudStorageConfig,
|
||||
input: { data: StorageUploadInput, metadata?: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleCloudStorageClient(config);
|
||||
await ensureBucket(config);
|
||||
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
const file = input.data.file;
|
||||
|
||||
let uploadStream: Readable;
|
||||
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
|
||||
uploadStream = Readable.from(file);
|
||||
} else if ((file as any).pipe) {
|
||||
uploadStream = file as Readable;
|
||||
} else {
|
||||
return {success: false, provider: "google-cloud-storage", error: "Unsupported file type for streaming upload"};
|
||||
}
|
||||
|
||||
try {
|
||||
const writeStream = client
|
||||
.bucket(config.bucketName)
|
||||
.file(key)
|
||||
.createWriteStream({contentType: input.data.contentType});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
uploadStream
|
||||
.pipe(writeStream)
|
||||
.on("finish", resolve)
|
||||
.on("error", reject);
|
||||
});
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "google-cloud-storage", error: err.message};
|
||||
}
|
||||
|
||||
return {success: true, provider: "google-cloud-storage"};
|
||||
}
|
||||
|
||||
export async function getGoogleCloudStorage(
|
||||
config: GoogleCloudStorageConfig,
|
||||
input: { data: StorageGetInput, metadata: StorageMetaData }
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleCloudStorageClient(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
const file = client.bucket(config.bucketName).file(key);
|
||||
|
||||
const [exists] = await file.exists();
|
||||
if (!exists) return {success: false, provider: "google-cloud-storage", error: "File not found"};
|
||||
|
||||
const fileStream = file.createReadStream();
|
||||
|
||||
let signedUrl: string | undefined;
|
||||
if (input.data.signedUrl) {
|
||||
if (config.apiEndpoint) {
|
||||
signedUrl = `${config.apiEndpoint.replace(/\/$/, "")}/${config.bucketName}/${encodeURI(key)}`;
|
||||
} else {
|
||||
const [url] = await file.getSignedUrl({
|
||||
action: "read",
|
||||
expires: Date.now() + (input.data.expiresInSeconds ?? 60) * 1000,
|
||||
});
|
||||
signedUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-cloud-storage",
|
||||
file: fileStream as unknown as Buffer | Readable,
|
||||
url: signedUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteGoogleCloudStorage(config: GoogleCloudStorageConfig, input: {
|
||||
data: StorageDeleteInput,
|
||||
metadata?: StorageMetaData
|
||||
}): Promise<StorageResult> {
|
||||
const client = await getGoogleCloudStorageClient(config);
|
||||
const key = `${BASE_DIR}${input.data.path}`;
|
||||
|
||||
try {
|
||||
await client.bucket(config.bucketName).file(key).delete();
|
||||
return {success: true, provider: "google-cloud-storage"};
|
||||
} catch (err: any) {
|
||||
return {success: false, provider: "google-cloud-storage", error: err.message};
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingGoogleCloudStorage(config: GoogleCloudStorageConfig): Promise<StorageResult> {
|
||||
try {
|
||||
const client = await getGoogleCloudStorageClient(config);
|
||||
const bucket = client.bucket(config.bucketName);
|
||||
|
||||
const [exists] = await bucket.exists();
|
||||
if (!exists) return {
|
||||
success: false,
|
||||
provider: "google-cloud-storage",
|
||||
response: "Bucket does not exist"
|
||||
};
|
||||
|
||||
const key = `${BASE_DIR}ping.txt`;
|
||||
const file = bucket.file(key);
|
||||
await file.save(Buffer.from("ping"));
|
||||
await file.download();
|
||||
await file.delete();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-cloud-storage",
|
||||
response: "Google Cloud Storage OK"
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-cloud-storage",
|
||||
response: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyGoogleCloudStorage(
|
||||
config: GoogleCloudStorageConfig,
|
||||
input: {
|
||||
data: StorageCopyInput,
|
||||
},
|
||||
): Promise<StorageResult> {
|
||||
const client = await getGoogleCloudStorageClient(config);
|
||||
await ensureBucket(config);
|
||||
|
||||
const sourceKey = `${BASE_DIR}${input.data.from}`;
|
||||
const destinationKey = `${BASE_DIR}${input.data.to}`;
|
||||
|
||||
try {
|
||||
const bucket = client.bucket(config.bucketName);
|
||||
await bucket.file(sourceKey).copy(bucket.file(destinationKey));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "google-cloud-storage",
|
||||
};
|
||||
} catch (err: any) {
|
||||
return {
|
||||
success: false,
|
||||
provider: "google-cloud-storage",
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type GoogleCloudStorageConfig = {
|
||||
projectId: string;
|
||||
bucketName: string;
|
||||
clientEmail: string;
|
||||
privateKey: string;
|
||||
apiEndpoint: string;
|
||||
};
|
||||
@@ -1,179 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
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 {
|
||||
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 "@/features/channel/components/storages/google-drive/google-drive-refresh.action";
|
||||
import { toast } from "sonner";
|
||||
googleDriveRefreshTokenAction
|
||||
} from "@/features/channel/components/storages/google-drive/google-drive-refresh.action";
|
||||
import {toast} from "sonner";
|
||||
|
||||
type StorageGoogleDriveFormProps = {
|
||||
form: UseFormReturn<any>;
|
||||
onSaveBeforeConnect?: () => Promise<boolean>;
|
||||
form: UseFormReturn<any>;
|
||||
};
|
||||
|
||||
export const StorageGoogleDriveForm = ({
|
||||
form,
|
||||
onSaveBeforeConnect,
|
||||
}: StorageGoogleDriveFormProps) => {
|
||||
const refreshToken = form.watch("config.refreshToken");
|
||||
const isConnected = Boolean(refreshToken);
|
||||
export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||
const refreshToken = form.watch("config.refreshToken");
|
||||
const isConnected = Boolean(refreshToken);
|
||||
|
||||
const handleConnect = async () => {
|
||||
const clientId = form.getValues("config.clientId");
|
||||
const clientSecret = form.getValues("config.clientSecret");
|
||||
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
|
||||
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;
|
||||
}
|
||||
|
||||
if (onSaveBeforeConnect) {
|
||||
const saved = await onSaveBeforeConnect();
|
||||
if (!saved) 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 (!clientId || !clientSecret) {
|
||||
form.setError("config.clientId", {message: "Client ID and Secret are required"});
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthWindow.location.href.startsWith(redirectUri)) {
|
||||
const code = new URL(oauthWindow.location.href).searchParams.get(
|
||||
"code",
|
||||
);
|
||||
if (!code) 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 result = await googleDriveRefreshTokenAction({
|
||||
code,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
});
|
||||
const inner = result?.data;
|
||||
const oauthWindow = window.open(oauthUrl, "_blank", "width=500,height=600");
|
||||
|
||||
if (inner?.success) {
|
||||
toast.success(inner.actionSuccess?.message);
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
if (!oauthWindow || oauthWindow.closed) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inner.value) {
|
||||
form.setError("config", {
|
||||
message: "OAuth succeeded but no refresh token returned",
|
||||
});
|
||||
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);
|
||||
};
|
||||
|
||||
form.setValue("config.refreshToken", inner.value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
} else {
|
||||
toast.error(inner?.actionError?.message);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1"/>
|
||||
|
||||
oauthWindow.close();
|
||||
clearInterval(interval);
|
||||
}
|
||||
} catch {}
|
||||
}, 500);
|
||||
};
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. xxxx.apps.googleusercontent.com"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<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.clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. xxxx.apps.googleusercontent.com"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.folderId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz"/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.folderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Folder ID *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
|
||||
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||
</Button>
|
||||
|
||||
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
className="hover:cursor-pointer"
|
||||
onClick={handleConnect}
|
||||
>
|
||||
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||
</Button>
|
||||
|
||||
{isConnected && (
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
{isConnected && (
|
||||
<span className="text-sm text-green-600 font-medium">
|
||||
Google Drive connected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
pingGoogleDrive,
|
||||
uploadGoogleDrive
|
||||
} from "@/features/channel/components/storages/google-drive";
|
||||
import {
|
||||
copyGoogleCloudStorage,
|
||||
deleteGoogleCloudStorage,
|
||||
getGoogleCloudStorage,
|
||||
pingGoogleCloudStorage,
|
||||
uploadGoogleCloudStorage
|
||||
} from "@/features/channel/components/storages/google-cloud-storage";
|
||||
|
||||
type ProviderHandler = {
|
||||
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
|
||||
@@ -51,6 +58,13 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
||||
delete: deleteBlob,
|
||||
ping: pingBlob,
|
||||
copy: copyBlob,
|
||||
},
|
||||
"google-cloud-storage": {
|
||||
upload: uploadGoogleCloudStorage,
|
||||
get: getGoogleCloudStorage,
|
||||
delete: deleteGoogleCloudStorage,
|
||||
ping: pingGoogleCloudStorage,
|
||||
copy: copyGoogleCloudStorage,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ export const StorageS3Form = ({ form }: StorageS3FormProps) => {
|
||||
<FormItem>
|
||||
<FormLabel>Use SSL</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
<Switch checked={field.value ?? true} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -13,6 +13,10 @@ import {GoogleDriveChannelConfigSchema} from "@/features/channel/components/stor
|
||||
import {LocalChannelConfigSchema} from "@/features/channel/components/storages/local.schema";
|
||||
import {TeamsChannelConfigSchema} from "@/features/channel/components/notifications/teams.schema";
|
||||
import {BlobChannelConfigSchema} from "@/features/channel/components/storages/az-blob.schema";
|
||||
import {
|
||||
GoogleCloudStorageChannelConfigSchema
|
||||
} from "@/features/channel/components/storages/google-cloud-storage/google-cloud-storage.schema";
|
||||
|
||||
|
||||
|
||||
const BaseChannelFormSchema = z.object({
|
||||
@@ -79,6 +83,10 @@ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
provider: z.literal("blob"),
|
||||
config: BlobChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("google-cloud-storage"),
|
||||
config: GoogleCloudStorageChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("local"),
|
||||
config: LocalChannelConfigSchema
|
||||
|
||||
@@ -4,6 +4,7 @@ export type StorageProviderKind =
|
||||
| 'local'
|
||||
| 's3'
|
||||
| 'google-drive'
|
||||
| 'google-cloud-storage'
|
||||
| 'blob'
|
||||
;
|
||||
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ export function capitalizeFirstLetter(text: string): string {
|
||||
}
|
||||
|
||||
export function isUUID(str: string) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
str,
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
str,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user