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
|
# Default to false
|
||||||
#TUSD_BEHIND_PROXY=true
|
#TUSD_BEHIND_PROXY=true
|
||||||
|
|
||||||
#
|
#Skip the onboarding on fresh instance
|
||||||
#SKIP_ONBOARDING=false
|
#SKIP_ONBOARDING=true
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
tags: ${{ steps.prep.outputs.image }}
|
tags: ${{ steps.prep.outputs.image }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
cache-from: type=gha,scope=build-${{ matrix.platform }}
|
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
|
- name: Save image name for manifest
|
||||||
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
run: echo "${{ steps.prep.outputs.image }}" > image.txt
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
gcs-data/
|
||||||
|
gcs-storage/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -28,6 +30,8 @@ yarn-error.log*
|
|||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
|||||||
- web-ui
|
- web-ui
|
||||||
- agent
|
- agent
|
||||||
license: Apache-2.0
|
license: Apache-2.0
|
||||||
version: 1.20.2
|
version: 1.21.0
|
||||||
date-released: '2026-03-02'
|
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/
|
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||||
|
|
||||||
[Docker-url]: https://www.docker.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 {currentUser} from "@/lib/auth/current-user";
|
||||||
import {notFound} from "next/navigation";
|
import {notFound} from "next/navigation";
|
||||||
import {db} from "@/db";
|
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 * as drizzleDb from "@/db";
|
||||||
import {listOrganizations} from "@/lib/auth/auth";
|
import {listOrganizations} from "@/lib/auth/auth";
|
||||||
import {Metadata} from "next";
|
import {Metadata} from "next";
|
||||||
@@ -23,7 +24,9 @@ export default async function RoutePage(props: PageParams<{}>) {
|
|||||||
|
|
||||||
const organizationIds = organizations.map(project => project.id);
|
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({
|
const projects = await db.query.project.findMany({
|
||||||
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
|
where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
# build:
|
build:
|
||||||
# context: .
|
context: .
|
||||||
# dockerfile: docker/dockerfile/Dockerfile
|
dockerfile: docker/dockerfile/Dockerfile
|
||||||
# target: prod
|
target: prod
|
||||||
image: portabase/portabase:1
|
# image: portabase/portabase:1
|
||||||
ports:
|
ports:
|
||||||
- "8887:80"
|
- "8887:80"
|
||||||
environment:
|
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;
|
# 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:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
azurite-data:
|
azurite-data:
|
||||||
|
gcs-data:
|
||||||
|
gcs-storage:
|
||||||
|
rustfs-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
portabase:
|
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 \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
bash \
|
bash \
|
||||||
tzdata \
|
tzdata \
|
||||||
libc6 \
|
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 \
|
build-essential \
|
||||||
g++ \
|
g++ \
|
||||||
make \
|
make \
|
||||||
@@ -13,25 +35,9 @@ RUN apt-get update && apt-get install -y \
|
|||||||
automake \
|
automake \
|
||||||
libtool \
|
libtool \
|
||||||
git \
|
git \
|
||||||
nginx \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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
|
FROM --platform=$BUILDPLATFORM golang:1.23-bookworm AS tusd-base
|
||||||
|
|
||||||
ARG TUSD_VERSION=2.8.0
|
ARG TUSD_VERSION=2.8.0
|
||||||
@@ -51,7 +57,7 @@ FROM scratch AS tusd-dist
|
|||||||
COPY --from=tusd-base /tusddist/tusd /tusd
|
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
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ COPY . .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM base AS prod
|
FROM runtime-base AS prod
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
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/standalone ./
|
||||||
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
COPY --from=builder --chown=1001:1001 /app/.next/static ./.next/static
|
||||||
COPY --chown=1001:1001 src/db ./src/db
|
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
|
COPY --from=tusd-dist /tusd /usr/local/bin/tusd
|
||||||
RUN chmod +x /usr/local/bin/tusd
|
RUN chmod +x /usr/local/bin/tusd
|
||||||
|
|||||||
+6
-38
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "portabase",
|
"name": "portabase",
|
||||||
"version": "1.20.2",
|
"version": "1.21.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 8887",
|
"dev": "next dev --turbopack -p 8887",
|
||||||
@@ -21,57 +21,44 @@
|
|||||||
"@better-auth/core": "1.6.11",
|
"@better-auth/core": "1.6.11",
|
||||||
"@better-auth/passkey": "1.6.11",
|
"@better-auth/passkey": "1.6.11",
|
||||||
"@better-auth/sso": "1.6.11",
|
"@better-auth/sso": "1.6.11",
|
||||||
|
"@google-cloud/storage": "^7.19.0",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"@onboardjs/react": "1.0.0-rc.5",
|
"@onboardjs/react": "1.0.0-rc.5",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@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-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@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-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@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-icons": "^1.3.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@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-popover": "^1.1.15",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@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": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-email/components": "^0.0.41",
|
"@react-email/components": "^0.0.41",
|
||||||
"@t3-oss/env-nextjs": "^0.13.11",
|
"@t3-oss/env-nextjs": "^0.13.11",
|
||||||
"@tanstack/react-query": "^5.100.14",
|
"@tanstack/react-query": "^5.100.14",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/nodemailer": "^6.4.23",
|
"@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",
|
"better-auth": "1.6.11",
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.3.0",
|
"date-fns": "^4.3.0",
|
||||||
"dockerode": "^4.0.12",
|
|
||||||
"dotenv": "^16.6.1",
|
"dotenv": "^16.6.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"googleapis": "^170.1.0",
|
"googleapis": "^170.1.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
@@ -81,33 +68,22 @@
|
|||||||
"next-safe-action": "^7.10.8",
|
"next-safe-action": "^7.10.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-forge": "^1.4.0",
|
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.5",
|
||||||
"npm-check-updates": "^18.3.1",
|
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
|
||||||
"prettier": "^3.8.3",
|
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-day-picker": "9.7.0",
|
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-email": "^4.3.2",
|
"react-email": "^4.3.2",
|
||||||
"react-hook-form": "^7.76.1",
|
"react-hook-form": "^7.76.1",
|
||||||
"react-qr-code": "^2.0.21",
|
"react-qr-code": "^2.0.21",
|
||||||
"react-resizable-panels": "^3.0.6",
|
|
||||||
"react-twc": "^1.5.1",
|
"react-twc": "^1.5.1",
|
||||||
"react-use-measure": "^2.1.7",
|
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.3",
|
|
||||||
"socket.io-client": "^4.8.3",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"swiper": "^12.1.4",
|
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"uuid": "^11.1.1",
|
"uuid": "^11.1.1",
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"ws": "^8.21.0",
|
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -118,29 +94,21 @@
|
|||||||
"@release-it/conventional-changelog": "^10.0.6",
|
"@release-it/conventional-changelog": "^10.0.6",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/eslint-plugin-tailwindcss": "^3.17.0",
|
|
||||||
"@types/node": "^22.19.19",
|
"@types/node": "^22.19.19",
|
||||||
"@types/node-forge": "^1.3.14",
|
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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",
|
"drizzle-kit": "^0.31.10",
|
||||||
"esbuild": "^0.27.7",
|
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.6",
|
"eslint-config-next": "^16.2.6",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"node-pty": "^1.1.0",
|
"pino-pretty": "^13.1.3",
|
||||||
"postcss": "8.5.10",
|
"postcss": "8.5.10",
|
||||||
|
"postcss-load-config": "^6.0.1",
|
||||||
"release-it": "^19.2.4",
|
"release-it": "^19.2.4",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"tsx": "^4.22.3",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3"
|
||||||
"zenstack": "2.14.2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.8.0+sha512.c1f5e7c4cb241c8f174b743851d82f42b802324afc8b0f116b96adb15aa06664948dde36960a3ba1079ba5b4b29dd0140135b94b5b5f5263592249d68e555f26"
|
"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,
|
"when": 1782474105293,
|
||||||
"tag": "0066_milky_puma",
|
"tag": "0066_milky_puma",
|
||||||
"breakpoints": true
|
"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 {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "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', {
|
export const storageChannel = pgTable('storage_channel', {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {pgEnum} from "drizzle-orm/pg-core";
|
|||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const dbmsEnum = pgEnum("dbms_status", ["postgresql", "mysql", "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 statusEnum = pgEnum("status", ["waiting", "ongoing", "failed", "success"]);
|
||||||
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
export const typeStorageEnum = pgEnum("type_storage", ["local", "s3"]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,297 +1,254 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import {useRouter} from "next/navigation";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
import {
|
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||||
Form,
|
import {ButtonWithLoading} from "@/components/common/button-with-loading";
|
||||||
FormControl,
|
import {Input} from "@/components/ui/input";
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
useZodForm,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { ButtonWithLoading } from "@/components/common/button-with-loading";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addStorageChannelAction,
|
addStorageChannelAction, updateStorageChannelAction
|
||||||
updateStorageChannelAction,
|
|
||||||
} from "@/features/channel/components/storages/channel.action";
|
} from "@/features/channel/components/storages/channel.action";
|
||||||
import { toast } from "sonner";
|
import {toast} from "sonner";
|
||||||
import { OrganizationWithMembers } from "@/db/schema/03_organization";
|
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||||
import { Button } from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import { NotificationChannelWith } from "@/db/schema/09_notification-channel";
|
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||||
import { useEffect } from "react";
|
import {useEffect} from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
import { Card } from "@/components/ui/card";
|
import {Card} from "@/components/ui/card";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import {ArrowLeft} from "lucide-react";
|
||||||
import { StorageChannelWith } from "@/db/schema/12_storage-channel";
|
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||||
import {
|
import {
|
||||||
NotificationChannelFormSchema,
|
NotificationChannelFormSchema, NotificationChannelFormType, StorageChannelFormSchema, StorageChannelFormType
|
||||||
NotificationChannelFormType,
|
|
||||||
StorageChannelFormSchema,
|
|
||||||
StorageChannelFormType,
|
|
||||||
} from "@/features/channel/schemas/channel-form.schema";
|
} from "@/features/channel/schemas/channel-form.schema";
|
||||||
import {
|
import {
|
||||||
ChannelKind,
|
ChannelKind,
|
||||||
renderChannelForm,
|
renderChannelForm
|
||||||
} from "@/features/channel/components/channels-helpers";
|
} from "@/features/channel/components/channels-helpers";
|
||||||
import { storageProviders } from "@/features/channel/components/channels-storage-helper";
|
import {storageProviders} from "@/features/channel/components/channels-storage-helper";
|
||||||
import { notificationProviders } from "@/features/channel/components/channels-notification-helper";
|
import {notificationProviders} from "@/features/channel/components/channels-notification-helper";
|
||||||
import { ChannelTestButton } from "@/features/channel/components/channel-test-button";
|
|
||||||
import {
|
import {
|
||||||
addNotificationChannelAction,
|
ChannelTestButton
|
||||||
updateNotificationChannelAction,
|
} from "@/features/channel/components/channel-test-button";
|
||||||
|
import {
|
||||||
|
addNotificationChannelAction, updateNotificationChannelAction
|
||||||
} from "@/features/channel/components/notifications/channel.action";
|
} from "@/features/channel/components/notifications/channel.action";
|
||||||
|
|
||||||
|
|
||||||
type NotifierFormProps = {
|
type NotifierFormProps = {
|
||||||
onSuccessAction?: () => void;
|
onSuccessAction?: () => void;
|
||||||
organization?: OrganizationWithMembers;
|
organization?: OrganizationWithMembers;
|
||||||
defaultValues?: NotificationChannelWith | StorageChannelWith;
|
defaultValues?: NotificationChannelWith | StorageChannelWith
|
||||||
adminView?: boolean;
|
adminView?: boolean
|
||||||
kind: ChannelKind;
|
kind: ChannelKind
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChannelForm = ({
|
export const ChannelForm = ({onSuccessAction, organization, defaultValues, kind}: NotifierFormProps) => {
|
||||||
onSuccessAction,
|
|
||||||
organization,
|
|
||||||
defaultValues,
|
|
||||||
kind,
|
|
||||||
}: NotifierFormProps) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const isCreate = !Boolean(defaultValues);
|
|
||||||
|
|
||||||
const form = useZodForm({
|
const router = useRouter();
|
||||||
schema:
|
const isCreate = !Boolean(defaultValues);
|
||||||
kind == "notification"
|
|
||||||
? NotificationChannelFormSchema
|
|
||||||
: StorageChannelFormSchema,
|
|
||||||
// @ts-expect-error — defaultValues type is a union of notification/storage channel types
|
|
||||||
defaultValues: { ...defaultValues },
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
const form = useZodForm({
|
||||||
form.reset(defaultValues ? { ...defaultValues } : { enabled: true });
|
schema: kind == "notification" ? NotificationChannelFormSchema : StorageChannelFormSchema,
|
||||||
}, [defaultValues]);
|
// @ts-expect-error — defaultValues type is a union of notification/storage channel types
|
||||||
|
defaultValues: {...defaultValues},
|
||||||
|
});
|
||||||
|
|
||||||
const mutationAddNotificationChannel = useMutation({
|
useEffect(() => {
|
||||||
mutationFn: async (
|
form.reset(defaultValues ? {...defaultValues} : {enabled: true});
|
||||||
values: NotificationChannelFormType | StorageChannelFormType,
|
}, [defaultValues]);
|
||||||
) => {
|
|
||||||
const payload = {
|
|
||||||
data: values,
|
|
||||||
...(organization && { organizationId: organization.id }),
|
|
||||||
...(defaultValues && { id: defaultValues.id }),
|
|
||||||
};
|
|
||||||
|
|
||||||
let result: any;
|
const mutationAddNotificationChannel = useMutation({
|
||||||
|
mutationFn: async (values: NotificationChannelFormType | StorageChannelFormType) => {
|
||||||
|
|
||||||
if (kind === "notification") {
|
const payload = {
|
||||||
// @ts-expect-error — payload type varies between notification and storage
|
data: values,
|
||||||
result = isCreate
|
...(organization && {organizationId: organization.id}),
|
||||||
? await addNotificationChannelAction(payload)
|
...((defaultValues && {id: defaultValues.id}))
|
||||||
: 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 inner = result?.data;
|
|
||||||
|
|
||||||
if (inner?.success) {
|
let result: any;
|
||||||
toast.success(inner.actionSuccess?.message);
|
|
||||||
router.refresh();
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
toast.error(inner?.actionError?.message);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveBeforeConnect = async (): Promise<boolean> => {
|
if (kind === "notification") {
|
||||||
const valid = await form.trigger();
|
// @ts-expect-error — payload type varies between notification and storage
|
||||||
if (!valid) return false;
|
result = isCreate ? await addNotificationChannelAction(payload) : await updateNotificationChannelAction(payload);
|
||||||
return Boolean(
|
} else if (kind === "storage") {
|
||||||
await mutationAddNotificationChannel.mutateAsync(form.getValues()),
|
// @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 inner = result?.data;
|
||||||
const channelTypes =
|
|
||||||
kind == "notification" ? notificationProviders : storageProviders;
|
|
||||||
const selectedProviderDetails = channelTypes.find(
|
|
||||||
(t) => t.value === provider,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isCreate && !provider) {
|
if (inner?.success) {
|
||||||
return (
|
toast.success(inner.actionSuccess?.message);
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
isCreate && onSuccessAction?.();
|
||||||
{channelTypes
|
router.refresh();
|
||||||
.filter((p) => p.value != "local")
|
} else {
|
||||||
.map((type) => {
|
toast.error(inner?.actionError?.message);
|
||||||
const Icon = type.icon;
|
isCreate && onSuccessAction?.();
|
||||||
return (
|
}
|
||||||
<Card
|
}
|
||||||
key={type.value}
|
});
|
||||||
className={cn(
|
|
||||||
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
|
const provider = form.watch("provider");
|
||||||
type.preview
|
const channelTypes = kind == "notification" ? notificationProviders : storageProviders
|
||||||
? "cursor-not-allowed bg-muted/60 border-border text-muted-foreground"
|
const selectedProviderDetails = channelTypes.find(t => t.value === provider);
|
||||||
: "cursor-pointer bg-background hover:bg-accent/40 hover:border-primary/50",
|
|
||||||
)}
|
if (isCreate && !provider) {
|
||||||
onClick={() => {
|
return (
|
||||||
if (type.preview) return;
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 py-4">
|
||||||
form.setValue("provider", type.value as any);
|
{channelTypes.filter(p => p.value != "local").map((type) => {
|
||||||
form.setValue("config", {});
|
const Icon = type.icon;
|
||||||
}}
|
return (
|
||||||
>
|
<Card
|
||||||
{type.preview && (
|
key={type.value}
|
||||||
<div className="absolute bottom-0 right-0 overflow-hidden w-20 h-20 pointer-events-none">
|
className={cn(
|
||||||
<div
|
"relative flex flex-col items-center justify-center gap-3 p-4 transition-all border",
|
||||||
className="absolute bottom-0 right-0 w-38 h-5 flex items-center justify-center
|
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"
|
transform -rotate-45 translate-x-14 -translate-y-4"
|
||||||
style={{ backgroundColor: "#FE6702" }}
|
style={{backgroundColor: "#FE6702"}}
|
||||||
>
|
>
|
||||||
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">
|
<span className="text-white text-[7px] pr-3 font-medium text-center w-full">COMING SOON</span>
|
||||||
COMING SOON
|
</div>
|
||||||
</span>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="flex-1">
|
||||||
className={cn(
|
<p className="text-sm font-medium">Configuring {selectedProviderDetails?.label}</p>
|
||||||
"h-10 w-10 rounded-full flex items-center justify-center transition-colors",
|
<p className="text-xs text-muted-foreground">{isCreate ? "New Channel" : "Edit Channel"}</p>
|
||||||
type.preview
|
|
||||||
? "bg-muted"
|
|
||||||
: "bg-accent text-accent-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={cn(
|
|
||||||
"h-6 w-6",
|
|
||||||
type.preview
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-foreground",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
{isCreate && (
|
||||||
className={cn(
|
<Button
|
||||||
"font-medium text-sm text-center leading-tight",
|
type="button"
|
||||||
type.preview ? "text-muted-foreground" : "text-foreground",
|
variant="ghost"
|
||||||
)}
|
size="sm"
|
||||||
>
|
onClick={() => {
|
||||||
{type.label}
|
form.setValue("provider", undefined as any);
|
||||||
</span>
|
form.setValue("config", undefined);
|
||||||
</Card>
|
}}
|
||||||
);
|
>
|
||||||
})}
|
<ArrowLeft className="h-4 w-4 mr-2"/>
|
||||||
</div>
|
Change
|
||||||
);
|
</Button>
|
||||||
}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
return (
|
<FormField
|
||||||
<Form
|
control={form.control}
|
||||||
form={form}
|
name="name"
|
||||||
className="flex flex-col gap-4"
|
render={({field}) => (
|
||||||
onSubmit={async (values) => {
|
<FormItem>
|
||||||
await mutationAddNotificationChannel.mutateAsync(values);
|
<FormLabel>Channel Name *</FormLabel>
|
||||||
isCreate && onSuccessAction?.();
|
<FormControl>
|
||||||
}}
|
<Input {...field} placeholder={`e.g. ${selectedProviderDetails?.label} Channel`}
|
||||||
>
|
value={field.value ?? ""}/>
|
||||||
<div className="flex items-center gap-3 mb-2 p-3 bg-secondary/30 rounded-lg border border-border">
|
</FormControl>
|
||||||
{selectedProviderDetails && (
|
<FormMessage/>
|
||||||
<div className="h-10 w-10 bg-background rounded-full flex items-center justify-center border border-border shadow-sm">
|
</FormItem>
|
||||||
<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()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
<FormField
|
||||||
<div className="flex gap-2">
|
control={form.control}
|
||||||
<Button
|
name="provider"
|
||||||
type="button"
|
render={({field}) => (
|
||||||
variant="outline"
|
<input type="hidden" {...field} value={field.value || ""}/>
|
||||||
onClick={() => {
|
)}
|
||||||
onSuccessAction?.();
|
/>
|
||||||
form.reset();
|
|
||||||
}}
|
{renderChannelForm(provider, form)}
|
||||||
>
|
|
||||||
Cancel
|
<div className="flex justify-between mt-4">
|
||||||
</Button>
|
<div>
|
||||||
<ButtonWithLoading
|
{(!isCreate || kind == "storage") && (
|
||||||
isPending={mutationAddNotificationChannel.isPending}
|
<ChannelTestButton
|
||||||
>
|
kind={kind}
|
||||||
{isCreate ? "Add" : "Save"} Channel
|
organizationId={organization?.id}
|
||||||
</ButtonWithLoading>
|
channel={form.getValues()}
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
</Form>
|
</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 {
|
import {
|
||||||
StorageBlobForm
|
StorageBlobForm
|
||||||
} from "@/features/channel/components/storages/az-blob.form";
|
} 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";
|
export type ChannelKind = "notification" | "storage";
|
||||||
|
|
||||||
@@ -83,11 +87,7 @@ export const getChannelIcon = (type: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const renderChannelForm = (
|
export const renderChannelForm = (provider: string | undefined, form: UseFormReturn<any>) => {
|
||||||
provider: string | undefined,
|
|
||||||
form: UseFormReturn<any>,
|
|
||||||
onSaveBeforeConnect?: () => Promise<boolean>,
|
|
||||||
) => {
|
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case "smtp":
|
case "smtp":
|
||||||
return <NotifierSmtpForm form={form}/>;
|
return <NotifierSmtpForm form={form}/>;
|
||||||
@@ -112,7 +112,9 @@ export const renderChannelForm = (
|
|||||||
case "s3":
|
case "s3":
|
||||||
return <StorageS3Form form={form}/>
|
return <StorageS3Form form={form}/>
|
||||||
case "google-drive":
|
case "google-drive":
|
||||||
return <StorageGoogleDriveForm form={form} onSaveBeforeConnect={onSaveBeforeConnect}/>
|
return <StorageGoogleDriveForm form={form}/>
|
||||||
|
case "google-cloud-storage":
|
||||||
|
return <StorageGoogleCloudStorageForm form={form}/>
|
||||||
case "blob":
|
case "blob":
|
||||||
return <StorageBlobForm form={form}/>
|
return <StorageBlobForm form={form}/>
|
||||||
case "local":
|
case "local":
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const storageProviders: ProviderIconTypes[] = [
|
|||||||
{value: "s3", label: "S3", icon: S3Icon},
|
{value: "s3", label: "S3", icon: S3Icon},
|
||||||
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
{value: "google-drive", label: "Google Drive", icon: GoogleDriveIcon},
|
||||||
{value: "blob", label: "Azure Blob Storage", icon: BlobIcon},
|
{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>) {
|
export function S3Icon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
|||||||
@@ -9,53 +9,117 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
|
||||||
type StorageBlobFormProps = {
|
type StorageBlobFormProps = {
|
||||||
form: UseFormReturn<any, any, any>;
|
form: UseFormReturn<any, any, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StorageBlobForm = ({ form }: StorageBlobFormProps) => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-1" />
|
<Separator className="my-1" />
|
||||||
<FormField
|
<Tabs value={authMode} onValueChange={handleModeChange} className="w-full">
|
||||||
control={form.control}
|
<TabsList className="w-full">
|
||||||
name="config.accountName"
|
<TabsTrigger value="connectionString">Connection String</TabsTrigger>
|
||||||
render={({ field }) => (
|
<TabsTrigger value="accountKey">Account Details</TabsTrigger>
|
||||||
<FormItem>
|
</TabsList>
|
||||||
<FormLabel>Account Name *</FormLabel>
|
|
||||||
<FormControl>
|
<TabsContent value="connectionString" className="flex flex-col gap-3">
|
||||||
<Input {...field} placeholder="e.g. mystorageaccount" />
|
<FormField
|
||||||
</FormControl>
|
control={form.control}
|
||||||
<FormMessage />
|
name="config.connectionString"
|
||||||
</FormItem>
|
render={({ field }) => (
|
||||||
)}
|
<FormItem>
|
||||||
/>
|
<FormLabel>Connection String *</FormLabel>
|
||||||
<FormField
|
<FormControl>
|
||||||
control={form.control}
|
<PasswordInput
|
||||||
name="config.accountKey"
|
{...field}
|
||||||
render={({ field }) => (
|
value={field.value ?? ""}
|
||||||
<FormItem>
|
placeholder="e.g. DefaultEndpointsProtocol=https;..."
|
||||||
<FormLabel>Account Key</FormLabel>
|
/>
|
||||||
<FormControl>
|
</FormControl>
|
||||||
<PasswordInput {...field} placeholder="e.g. base64-encoded-key" />
|
<FormMessage />
|
||||||
</FormControl>
|
</FormItem>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
</TabsContent>
|
||||||
/>
|
|
||||||
<FormField
|
<TabsContent value="accountKey" className="flex flex-col gap-3">
|
||||||
control={form.control}
|
<FormField
|
||||||
name="config.connectionString"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="config.accountName"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Connection String</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Account Name *</FormLabel>
|
||||||
<PasswordInput {...field} placeholder="e.g. DefaultEndpointsProtocol=https;..." />
|
<FormControl>
|
||||||
</FormControl>
|
<Input
|
||||||
<FormMessage />
|
{...field}
|
||||||
</FormItem>
|
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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.containerName"
|
name="config.containerName"
|
||||||
@@ -63,20 +127,11 @@ export const StorageBlobForm = ({ form }: StorageBlobFormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Container Name *</FormLabel>
|
<FormLabel>Container Name *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="e.g. backups-prod" />
|
<Input
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ""}
|
||||||
</FormItem>
|
placeholder="e.g. backups-prod"
|
||||||
)}
|
/>
|
||||||
/>
|
|
||||||
<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" />
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
|
|
||||||
export const BlobChannelConfigSchema = z.object({
|
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(),
|
accountKey: z.string().optional(),
|
||||||
connectionString: z.string().optional(),
|
connectionString: z.string().optional(),
|
||||||
containerName: z.string().min(1, "Container name is required"),
|
containerName: z.string().min(1, "Container name is required"),
|
||||||
@@ -9,7 +10,29 @@ export const BlobChannelConfigSchema = z.object({
|
|||||||
(v) => (v === "" ? undefined : v),
|
(v) => (v === "" ? undefined : v),
|
||||||
z.string().url("Endpoint URL must be a valid URL").optional(),
|
z.string().url("Endpoint URL must be a valid URL").optional(),
|
||||||
),
|
),
|
||||||
}).refine(
|
}).superRefine((data, ctx) => {
|
||||||
(data) => data.accountKey || data.connectionString,
|
if (data.authMode === "connectionString") {
|
||||||
{message: "Either account key or connection string is required"}
|
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";
|
"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 {
|
import {
|
||||||
FormControl,
|
googleDriveRefreshTokenAction
|
||||||
FormField,
|
} from "@/features/channel/components/storages/google-drive/google-drive-refresh.action";
|
||||||
FormItem,
|
import {toast} from "sonner";
|
||||||
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";
|
|
||||||
|
|
||||||
type StorageGoogleDriveFormProps = {
|
type StorageGoogleDriveFormProps = {
|
||||||
form: UseFormReturn<any>;
|
form: UseFormReturn<any>;
|
||||||
onSaveBeforeConnect?: () => Promise<boolean>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StorageGoogleDriveForm = ({
|
export const StorageGoogleDriveForm = ({form}: StorageGoogleDriveFormProps) => {
|
||||||
form,
|
const refreshToken = form.watch("config.refreshToken");
|
||||||
onSaveBeforeConnect,
|
const isConnected = Boolean(refreshToken);
|
||||||
}: StorageGoogleDriveFormProps) => {
|
|
||||||
const refreshToken = form.watch("config.refreshToken");
|
|
||||||
const isConnected = Boolean(refreshToken);
|
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = () => {
|
||||||
const clientId = form.getValues("config.clientId");
|
const clientId = form.getValues("config.clientId");
|
||||||
const clientSecret = form.getValues("config.clientSecret");
|
const clientSecret = form.getValues("config.clientSecret");
|
||||||
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
|
const redirectUri = `${window.location.origin}/api/google/drive/callback`;
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
if (!clientId || !clientSecret) {
|
||||||
form.setError("config.clientId", {
|
form.setError("config.clientId", {message: "Client ID and Secret are required"});
|
||||||
message: "Client ID and Secret are required",
|
return;
|
||||||
});
|
|
||||||
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 (oauthWindow.location.href.startsWith(redirectUri)) {
|
const scope = encodeURIComponent("https://www.googleapis.com/auth/drive.file");
|
||||||
const code = new URL(oauthWindow.location.href).searchParams.get(
|
const oauthUrl =
|
||||||
"code",
|
`https://accounts.google.com/o/oauth2/v2/auth` +
|
||||||
);
|
`?client_id=${clientId}` +
|
||||||
if (!code) return;
|
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
||||||
|
`&response_type=code` +
|
||||||
|
`&scope=${scope}` +
|
||||||
|
`&access_type=offline` +
|
||||||
|
`&prompt=consent`;
|
||||||
|
|
||||||
const result = await googleDriveRefreshTokenAction({
|
const oauthWindow = window.open(oauthUrl, "_blank", "width=500,height=600");
|
||||||
code,
|
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
redirectUri,
|
|
||||||
});
|
|
||||||
const inner = result?.data;
|
|
||||||
|
|
||||||
if (inner?.success) {
|
const interval = setInterval(async () => {
|
||||||
toast.success(inner.actionSuccess?.message);
|
try {
|
||||||
|
if (!oauthWindow || oauthWindow.closed) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!inner.value) {
|
if (oauthWindow.location.href.startsWith(redirectUri)) {
|
||||||
form.setError("config", {
|
const code = new URL(oauthWindow.location.href).searchParams.get("code");
|
||||||
message: "OAuth succeeded but no refresh token returned",
|
if (!code) return;
|
||||||
});
|
|
||||||
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, {
|
return (
|
||||||
shouldValidate: true,
|
<>
|
||||||
shouldDirty: true,
|
<Separator className="my-1"/>
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.error(inner?.actionError?.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthWindow.close();
|
<FormField
|
||||||
clearInterval(interval);
|
control={form.control}
|
||||||
}
|
name="config.clientId"
|
||||||
} catch {}
|
render={({field}) => (
|
||||||
}, 500);
|
<FormItem>
|
||||||
};
|
<FormLabel>Client ID *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="e.g. xxxx.apps.googleusercontent.com"/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<FormField
|
||||||
<>
|
control={form.control}
|
||||||
<Separator className="my-1" />
|
name="config.clientSecret"
|
||||||
|
render={({field}) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Client Secret *</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PasswordInput {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="config.clientId"
|
name="config.folderId"
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Client ID *</FormLabel>
|
<FormLabel>Folder ID *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} placeholder="e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz"/>
|
||||||
{...field}
|
</FormControl>
|
||||||
placeholder="e.g. xxxx.apps.googleusercontent.com"
|
<FormMessage/>
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<input type="hidden" {...form.register("config.refreshToken")} />
|
||||||
control={form.control}
|
|
||||||
name="config.clientSecret"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Client Secret *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<PasswordInput {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
<div className="flex items-center gap-3">
|
||||||
control={form.control}
|
<Button type="button" variant={"secondary"} className="hover:cursor-pointer" onClick={handleConnect}>
|
||||||
name="config.folderId"
|
{isConnected ? "Reconnect Google Drive" : "Connect Google Drive"}
|
||||||
render={({ field }) => (
|
</Button>
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Folder ID *</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder="e.g. 1AbCdEfGhIjKlMnOpQrStUvWxYz"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<input type="hidden" {...form.register("config.refreshToken")} />
|
{isConnected && (
|
||||||
|
<span className="text-sm text-green-600 font-medium">
|
||||||
<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">
|
|
||||||
Google Drive connected
|
Google Drive connected
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import {
|
|||||||
pingGoogleDrive,
|
pingGoogleDrive,
|
||||||
uploadGoogleDrive
|
uploadGoogleDrive
|
||||||
} from "@/features/channel/components/storages/google-drive";
|
} from "@/features/channel/components/storages/google-drive";
|
||||||
|
import {
|
||||||
|
copyGoogleCloudStorage,
|
||||||
|
deleteGoogleCloudStorage,
|
||||||
|
getGoogleCloudStorage,
|
||||||
|
pingGoogleCloudStorage,
|
||||||
|
uploadGoogleCloudStorage
|
||||||
|
} from "@/features/channel/components/storages/google-cloud-storage";
|
||||||
|
|
||||||
type ProviderHandler = {
|
type ProviderHandler = {
|
||||||
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
|
upload: (config: any, input: StorageInput & { action: 'upload' }) => Promise<StorageResult>;
|
||||||
@@ -51,6 +58,13 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
|||||||
delete: deleteBlob,
|
delete: deleteBlob,
|
||||||
ping: pingBlob,
|
ping: pingBlob,
|
||||||
copy: copyBlob,
|
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>
|
<FormItem>
|
||||||
<FormLabel>Use SSL</FormLabel>
|
<FormLabel>Use SSL</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
<Switch checked={field.value ?? true} onCheckedChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {GoogleDriveChannelConfigSchema} from "@/features/channel/components/stor
|
|||||||
import {LocalChannelConfigSchema} from "@/features/channel/components/storages/local.schema";
|
import {LocalChannelConfigSchema} from "@/features/channel/components/storages/local.schema";
|
||||||
import {TeamsChannelConfigSchema} from "@/features/channel/components/notifications/teams.schema";
|
import {TeamsChannelConfigSchema} from "@/features/channel/components/notifications/teams.schema";
|
||||||
import {BlobChannelConfigSchema} from "@/features/channel/components/storages/az-blob.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({
|
const BaseChannelFormSchema = z.object({
|
||||||
@@ -79,6 +83,10 @@ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
|||||||
provider: z.literal("blob"),
|
provider: z.literal("blob"),
|
||||||
config: BlobChannelConfigSchema,
|
config: BlobChannelConfigSchema,
|
||||||
}),
|
}),
|
||||||
|
BaseChannelFormSchema.extend({
|
||||||
|
provider: z.literal("google-cloud-storage"),
|
||||||
|
config: GoogleCloudStorageChannelConfigSchema,
|
||||||
|
}),
|
||||||
BaseChannelFormSchema.extend({
|
BaseChannelFormSchema.extend({
|
||||||
provider: z.literal("local"),
|
provider: z.literal("local"),
|
||||||
config: LocalChannelConfigSchema
|
config: LocalChannelConfigSchema
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type StorageProviderKind =
|
|||||||
| 'local'
|
| 'local'
|
||||||
| 's3'
|
| 's3'
|
||||||
| 'google-drive'
|
| 'google-drive'
|
||||||
|
| 'google-cloud-storage'
|
||||||
| 'blob'
|
| 'blob'
|
||||||
;
|
;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@ export function capitalizeFirstLetter(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isUUID(str: 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(
|
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,
|
str,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user