Skip to content

Commit 663959d

Browse files
authored
feat: s3 and gcs integrations (#464)
* feat: S3/GCS data export integration Export a project's analytics events to the user's own S3 or GCS bucket. ClickHouse is the single source of truth for the export — there is no Redis buffer and no per-event ingestion hook, so a stalled/failing bucket can never touch the ingestion path (in either the Kafka or GroupMQ mode). How it works: - A new `inserted_at DateTime64(3)` column on the events table records ingestion time. It DEFAULTs to `created_at` (deterministic for pre-migration parts — a DEFAULT now() would be evaluated lazily at read time and never settle) and is set explicitly at insert time (createEvent + the import production-move), so backdated events (server-side, offline, imports) still get a real insert time. A minmax skip index keeps the windowed scan cheap since inserted_at isn't in the primary key. - The flushExports cron job (every 60s) windows the events table by inserted_at for each (project, integration), batches rows into gzipped JSONL + a manifest, and uploads them. A per-(project, integration) ExportWatermark (Postgres) tracks progress with a composite (inserted_at, id) cursor — the id tie-breaker is required because an import stamps one inserted_at across its whole batch. A safety lag behind now() avoids reading in-flight inserts; the watermark only advances after a successful upload (at-least-once; the manifest is the commit marker). First run starts from now(), so connecting an export doesn't dump all history (historical backfill = reset the watermark). Integrations are organization-scoped, so every active project in the org exports independently and gets its own watermark + object path (layout keyed by project_id). Destinations are a pluggable object-store sink (S3 + GCS adapters) so future targets are small additions. Credentials (S3 secret keys, GCS service-account keys) are encrypted at rest with CREDENTIALS_ENCRYPTION_KEY and decrypted inside the adapters. Export batching and the safety lag are tunable via EXPORT_* env vars. Rebased onto main after the Redpanda/Kafka migration. Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * feat: scope integrations to projects (dual-scope) Phase 1 of the integrations rework. Adds a nullable Integration.projectId: set = project-scoped, null = legacy org-wide. Existing org integrations keep working untouched (additive migration, no backfill). - schema: Integration.projectId (nullable FK) + indexes; Project reverse relation. - export cron: project-scoped integrations export only their project; legacy org-wide rows still fan out across the org's projects. - validation/tRPC: create inputs take projectId (org derived from project); added in-handler getProjectAccess to list + createOrUpdate* (closing a pre-existing authz gap that trusted client-supplied scope); get/delete use project-or-org access by scope. list also surfaces legacy org-wide rows. - notification rules: connected integrations must belong to the same project (or be org-wide in the same org); also added the missing access check on the rule create branch. - Slack OAuth carries projectId through install metadata + callback redirect. - dashboard: integrations moved to project-scoped routes + sidebar; org route and org-level "add integration" CTA removed. typecheck + tests green (657). Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * refactor: integration plugin registry (generic dispatch) Phase 2 of the integrations rework. Restructures integrations as three registries keyed by the same `type` literal so adding one is additive — no edits to central switches — while keeping types bulletproof. Type safety (the priority): IIntegrationConfig stays the explicit, hand-written discriminated union (source of truth, feeds Prisma's IPrismaIntegrationConfig). It is NOT derived from the registry. The registries are forced to MATCH it via `satisfies Record<IIntegrationConfig['type'], …>`, and two compile-time guards in validation fail the build if any union member loses its literal `type` discriminant or if the descriptor set drifts from the union. - core (packages/validation/src/integrations.ts): per-type zod schemas + an INTEGRATION_DESCRIPTORS registry (kinds/setup/configSchema/catalog), getDescriptor/isKind, a runtime zIntegrationConfig, a generic zCreateIntegration, and the type-level guards. - server (packages/integrations/src/registry.ts): IServerIntegration<T> + SERVER_INTEGRATIONS with notification.deliver / export.createAdapter / validateConfig / testConnection / encryptCredentials hooks. The bespoke slack/discord/webhook send bodies and s3/gcs adapter+encrypt logic moved here. getServerIntegration<T> holds the one contained cast. - client (apps/start integrations.tsx): CLIENT_INTEGRATIONS (icon + Form) keyed by type; the catalog is derived from descriptors. add-integration.tsx renders via registry lookup instead of a switch. - dispatch made generic: worker notification.ts and cron.flush-exports.ts look up the plugin; the tRPC router collapses to a generic createOrUpdate + testConnection delegating to plugin hooks (export/slack procedures kept as thin aliases for one release). - fixed the discord form's server value-import (browser-bundle leak) by routing its test through trpc.integration.testConnection. Deferred (demand-driven): the generic /oauth/:type route only benefits a second OAuth integration and carries external Slack-redirect-URI risk, so slack OAuth is unchanged. Legacy zCreate* schemas + tRPC aliases kept until the dashboard drops them. typecheck + tests green (657). Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * fix: make isKind lenient for empty/unknown integration config The flushExports cron lists every integration and filters by isKind(config, 'export'). A Slack integration that hasn't completed OAuth has an empty config ({} with no type), and the registry refactor made isKind throw "Unknown integration type: undefined" on it, failing the whole cron run. isKind now returns false for unknown/undefined types instead of throwing (it's a filter predicate, not a guaranteed-known lookup). Also guard the notification worker against delivering to an unconfigured integration. Adds an isKind regression test. Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * refactor: one encryption key for all at-rest secrets There were two AES-256-GCM modules with two env keys for the same purpose: db/src/encryption.ts (ENCRYPTION_KEY, for TOTP/GSC) and common/server/encryption.ts (CREDENTIALS_ENCRYPTION_KEY, for integration creds, needed in @openpanel/integrations which can't import db). Consolidate to one implementation in @openpanel/common/server keyed solely by ENCRYPTION_KEY. It hosts both the plain encrypt/decrypt (unchanged format, so existing TOTP/GSC ciphertext still decrypts) and the prefixed encryptCredential/decryptCredential (idempotent, plaintext-passthrough for the test-connection flow). db/src/encryption.ts now re-exports encrypt/decrypt, so @openpanel/db importers are unchanged. Drops CREDENTIALS_ENCRYPTION_KEY from .env.example. Adds an encryption round-trip test. Note: any integration credentials encrypted on this branch with the old key must be re-saved (no prod data — feature is unmerged). Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * chore: remove dead integration-registry exports PR-review cleanup: getDescriptor, descriptorsByKind, zCreateIntegration and ICreateIntegration in validation/src/integrations.ts had zero callers (the client derives the catalog from INTEGRATION_DESCRIPTORS directly, isKind uses the type map directly, and the tRPC create procedure inlines its own config-required input). The type-safety guards and isKind are unaffected. Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * fix: slack OAuth callback no longer redirects to a deleted route Integrations became project-scoped, so the org-level /$organizationId/integrations/installed route was removed. The slack callback's projectId-absent fallback still pointed there, 404-ing older in-flight installs (metadata without projectId). Fall back to the org landing page instead. Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * fix: SSRF guards + cross-project integration update authz Security-review findings (all verified against current code): - Cross-project update (integration.ts): the generic upsert and the Slack create/update authorized against the client-supplied input.projectId, so a user with access to one project could update another project's integration in the same org. Updates now load the existing row and authorize against ITS scope via assertIntegrationAccess (project access, or org access for legacy org-wide rows). - Webhook SSRF (registry.ts): user-configured webhook URLs were fetched directly. Now guarded by assertSafeUrl before dispatch. - S3 custom-endpoint SSRF (s3-adapter.ts): a tenant-controlled endpoint could point at internal services. The resolved host is now SSRF-checked in getClient() before connecting (covers upload + testConnection). assertSafeUrl is a new shared helper in @openpanel/common/server: rejects non-http(s) schemes and hosts resolving to loopback/private/CGNAT/link-local (incl. 169.254.169.254 metadata). Skipped on SELF_HOSTED, where the single operator already controls the network and internal targets are legitimate (blocking them would regress existing behavior). Adds unit tests. Skipped: tightening the S3 endpoint zod to https-only — it would break legitimate self-hosted http MinIO/internal stores, and the runtime guard (scheme + resolved-IP, cloud-gated) is the actual SSRF protection. Claude-Session: https://claude.ai/code/session_01AWucrxmRqWqgWBvr8cC5cj * chore(db): renumber migrations after rebase onto main main landed migrations up to 20260825120100 and code-migration 20 while this branch was open, so the two Prisma migrations and the ClickHouse code-migration added here sorted *before* them and code-migration 18 was a duplicate number. Renumbered to sort last; contents unchanged. None of these have been deployed, so renaming the directories is safe. * fix(integrations): gate mutations on project write access The router authorized every procedure by testing `getProjectAccess` / `getOrganizationAccess` for truthiness, which only proves membership. A read-only project member could create, update and delete integrations — the pattern main's access ladder (GHSA-f9rx-pxgw-c6rg) replaced. - create / update / delete now require `level: 'write'`; `get` and `list` stay at `'read'`. - Legacy org-wide rows (projectId null) have no project access level to consult and are shared by every project in the org, so writing to one is admin-tier; reading still only needs membership. - `testConnection` / `testExportConnection` had no check at all. They make the server connect outbound to a caller-supplied destination with caller-supplied credentials, so they now take a projectId and require write access too. * test(integrations): cover the GCS export path against a real GCS API Only the S3-compatible path had been exercised. The Google SDK has no in-process double and real GCS needs live credentials, so these run against a local fake-gcs-server and skip when it isn't reachable. Two bugs the tests turned up: - `testConnection` used `bucket.exists()`, which needs `storage.buckets.get`. The least-privilege grant for an export target (roles/storage.objectCreator / objectAdmin) does not include it, so a correctly configured bucket failed setup with a 403 while the export itself would have worked. It now writes and removes a probe object, exercising the permission the export actually needs, and maps 404/403 to a message that says what to do. - `upload` re-read `file.getMetadata()` purely for the etag, doubling the request count of every export. `save` already populates `file.metadata`. Also adds a `GCS_API_ENDPOINT` override so the client can be pointed at an emulator. The SDK's own STORAGE_EMULATOR_HOST is unusable on v7: it rewrites the JSON API base but not the upload base, so uploads and metadata reads cannot both resolve. Drops a duplicate @openpanel/common key in the package manifest (a rebase artifact) and moves @openpanel/logger to dependencies, where the adapters import it at runtime. * chore(db): renumber migrations after rebase onto main (again) main landed migration 20260828120000 and code-migrations 20/21 since the last renumber, so this branch's two Prisma migrations and the ClickHouse code-migration sorted before them again. Renumbered to sort last; contents unchanged. None of these have been deployed, so renaming the directories is safe. Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy * fix(integrations): address CodeRabbit review findings - encryption: concatenate the decipher buffers before decoding. AES-GCM is a stream cipher, so update() can end mid multi-byte UTF-8 character and the implicit per-chunk toString would emit replacement chars. Covered by a test. - ssrf: compare SELF_HOSTED to "true"/"1" instead of bare truthiness, matching the rest of the repo. SELF_HOSTED="false" was skipping assertPublicUrl before connecting to a tenant-supplied S3 endpoint. - ssrf test: restore an originally-unset SELF_HOSTED by deleting it (assigning undefined leaves the truthy string "undefined"). - exports: drop `parquet` from ExportFormat and the config schemas. createBatch always threw for it, so a parquet-configured integration could never export. - code-migration 22: MATERIALIZE INDEX after ADD INDEX so existing event parts are indexed, same as 18-events-profile-id-index.ts. - slack: build the install URL from the authorized integration's own projectId on update, so the OAuth metadata and post-callback redirect can't point at a different project than the row. - notification rules: reject integrations without the `notification` kind and filter them out of the picker. S3/GCS rows were selectable and only failed later in the worker with "is not a notification sink". Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy * fix(integrations): pin GCS credential type, make export secrets write-only Two findings from a security review of this PR. HIGH — GCS credential type confusion (arbitrary file read + SSRF). `GCSAdapter.getStorage` JSON.parsed the tenant-supplied `serviceAccountKey` and handed the raw document to `new Storage({ credentials })`. google-auth- library dispatches on the document's `type`: an `external_account` document is routed to ExternalAccountClient, whose `credential_source.file` reads any local path and whose `credential_source.url` issues an unguarded request with attacker-chosen headers, with the result POSTed to an unvalidated `token_url`. Verified against the vendored google-auth-library@9.15.1: googleauth.js:440-462 dispatch, identitypoolclient.js:74-88 suppliers, baseexternalclient.js:110 token_url. Anyone with write on any one project could read /proc/self/environ off the API process — which holds ENCRYPTION_KEY, the single key for all at-rest secrets — via the Test connection button, and again from the worker on every flushExports tick once saved. Now parsed and pinned by `parseServiceAccountKey` before it reaches the SDK, and the client is built from allowlisted fields with no `type` key at all, so GoogleAuth can only fall through to its JWT branch. The zod schema rejects non-service-account documents on the way in; the adapter re-checks, covering rows written before the schema and any future caller. MEDIUM — replayable credential ciphertext exposed at read access. `integration.list`/`get` returned the whole config, including the `enc:` ciphertext, to anyone with project membership, and `decryptCredential` accepts any `enc:` blob under the global key with no binding to the row. A read-only member could lift a ciphertext and replay it through `testConnection` or a new integration to make the server authenticate as those credentials against a destination of their choosing. Credentials are now write-only: redacted on read, rejected if an `enc:` value arrives on any input path, and carried over from the stored row when an update submits a blank (the forms say so). One `secretFields` declaration per plugin drives encrypt/redact/carry-over, replacing the `encryptCredentials` hook, so the three can't drift apart as integrations are added. Tests: credential-type rejection at both the schema and adapter layers, and the secret lifecycle. Verified against a live fake-gcs-server — uploads, manifests and the encrypted-key path all still work (994 passing with the emulator up). Fixed two test fixtures that carried incomplete service-account documents. Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy * fix(integrations): pin the S3 endpoint socket, redact Slack/webhook secrets Follow-ups to the security review. DNS rebinding on the S3 custom endpoint. `assertSafeUrl` validated the resolved address and threw it away; the AWS SDK then resolved the hostname again on its own, so a tenant endpoint whose DNS answer flipped between the check and the connect would reach an internal host. `assertSafeUrl` now returns the addresses it validated (null when skipped on self-hosted) and the S3 client is built with http/https agents whose `lookup` only ever yields that address — the same pinning `safeFetch` already does for fetch callers, via a shared `createPinnedLookup`. Only the access-key path takes an endpoint; assumed-role clients always talk to the default AWS endpoint. The hostname still travels as SNI and Host, so TLS verification is unchanged. `S3Adapter.testConnection` also echoed raw SDK errors to the caller, which made it a probe for whatever the worker could reach. Common cases now map to actionable messages and everything else (DNS, connection, TLS) collapses to one line; the full error is logged for operators. Slack tokens and webhook headers were returned in plaintext by `integration.list`/`get` at `read` — bare project membership. The Slack bot token and incoming-webhook URL are both bearer credentials for the customer's workspace, and webhook header values routinely carry an Authorization bearer. Both are now redacted. These are read-only exposures of values the notification senders consume raw, so they are redact-only: encrypting them would need decrypt-at-use plus a backfill of the existing plaintext rows. `secretFields` therefore became a descriptor — `path` (dotted, for incoming_webhook.url), `encrypted` (also encrypt at rest, reject a replayed ciphertext, require non-blank) and `record` (a map whose values are secret, keys stay visible so the form still shows which headers are set, carry-over per key so editing a webhook no longer wipes its auth header). Claude-Session: https://claude.ai/code/session_01MfxSQT66GbrE11cYb6qbdy
1 parent 247744a commit 663959d

