From e1669f10718e2f7094ca9d5e72c7e4ccc569732d Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 10 Feb 2026 12:00:29 -0800 Subject: [PATCH 1/7] WIP --- .github/workflows/deploy-preview.yml | 22 +- .github/workflows/deploy-production.yml | 22 +- .../src/app/api/electric/[...path]/utils.ts | 8 +- apps/api/src/env.ts | 2 + .../src/lib/trpc/routers/projects/projects.ts | 11 + .../hooks/useNeonProjectLink/index.ts | 1 + .../useNeonProjectLink/useNeonProjectLink.ts | 61 +++ .../renderer/routes/_authenticated/layout.tsx | 7 + .../CollectionsProvider/collections.ts | 24 +- .../SettingsSidebar/ProjectsSettings.tsx | 141 +++---- .../routes/_authenticated/settings/layout.tsx | 5 +- .../ProjectSettings/ProjectSettings.tsx | 2 +- .../components/ProjectSettings/index.ts | 2 +- .../SecretsSettings/SecretsSettings.tsx | 209 +++++++++++ .../AddSecretSheet/AddSecretSheet.tsx | 351 ++++++++++++++++++ .../components/AddSecretSheet/index.ts | 1 + .../EditSecretDialog/EditSecretDialog.tsx | 124 +++++++ .../components/EditSecretDialog/index.ts | 1 + .../EnvironmentVariablesList.tsx | 104 ++++++ .../components/SecretRow/SecretRow.tsx | 140 +++++++ .../components/SecretRow/index.ts | 1 + .../EnvironmentVariablesList/index.ts | 1 + .../components/SecretsSettings/index.ts | 1 + .../utils/env-file/env-file.ts | 91 +++++ .../SecretsSettings/utils/env-file/index.ts | 1 + .../project/$projectId/general/page.tsx | 49 +++ .../settings/project/$projectId/page.tsx | 53 +-- .../project/$projectId/secrets/page.tsx | 35 ++ .../utils/settings-search/settings-search.ts | 34 -- .../settings/workspace/$workspaceId/page.tsx | 128 ------- .../src/renderer/stores/settings-state.ts | 15 +- apps/mobile/lib/collections/collections.ts | 24 +- packages/db/drizzle/0018_sandbox_start.sql | 74 ++++ packages/db/src/schema/enums.ts | 20 + packages/db/src/schema/index.ts | 1 + packages/db/src/schema/relations.ts | 77 +++- packages/db/src/schema/schema.ts | 159 ++++++-- packages/db/src/schema/zod.ts | 34 ++ .../0025_add_neon_project_id_to_projects.sql | 1 + packages/local-db/drizzle/meta/_journal.json | 7 + packages/local-db/src/schema/schema.ts | 1 + packages/trpc/src/env.ts | 2 + packages/trpc/src/lib/crypto.ts | 41 ++ packages/trpc/src/lib/secrets-validation.ts | 55 +++ packages/trpc/src/root.ts | 6 +- .../src/router/organization/organization.ts | 4 +- packages/trpc/src/router/project/index.ts | 1 + packages/trpc/src/router/project/project.ts | 93 +++++ packages/trpc/src/router/project/secrets.ts | 137 +++++++ packages/trpc/src/router/repository/index.ts | 1 - .../trpc/src/router/repository/repository.ts | 123 ------ packages/trpc/src/router/task/schema.ts | 4 +- packages/trpc/src/router/task/task.ts | 8 - packages/trpc/src/router/workspace/index.ts | 1 + .../trpc/src/router/workspace/workspace.ts | 56 +++ 55 files changed, 2032 insertions(+), 545 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/useNeonProjectLink.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/SecretsSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/AddSecretSheet/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/EditSecretDialog/EditSecretDialog.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/EditSecretDialog/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/EnvironmentVariablesList/EnvironmentVariablesList.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/EnvironmentVariablesList/components/SecretRow/SecretRow.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/EnvironmentVariablesList/components/SecretRow/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/EnvironmentVariablesList/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/utils/env-file/env-file.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/utils/env-file/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/general/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/secrets/page.tsx delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/workspace/$workspaceId/page.tsx create mode 100644 packages/db/drizzle/0018_sandbox_start.sql create mode 100644 packages/db/src/schema/zod.ts create mode 100644 packages/local-db/drizzle/0025_add_neon_project_id_to_projects.sql create mode 100644 packages/trpc/src/lib/crypto.ts create mode 100644 packages/trpc/src/lib/secrets-validation.ts create mode 100644 packages/trpc/src/router/project/index.ts create mode 100644 packages/trpc/src/router/project/project.ts create mode 100644 packages/trpc/src/router/project/secrets.ts delete mode 100644 packages/trpc/src/router/repository/index.ts delete mode 100644 packages/trpc/src/router/repository/repository.ts create mode 100644 packages/trpc/src/router/workspace/index.ts create mode 100644 packages/trpc/src/router/workspace/workspace.ts diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 1746167a565..b2e8877baa6 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -250,6 +250,8 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + STREAMS_URL: ${{ env.STREAMS_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -294,7 +296,9 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL) + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env STREAMS_URL=$STREAMS_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY) vercel alias $VERCEL_URL ${{ env.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -379,6 +383,7 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -404,7 +409,8 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL) + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY) vercel alias $VERCEL_URL ${{ env.WEB_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -474,6 +480,7 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -496,7 +503,8 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL) + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY) vercel alias $VERCEL_URL ${{ env.MARKETING_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -581,6 +589,7 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -607,7 +616,8 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL) + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY) vercel alias $VERCEL_URL ${{ env.ADMIN_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -665,6 +675,7 @@ jobs: NEXT_PUBLIC_SENTRY_DSN_DOCS: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_DOCS }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=preview --token=$VERCEL_TOKEN vercel build --token=$VERCEL_TOKEN @@ -672,7 +683,8 @@ jobs: --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env NEXT_PUBLIC_SENTRY_DSN_DOCS=$NEXT_PUBLIC_SENTRY_DSN_DOCS \ - --env NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT) + --env NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY) vercel alias $VERCEL_URL ${{ env.DOCS_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 687269d4cc9..a2740cccd6d 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -110,6 +110,8 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + STREAMS_URL: ${{ secrets.STREAMS_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -154,7 +156,9 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env STREAMS_URL=$STREAMS_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY deploy-web: name: Deploy Web to Vercel @@ -211,6 +215,7 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -236,7 +241,8 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY deploy-marketing: name: Deploy Marketing to Vercel @@ -290,6 +296,7 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -312,7 +319,8 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY deploy-admin: name: Deploy Admin to Vercel @@ -370,6 +378,7 @@ jobs: STRIPE_PRO_MONTHLY_PRICE_ID: ${{ secrets.STRIPE_PRO_MONTHLY_PRICE_ID }} STRIPE_PRO_YEARLY_PRICE_ID: ${{ secrets.STRIPE_PRO_YEARLY_PRICE_ID }} SLACK_BILLING_WEBHOOK_URL: ${{ secrets.SLACK_BILLING_WEBHOOK_URL }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -396,7 +405,8 @@ jobs: --env STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET \ --env STRIPE_PRO_MONTHLY_PRICE_ID=$STRIPE_PRO_MONTHLY_PRICE_ID \ --env STRIPE_PRO_YEARLY_PRICE_ID=$STRIPE_PRO_YEARLY_PRICE_ID \ - --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL + --env SLACK_BILLING_WEBHOOK_URL=$SLACK_BILLING_WEBHOOK_URL \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY deploy-streams: name: Deploy Streams to Fly.io @@ -491,6 +501,7 @@ jobs: NEXT_PUBLIC_SENTRY_DSN_DOCS: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN_DOCS }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} NEXT_PUBLIC_SENTRY_ENVIRONMENT: ${{ vars.NEXT_PUBLIC_SENTRY_ENVIRONMENT }} + SECRETS_ENCRYPTION_KEY: ${{ secrets.SECRETS_ENCRYPTION_KEY }} run: | vercel pull --yes --environment=production --token=$VERCEL_TOKEN vercel build --prod --token=$VERCEL_TOKEN @@ -498,4 +509,5 @@ jobs: --env NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ --env NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ --env NEXT_PUBLIC_SENTRY_DSN_DOCS=$NEXT_PUBLIC_SENTRY_DSN_DOCS \ - --env NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT + --env NEXT_PUBLIC_SENTRY_ENVIRONMENT=$NEXT_PUBLIC_SENTRY_ENVIRONMENT \ + --env SECRETS_ENCRYPTION_KEY=$SECRETS_ENCRYPTION_KEY diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index 280551d59fb..5046f249d38 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -6,7 +6,7 @@ import { invitations, members, organizations, - repositories, + projects, subscriptions, taskStatuses, tasks, @@ -18,7 +18,7 @@ import { QueryBuilder } from "drizzle-orm/pg-core"; export type AllowedTable = | "tasks" | "task_statuses" - | "repositories" + | "projects" | "auth.members" | "auth.organizations" | "auth.users" @@ -58,8 +58,8 @@ export async function buildWhereClause( case "task_statuses": return build(taskStatuses, taskStatuses.organizationId, organizationId); - case "repositories": - return build(repositories, repositories.organizationId, organizationId); + case "projects": + return build(projects, projects.organizationId, organizationId); case "auth.members": return build(members, members.organizationId, organizationId); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a1116de55c2..4066e04aad0 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -40,6 +40,8 @@ export const env = createEnv({ STRIPE_PRO_MONTHLY_PRICE_ID: z.string(), STRIPE_PRO_YEARLY_PRICE_ID: z.string(), SLACK_BILLING_WEBHOOK_URL: z.string().url(), + STREAMS_URL: z.string().url(), + SECRETS_ENCRYPTION_KEY: z.string().min(1), SENTRY_AUTH_TOKEN: z.string().optional(), }, client: { diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 1913026a10b..107b5e1a996 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -1001,6 +1001,17 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { return { success: true, terminalWarning }; }), + linkToNeon: publicProcedure + .input(z.object({ id: z.string(), neonProjectId: z.string() })) + .mutation(({ input }) => { + localDb + .update(projects) + .set({ neonProjectId: input.neonProjectId }) + .where(eq(projects.id, input.id)) + .run(); + return { success: true }; + }), + getGitHubAvatar: publicProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/index.ts new file mode 100644 index 00000000000..7f575906893 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/index.ts @@ -0,0 +1 @@ +export { useNeonProjectLink } from "./useNeonProjectLink"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/useNeonProjectLink.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/useNeonProjectLink.ts new file mode 100644 index 00000000000..e24b12b0df4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useNeonProjectLink/useNeonProjectLink.ts @@ -0,0 +1,61 @@ +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useEffect, useRef } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "../../providers/CollectionsProvider"; + +export function useNeonProjectLink() { + const collections = useCollections(); + const utils = electronTrpc.useUtils(); + const linkMutation = electronTrpc.projects.linkToNeon.useMutation({ + onSuccess: () => { + utils.projects.getRecents.invalidate(); + }, + }); + + const { data: localProjects } = electronTrpc.projects.getRecents.useQuery(); + + const { data: cloudProjects } = useLiveQuery( + (q) => + q.from({ projects: collections.projects }).select(({ projects }) => ({ + id: projects.id, + repoOwner: projects.repoOwner, + repoName: projects.repoName, + })), + [collections.projects], + ); + + const linkingRef = useRef(new Set()); + + const linkProjects = useCallback(() => { + if (!localProjects || !cloudProjects) return; + + for (const local of localProjects) { + if (local.neonProjectId || !local.githubOwner) continue; + if (linkingRef.current.has(local.id)) continue; + + const repoName = local.mainRepoPath.split("/").pop(); + if (!repoName) continue; + + const match = cloudProjects.find( + (cloud) => + cloud.repoOwner === local.githubOwner && cloud.repoName === repoName, + ); + + if (match) { + linkingRef.current.add(local.id); + linkMutation.mutate( + { id: local.id, neonProjectId: match.id }, + { + onError: () => { + linkingRef.current.delete(local.id); + }, + }, + ); + } + } + }, [localProjects, cloudProjects, linkMutation]); + + useEffect(() => { + linkProjects(); + }, [linkProjects]); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 48bbf8ac67d..92f547c97d3 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -23,6 +23,7 @@ import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; +import { useNeonProjectLink } from "./hooks/useNeonProjectLink"; import { CollectionsProvider } from "./providers/CollectionsProvider"; export const Route = createFileRoute("/_authenticated")({ @@ -113,6 +114,7 @@ function AuthenticatedLayout() { + @@ -122,3 +124,8 @@ function AuthenticatedLayout() { ); } + +function NeonProjectLinker() { + useNeonProjectLink(); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index 6055fda0526..8171a68c2ad 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -6,7 +6,7 @@ import type { SelectInvitation, SelectMember, SelectOrganization, - SelectRepository, + SelectProject, SelectSubscription, SelectTask, SelectTaskStatus, @@ -38,7 +38,7 @@ type ApiKeyDisplay = z.infer; interface OrgCollections { tasks: Collection; taskStatuses: Collection; - repositories: Collection; + projects: Collection; members: Collection; users: Collection; invitations: Collection; @@ -142,29 +142,19 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - const repositories = createCollection( - electricCollectionOptions({ - id: `repositories-${organizationId}`, + const projects = createCollection( + electricCollectionOptions({ + id: `projects-${organizationId}`, shapeOptions: { url: electricUrl, params: { - table: "repositories", + table: "projects", organizationId, }, headers, columnMapper, }, getKey: (item) => item.id, - onInsert: async ({ transaction }) => { - const item = transaction.mutations[0].modified; - const result = await apiClient.repository.create.mutate(item); - return { txid: result.txid }; - }, - onUpdate: async ({ transaction }) => { - const { modified } = transaction.mutations[0]; - const result = await apiClient.repository.update.mutate(modified); - return { txid: result.txid }; - }, }), ); @@ -315,7 +305,7 @@ function createOrgCollections(organizationId: string): OrgCollections { return { tasks, taskStatuses, - repositories, + projects, members, users, invitations, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx index 0bcdccad945..23c1a3e77aa 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/ProjectsSettings.tsx @@ -1,6 +1,11 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; import { cn } from "@superset/ui/utils"; import { Link, useMatchRoute } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { HiChevronDown, HiChevronRight } from "react-icons/hi2"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { getMatchCountBySection } from "../../utils/settings-search"; @@ -13,34 +18,15 @@ export function ProjectsSettings({ searchQuery }: ProjectsSettingsProps) { const { data: groups = [] } = electronTrpc.workspaces.getAllGrouped.useQuery(); const matchRoute = useMatchRoute(); - const [expandedProjects, setExpandedProjects] = useState>( - new Set(), - ); - // Check if project/workspace sections have matches during search const matchCounts = useMemo(() => { if (!searchQuery) return null; return getMatchCountBySection(searchQuery); }, [searchQuery]); const hasProjectMatches = (matchCounts?.project ?? 0) > 0; - const hasWorkspaceMatches = (matchCounts?.workspace ?? 0) > 0; - const hasAnyMatches = hasProjectMatches || hasWorkspaceMatches; - - const toggleProject = (projectId: string) => { - setExpandedProjects((prev) => { - const next = new Set(prev); - if (next.has(projectId)) { - next.delete(projectId); - } else { - next.add(projectId); - } - return next; - }); - }; - // Hide projects section when searching and no matches - if (searchQuery && !hasAnyMatches) { + if (searchQuery && !hasProjectMatches) { return null; } @@ -52,89 +38,74 @@ export function ProjectsSettings({ searchQuery }: ProjectsSettingsProps) {

Projects - {searchQuery && hasAnyMatches && ( + {searchQuery && hasProjectMatches && ( - {(matchCounts?.project ?? 0) + (matchCounts?.workspace ?? 0)} + {matchCounts?.project ?? 0} )}

diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx index cad16e48e10..0b9582593ae 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/layout.tsx @@ -40,7 +40,6 @@ function getSectionFromPath(pathname: string): SettingsSection | null { if (pathname.includes("/settings/terminal")) return "terminal"; if (pathname.includes("/settings/integrations")) return "integrations"; if (pathname.includes("/settings/project")) return "project"; - if (pathname.includes("/settings/workspace")) return "workspace"; return null; } @@ -82,8 +81,8 @@ function SettingsLayout() { const currentSection = getSectionFromPath(location.pathname); if (!currentSection) return; - // Don't auto-navigate from project/workspace pages - if (currentSection === "project" || currentSection === "workspace") return; + // Don't auto-navigate from project pages + if (currentSection === "project") return; const matchCounts = getMatchCountBySection(searchQuery); const currentHasMatches = (matchCounts[currentSection] ?? 0) > 0; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index 7b38abc7bc2..1620f51f84e 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -24,7 +24,7 @@ import { ClickablePath } from "../../../../components/ClickablePath"; import { BRANCH_PREFIX_MODE_LABELS_WITH_DEFAULT } from "../../../../utils/branch-prefix"; import { ScriptsEditor } from "./components/ScriptsEditor"; -function SettingsSection({ +export function SettingsSection({ icon, title, description, diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/index.ts index 46b8b1f5e83..a9af85e51d6 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/index.ts @@ -1 +1 @@ -export { ProjectSettings } from "./ProjectSettings"; +export { ProjectSettings, SettingsSection } from "./ProjectSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/SecretsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/SecretsSettings.tsx new file mode 100644 index 00000000000..66fd4fc19ca --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/SecretsSettings.tsx @@ -0,0 +1,209 @@ +import { Button } from "@superset/ui/button"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo, useState } from "react"; +import { HiOutlineCheck, HiOutlineCloud } from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { SettingsSection } from "../ProjectSettings"; +import { AddSecretSheet } from "./components/AddSecretSheet"; +import { EditSecretDialog } from "./components/EditSecretDialog"; +import { EnvironmentVariablesList } from "./components/EnvironmentVariablesList"; + +interface SecretsSettingsProps { + projectId: string; +} + +interface EditingSecret { + id: string; + key: string; + value: string; + sensitive: boolean; +} + +export function SecretsSettings({ projectId }: SecretsSettingsProps) { + const utils = electronTrpc.useUtils(); + const collections = useCollections(); + const { data: project } = electronTrpc.projects.get.useQuery({ + id: projectId, + }); + + const linkToNeon = electronTrpc.projects.linkToNeon.useMutation({ + onSettled: () => { + utils.projects.get.invalidate({ id: projectId }); + utils.projects.getRecents.invalidate(); + }, + }); + + const { data: cloudProjects } = useLiveQuery( + (q) => + q.from({ projects: collections.projects }).select(({ projects }) => ({ + id: projects.id, + name: projects.name, + repoOwner: projects.repoOwner, + repoName: projects.repoName, + })), + [collections.projects], + ); + + const suggestedMatch = useMemo(() => { + if (!project || project.neonProjectId || !cloudProjects) return null; + const repoName = project.mainRepoPath.split("/").pop(); + if (!repoName || !project.githubOwner) return null; + return cloudProjects.find( + (cloud) => + cloud.repoOwner === project.githubOwner && cloud.repoName === repoName, + ); + }, [project, cloudProjects]); + + const linkedCloudProject = useMemo(() => { + if (!project?.neonProjectId || !cloudProjects) return null; + return cloudProjects.find((c) => c.id === project.neonProjectId); + }, [project?.neonProjectId, cloudProjects]); + + const { data: session } = authClient.useSession(); + const organizationId = session?.session?.activeOrganizationId; + const [isCreatingCloud, setIsCreatingCloud] = useState(false); + const [isAddSheetOpen, setIsAddSheetOpen] = useState(false); + const [editingSecret, setEditingSecret] = useState( + null, + ); + const [refreshKey, setRefreshKey] = useState(0); + + const handleCreateCloudProject = useCallback(async () => { + if (!project || !organizationId || !project.githubOwner) return; + const repoName = project.mainRepoPath.split("/").pop(); + if (!repoName) return; + + setIsCreatingCloud(true); + try { + const cloudProject = await apiTrpcClient.project.create.mutate({ + organizationId, + name: project.name, + slug: repoName.toLowerCase(), + repoOwner: project.githubOwner, + repoName, + repoUrl: `https://github.com/${project.githubOwner}/${repoName}`, + }); + linkToNeon.mutate({ + id: projectId, + neonProjectId: cloudProject.id, + }); + } catch (err) { + console.error("[project-settings] Failed to create cloud project:", err); + } finally { + setIsCreatingCloud(false); + } + }, [project, organizationId, linkToNeon, projectId]); + + const handleSaved = () => { + setRefreshKey((k) => k + 1); + }; + + if (!project) { + return null; + } + + const isConnected = !!project.neonProjectId && !!linkedCloudProject; + + return ( +
+
+

Environment Variables

+
+ +
+ {isConnected && organizationId && project.neonProjectId ? ( + setIsAddSheetOpen(true)} + onEdit={setEditingSecret} + /> + ) : ( + } + title="Cloud Project" + description="Link this project to a cloud project for sandboxes and environment variables." + > + {linkedCloudProject ? ( +
+
+ + + Connected to{" "} + + {linkedCloudProject.repoOwner}/ + {linkedCloudProject.repoName} + + +
+
+ ) : suggestedMatch ? ( +
+

+ Found matching cloud project:{" "} + + {suggestedMatch.repoOwner}/{suggestedMatch.repoName} + +

+ +
+ ) : ( +
+

+ Not connected to a cloud project. +

+ +
+ )} +
+ )} +
+ + {organizationId && ( + + )} + + {organizationId && editingSecret && ( + { + if (!open) setEditingSecret(null); + }} + projectId={project.neonProjectId ?? ""} + organizationId={organizationId} + secret={editingSecret} + onSaved={handleSaved} + /> + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx new file mode 100644 index 00000000000..d997a610fa0 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx @@ -0,0 +1,351 @@ +import { alert } from "@superset/ui/atoms/Alert"; +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@superset/ui/sheet"; +import { toast } from "@superset/ui/sonner"; +import { Switch } from "@superset/ui/switch"; +import { Textarea } from "@superset/ui/textarea"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + HiOutlineArrowDownTray, + HiOutlineQuestionMarkCircle, + HiOutlineTrash, + HiPlus, +} from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { parseEnvContent, validateEnvContent } from "../../utils/env-file"; + +interface SecretEntry { + id: string; + key: string; + value: string; +} + +let entryIdCounter = 0; +function nextEntryId() { + return `entry-${++entryIdCounter}`; +} + +interface AddSecretSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + projectId: string; + organizationId: string; + onSaved: () => void; +} + +function createEmptyEntry(): SecretEntry { + return { id: nextEntryId(), key: "", value: "" }; +} + +function toSecretEntries( + entries: { key: string; value: string }[], +): SecretEntry[] { + return entries.map((e) => ({ + id: nextEntryId(), + key: e.key, + value: e.value, + })); +} + +export function AddSecretSheet({ + open, + onOpenChange, + projectId, + organizationId, + onSaved, +}: AddSecretSheetProps) { + const [entries, setEntries] = useState([createEmptyEntry()]); + const [sensitive, setSensitive] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const fileInputRef = useRef(null); + + const hasContent = entries.some((e) => e.key.trim() || e.value.trim()); + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && hasContent) { + alert.destructive({ + title: "Discard unsaved changes?", + description: + "You have unsaved environment variables. Are you sure you want to close?", + confirmText: "Discard", + onConfirm: () => onOpenChange(false), + }); + return; + } + onOpenChange(nextOpen); + }; + + useEffect(() => { + if (open) { + setEntries([createEmptyEntry()]); + setSensitive(true); + } + }, [open]); + + const updateEntry = ( + index: number, + field: keyof SecretEntry, + value: string | boolean, + ) => { + setEntries((prev) => { + const updated = [...prev]; + updated[index] = { ...updated[index], [field]: value }; + return updated; + }); + }; + + const removeEntry = (index: number) => { + setEntries((prev) => { + if (prev.length <= 1) return [createEmptyEntry()]; + return prev.filter((_, i) => i !== index); + }); + }; + + const addEntry = () => { + setEntries((prev) => [...prev, createEmptyEntry()]); + }; + + const handleKeyPaste = ( + index: number, + e: React.ClipboardEvent, + ) => { + const pasted = e.clipboardData.getData("text"); + if (pasted.includes("=") && pasted.includes("\n")) { + e.preventDefault(); + const parsed = toSecretEntries(parseEnvContent(pasted)); + if (parsed.length > 0) { + setEntries((prev) => { + const before = prev.slice(0, index); + const after = prev.slice(index + 1); + return [...before, ...parsed, ...after]; + }); + } + } + }; + + const handleFileImport = useCallback((content: string) => { + const parsed = toSecretEntries(parseEnvContent(content)); + if (parsed.length === 0) { + toast.error("No valid environment variables found in file"); + return; + } + setEntries((prev) => { + const hasExisting = prev.some((e) => e.key || e.value); + return hasExisting ? [...prev, ...parsed] : parsed; + }); + }, []); + + const MAX_FILE_SIZE = 256 * 1024; // 256 KB + + const validateAndReadFile = useCallback( + (file: File) => { + if (file.size > MAX_FILE_SIZE) { + toast.error("File too large. Maximum size is 256 KB."); + return; + } + + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result; + if (typeof text !== "string") return; + + const validation = validateEnvContent(text); + if (!validation.ok) { + toast.error(validation.error); + return; + } + + handleFileImport(text); + }; + reader.onerror = () => { + toast.error("Failed to read file."); + }; + reader.readAsText(file); + }, + [handleFileImport], + ); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) { + validateAndReadFile(file); + } + }, + [validateAndReadFile], + ); + + const handleFileInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + validateAndReadFile(file); + } + e.target.value = ""; + }; + + const handleSave = async () => { + const validEntries = entries.filter((e) => e.key.trim() && e.value.trim()); + if (validEntries.length === 0) return; + + setIsSaving(true); + try { + for (const entry of validEntries) { + await apiTrpcClient.project.secrets.upsert.mutate({ + projectId, + organizationId, + key: entry.key.trim(), + value: entry.value.trim(), + sensitive, + }); + } + onSaved(); + onOpenChange(false); + } catch (err) { + console.error("[secrets/upsert] Failed to save:", err); + } finally { + setIsSaving(false); + } + }; + + const hasValidEntries = entries.some((e) => e.key.trim() && e.value.trim()); + + return ( + + { + e.preventDefault(); + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDrop} + > + + Add Environment Variable + + Add one or more environment variables. You can also drag and drop a + .env file. + + + +
+
+ {/* Column headers */} +
+ + Key + + + Value + + {/* spacer for trash button */} +
+
+ + {entries.map((entry, index) => ( +
+ updateEntry(index, "key", e.target.value)} + onPaste={(e) => handleKeyPaste(index, e)} + className="flex-1 font-mono text-sm mt-[1px]" + /> +