Compare commits

..
Author SHA1 Message Date
github-actions[bot] 6548945b2f chore: release 1.21.0 2026-07-03 06:15:32 +00:00
Charles GTEandGitHub 4b1d0aba74 Merge pull request #350 from Portabase/feat/backup-restore-docker-volume
feat: docker-volume provider
2026-07-03 08:14:51 +02:00
charles-gauthereau c726346258 feat: docker-volume provider 2026-07-02 21:55:19 +02:00
github-actions[bot] 0677f14ac5 chore: release 1.20.7 2026-07-01 06:11:01 +00:00
Charles GTEandGitHub d346f5cdc4 Merge pull request #349 from Portabase/fix/docker-image-weight
fix: docker-image-weight
2026-07-01 08:10:22 +02:00
charles-gauthereau 1ce08448ec fix: Dockerfile 2026-06-30 16:56:28 +02:00
charles-gauthereau 77fb1095f1 fix: removed unused packages 2026-06-30 16:24:06 +02:00
29dd597c7a fix: s3-ssl-error [skip-release] (#348)
* fix: rustfs test instance

* fix: s3.form.tsx

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 15:32:06 +02:00
github-actions[bot] 7943f3fd4b chore: release 1.20.6 2026-06-30 07:33:58 +00:00
a63c29b943 fix: docker.yml (#346)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 09:32:58 +02:00
github-actions[bot] f4d72090a3 chore: release 1.20.5 2026-06-30 07:14:59 +00:00
8b379067a1 fix: readme (#345)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 09:13:56 +02:00
github-actions[bot] 896b65f2a5 chore: release 1.20.4 2026-06-30 06:18:38 +00:00
79d725cd12 fix: uuid v7 (#344)
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-30 08:17:46 +02:00
github-actions[bot] dfa5b42467 chore: release 1.20.3 2026-06-27 19:34:36 +00:00
Charles GTEandGitHub e6b63a4570 Merge pull request #340 from Portabase/dev
Dev
2026-06-27 21:14:47 +02:00
Charles GTE eb13fce8e9 chore: pnpm 2026-06-27 20:54:54 +02:00
Charles GTEandGitHub f70d86f394 fix: azure form (#341) 2026-06-27 20:52:43 +02:00
Hayzie ChuGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Charles GTE
8f44071069 Feature/google cloud storage (#316)
* environment setup and reproduce

* rmv my dashboard

* Stop tracking my-dashboard

* gitignore: ignore all .env files

* working UI, not yet tested

* Update credential validation

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* tested with fake-gcs-server

* fix: migration

* fix: migration, init for gcs and refactoring

* fix: get object gcs

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Charles GTE <charlesgte31@gmail.com>
2026-06-27 20:35:59 +02:00
79832f0d0b Agent count in dashboard bug (#336)
* potential issue source

* filtered agents

---------

Co-authored-by: Charles GTE <charlesgte31@gmail.com>
2026-06-27 17:19:26 +02:00
Charles GTEandGitHub f89b8d3ac3 fix: postgres-cluster (#339)
* fix: postgres cluster

* fix: migration

* fix: migration
2026-06-27 17:14:39 +02:00
40247efddd feat/onboarding (#337)
* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-06-27 17:08:38 +02:00
38 changed files with 11352 additions and 4157 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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
View File
@@ -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'
+2
View File
@@ -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 -2
View File
@@ -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)),
+5 -5
View File
@@ -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:
+93
View File
@@ -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:
+26 -21
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
{
"ignoreDependencies": ["pino-pretty"]
}
+6 -38
View File
@@ -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"
}
+1421 -3598
View File
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';
+1
View File
@@ -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
+21
View File
@@ -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
}
]
}
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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"]);
+222 -265
View File
@@ -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",
});
}
}
});
@@ -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>
)}
/>
</>
);
};
@@ -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
+1
View File
@@ -4,6 +4,7 @@ export type StorageProviderKind =
| 'local'
| 's3'
| 'google-drive'
| 'google-cloud-storage'
| 'blob'
;
+2 -2
View File
@@ -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,
);
}