65 files changed

Lines changed: 5290 additions & 578 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,21 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres?schema=publ
44
DATABASE_URL_DIRECT="$DATABASE_URL"
55
CLICKHOUSE_URL="http://localhost:8123/openpanel"
66

7-
# Symmetric key used to encrypt TOTP secrets and other sensitive data at rest.
7+
# Symmetric key for all at-rest encryption: TOTP secrets, GSC tokens, and
8+
# object-store export credentials (S3 secret keys, GCS service-account keys).
89
# Generate with: openssl rand -hex 32
910
ENCRYPTION_KEY=""
1011

12+
# OBJECT-STORE EXPORT (S3/GCS) tuning — optional, sensible defaults shown.
13+
# The flushExports cron job windows ClickHouse by inserted_at and uploads
14+
# batched files. LAG keeps a safety gap behind now() for in-flight inserts;
15+
# BATCH_SIZE is rows per file; MAX_BATCHES_PER_RUN bounds backlog drain per
16+
# tick; CONCURRENCY is parallel (project,integration) uploads.
17+
# EXPORT_LAG_SECONDS="60"
18+
# EXPORT_BATCH_SIZE="50000"
19+
# EXPORT_MAX_BATCHES_PER_RUN="20"
20+
# EXPORT_CONCURRENCY="4"
21+
1122
# REST
1223
BATCH_SIZE="5000"
1324
BATCH_INTERVAL="10000"

apps/api/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,10 @@
7777
"tsdown": "0.14.2",
7878
"typescript": "catalog:",
7979
"vitest": "^1.0.0"
80+
},
81+
"peerDependencies": {
82+
"@aws-sdk/client-s3": "^3.974.0",
83+
"@aws-sdk/client-sts": "^3.974.0",
84+
"@google-cloud/storage": "^7.18.0"
8085
}
8186
}

apps/api/src/controllers/webhook.controller.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const paramsSchema = z.object({
2626
const metadataSchema = z.object({
2727
organizationId: z.string(),
2828
integrationId: z.string(),
29+
// Optional for back-compat with install URLs generated before integrations
30+
// became project-scoped; the post-install redirect falls back to the org page.
31+
projectId: z.string().optional(),
2932
});
3033

3134
export async function slackWebhook(
@@ -87,7 +90,7 @@ export async function slackWebhook(
8790
'👋 Hello. You have successfully connected OpenPanel.dev to your Slack workspace.',
8891
});
8992

90-
const { organizationId, integrationId } = parsedMetadata.data;
93+
const { organizationId, integrationId, projectId } = parsedMetadata.data;
9194

9295
await db.integration.update({
9396
where: {
@@ -102,8 +105,16 @@ export async function slackWebhook(
102105
},
103106
});
104107

108+
const dashboardUrl =
109+
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL;
110+
// Integrations are project-scoped; the org-level integrations route no longer
111+
// exists. Newer installs carry projectId in their metadata. Older in-flight
112+
// installs (started before the project-scoped routes shipped) may lack it —
113+
// fall back to the org landing page rather than a now-404 integrations URL.
105114
return reply.redirect(
106-
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`
115+
projectId
116+
? `${dashboardUrl}/${organizationId}/${projectId}/integrations/installed`
117+
: `${dashboardUrl}/${organizationId}`
107118
);
108119
} catch (err) {
109120
request.log.error(err);

apps/api/tsdown.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const options: Options = {
1010
'pino',
1111
'pino-pretty',
1212
'@node-rs/argon2',
13+
// integrations package
14+
'@aws-sdk/client-s3',
15+
'@aws-sdk/client-sts',
16+
'@google-cloud/storage',
1317
],
1418
sourcemap: true,
1519
platform: 'node',

apps/start/src/components/integrations/active-integrations.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import {
1717
import { INTEGRATIONS } from './integrations';
1818

1919
export function ActiveIntegrations() {
20-
const { organizationId } = useAppParams();
20+
const { projectId } = useAppParams();
2121
const trpc = useTRPC();
2222
const query = useQuery(
2323
trpc.integration.list.queryOptions({
24-
organizationId: organizationId!,
24+
projectId: projectId!,
2525
}),
2626
);
2727
const client = useQueryClient();
@@ -30,7 +30,7 @@ export function ActiveIntegrations() {
3030
onSuccess() {
3131
client.refetchQueries(
3232
trpc.integration.list.queryFilter({
33-
organizationId,
33+
projectId,
3434
}),
3535
);
3636
},

apps/start/src/components/integrations/forms/discord-integration.tsx

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useAppParams } from '@/hooks/use-app-params';
44
import { useTRPC } from '@/integrations/trpc/react';
55
import type { RouterOutputs } from '@/trpc/client';
66
import { zodResolver } from '@hookform/resolvers/zod';
7-
import { sendTestDiscordNotification } from '@openpanel/integrations/src/discord';
87
import { zCreateDiscordIntegration } from '@openpanel/validation';
98
import { useMutation } from '@tanstack/react-query';
109
import { path, mergeDeepRight } from 'ramda';
@@ -21,12 +20,12 @@ export function DiscordIntegrationForm({
2120
defaultValues?: RouterOutputs['integration']['get'];
2221
onSuccess: () => void;
2322
}) {
24-
const { organizationId } = useAppParams();
23+
const { projectId } = useAppParams();
2524
const form = useForm<IForm>({
2625
defaultValues: mergeDeepRight(
2726
{
2827
id: defaultValues?.id,
29-
organizationId,
28+
projectId,
3029
config: {
3130
type: 'discord' as const,
3231
url: '',
@@ -55,13 +54,20 @@ export function DiscordIntegrationForm({
5554
toast.error('Validation error');
5655
};
5756

57+
const testMutation = useMutation(
58+
trpc.integration.testConnection.mutationOptions(),
59+
);
60+
5861
const handleTest = async () => {
59-
const webhookUrl = form.getValues('config.url');
60-
if (!webhookUrl) {
62+
const url = form.getValues('config.url');
63+
if (!url) {
6164
return toast.error('Webhook URL is required');
6265
}
63-
const res = await sendTestDiscordNotification(webhookUrl);
64-
if (res.ok) {
66+
const res = await testMutation.mutateAsync({
67+
projectId,
68+
config: { type: 'discord', url },
69+
});
70+
if (res.success) {
6571
toast.success('Test notification sent');
6672
} else {
6773
toast.error('Failed to send test notification');
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { InputWithLabel } from '@/components/forms/input-with-label';
2+
import { Button } from '@/components/ui/button';
3+
import {
4+
Select,
5+
SelectContent,
6+
SelectItem,
7+
SelectTrigger,
8+
SelectValue,
9+
} from '@/components/ui/select';
10+
import { useAppParams } from '@/hooks/use-app-params';
11+
import { useTRPC } from '@/integrations/trpc/react';
12+
import type { RouterOutputs } from '@/trpc/client';
13+
import { zodResolver } from '@hookform/resolvers/zod';
14+
import { zCreateGCSExportIntegration } from '@openpanel/validation';
15+
import { useMutation } from '@tanstack/react-query';
16+
import { path, mergeDeepRight } from 'ramda';
17+
import { Controller, useForm } from 'react-hook-form';
18+
import { toast } from 'sonner';
19+
import type { z } from 'zod';
20+
21+
type IForm = z.infer<typeof zCreateGCSExportIntegration>;
22+
23+
export function GCSExportIntegrationForm({
24+
defaultValues,
25+
onSuccess,
26+
}: {
27+
defaultValues?: RouterOutputs['integration']['get'];
28+
onSuccess: () => void;
29+
}) {
30+
const { projectId } = useAppParams();
31+
const form = useForm<IForm>({
32+
defaultValues: mergeDeepRight(
33+
{
34+
id: defaultValues?.id,
35+
projectId,
36+
name: '',
37+
config: {
38+
type: 'gcs_export' as const,
39+
bucket: '',
40+
prefix: 'openpanel-exports',
41+
format: 'jsonl_gzip' as const,
42+
serviceAccountKey: '',
43+
},
44+
},
45+
defaultValues ?? {},
46+
),
47+
resolver: zodResolver(zCreateGCSExportIntegration),
48+
});
49+
const trpc = useTRPC();
50+
const mutation = useMutation(
51+
trpc.integration.createOrUpdateExport.mutationOptions({
52+
onSuccess,
53+
onError(error) {
54+
toast.error(error.message || 'Failed to create integration');
55+
},
56+
}),
57+
);
58+
59+
const testMutation = useMutation(
60+
trpc.integration.testExportConnection.mutationOptions({
61+
onSuccess(data) {
62+
if (data.success) {
63+
toast.success('Connection successful! Bucket is accessible.');
64+
} else {
65+
toast.error(`Connection failed: ${data.error}`);
66+
}
67+
},
68+
onError(error) {
69+
toast.error(error.message || 'Failed to test connection');
70+
},
71+
}),
72+
);
73+
74+
const handleSubmit = (values: IForm) => {
75+
mutation.mutate(values);
76+
};
77+
78+
const handleError = () => {
79+
toast.error('Please fix validation errors');
80+
};
81+
82+
const handleTest = () => {
83+
const values = form.getValues();
84+
if (!values.config.bucket || !values.config.serviceAccountKey) {
85+
return toast.error('Bucket and Service Account Key are required');
86+
}
87+
testMutation.mutate(values);
88+
};
89+
90+
return (
91+
<form
92+
onSubmit={form.handleSubmit(handleSubmit, handleError)}
93+
className="col gap-4"
94+
>
95+
<InputWithLabel
96+
label="Name"
97+
placeholder="Eg. Production GCS Export"
98+
{...form.register('name')}
99+
error={form.formState.errors.name?.message}
100+
/>
101+
102+
<div className="grid grid-cols-2 gap-4">
103+
<InputWithLabel
104+
label="GCS Bucket"
105+
placeholder="my-analytics-bucket"
106+
{...form.register('config.bucket')}
107+
error={path(['config', 'bucket', 'message'], form.formState.errors)}
108+
/>
109+
<InputWithLabel
110+
label="Prefix"
111+
placeholder="openpanel-exports"
112+
{...form.register('config.prefix')}
113+
error={path(['config', 'prefix', 'message'], form.formState.errors)}
114+
/>
115+
</div>
116+
117+
<div className="col gap-1.5">
118+
<label className="text-sm font-medium">Format</label>
119+
<Controller
120+
name="config.format"
121+
control={form.control}
122+
render={({ field }) => (
123+
<Select onValueChange={field.onChange} value={field.value}>
124+
<SelectTrigger className="w-48">
125+
<SelectValue placeholder="Select format" />
126+
</SelectTrigger>
127+
<SelectContent>
128+
<SelectItem value="jsonl_gzip">JSONL (gzip)</SelectItem>
129+
<SelectItem value="parquet" disabled>
130+
Parquet (coming soon)
131+
</SelectItem>
132+
</SelectContent>
133+
</Select>
134+
)}
135+
/>
136+
</div>
137+
138+
<div className="col gap-1.5">
139+
<label className="text-sm font-medium">
140+
Service Account Key (JSON)
141+
</label>
142+
<textarea
143+
className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring min-h-32 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
144+
placeholder={
145+
defaultValues?.id
146+
? 'Leave blank to keep the current key'
147+
: '{"type": "service_account", "project_id": "...", ...}'
148+
}
149+
// Stored secrets are never sent back to the browser, so an edit starts
150+
// blank and blank means "keep the stored key".
151+
{...form.register('config.serviceAccountKey')}
152+
/>
153+
{!!path(
154+
['config', 'serviceAccountKey', 'message'],
155+
form.formState.errors,
156+
) && (
157+
<p className="text-destructive text-xs">
158+
{
159+
path(
160+
['config', 'serviceAccountKey', 'message'],
161+
form.formState.errors,
162+
) as any
163+
}
164+
</p>
165+
)}
166+
<p className="text-muted-foreground text-xs">
167+
Paste the contents of your GCS service account JSON key file (a
168+
document with <code>"type": "service_account"</code>). The service
169+
account needs write access to the specified bucket.
170+
</p>
171+
</div>
172+
173+
<div className="row gap-4">
174+
<Button
175+
type="button"
176+
variant="outline"
177+
onClick={handleTest}
178+
disabled={testMutation.isPending}
179+
>
180+
{testMutation.isPending ? 'Testing...' : 'Test connection'}
181+
</Button>
182+
<Button type="submit" className="flex-1" disabled={mutation.isPending}>
183+
{mutation.isPending
184+
? 'Saving...'
185+
: defaultValues?.id
186+
? 'Update'
187+
: 'Create'}
188+
</Button>
189+
</div>
190+
</form>
191+
);
192+
}

0 commit comments

Comments
 (0)