diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 1746167a565..133b495767e 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -250,6 +250,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 @@ -294,7 +295,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.API_ALIAS }} --scope=$VERCEL_ORG_ID --token=$VERCEL_TOKEN echo "vercel_url=$VERCEL_URL" >> $GITHUB_OUTPUT @@ -379,6 +381,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 +407,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 +478,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 +501,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 +587,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 +614,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 +673,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 +681,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..97c0506d4a3 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -110,6 +110,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 @@ -154,7 +155,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-web: name: Deploy Web to Vercel @@ -211,6 +213,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 +239,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 +294,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 +317,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 +376,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 +403,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 +499,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 +507,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..7c57e9ae99f 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -40,6 +40,7 @@ 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(), + 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/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..a6653b177ab 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,13 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +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 { useFeatureFlagEnabled } from "posthog-js/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 +20,16 @@ export function ProjectsSettings({ searchQuery }: ProjectsSettingsProps) { const { data: groups = [] } = electronTrpc.workspaces.getAllGrouped.useQuery(); const matchRoute = useMatchRoute(); - const [expandedProjects, setExpandedProjects] = useState>( - new Set(), - ); + const hasCloudAccess = useFeatureFlagEnabled(FEATURE_FLAGS.CLOUD_ACCESS); - // 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 +41,99 @@ 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/cloud/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx new file mode 100644 index 00000000000..bef14196c6e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/page.tsx @@ -0,0 +1,32 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; + +export const Route = createFileRoute( + "/_authenticated/settings/project/$projectId/cloud/", +)({ + component: CloudSettingsIndex, +}); + +function CloudSettingsIndex() { + const { projectId } = Route.useParams(); + const hasCloudAccess = useFeatureFlagEnabled(FEATURE_FLAGS.CLOUD_ACCESS); + + if (!hasCloudAccess) { + return ( + + ); + } + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/SecretsSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/SecretsSettings.tsx new file mode 100644 index 00000000000..8f30d50192c --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/SecretsSettings.tsx @@ -0,0 +1,184 @@ +import { Button } from "@superset/ui/button"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { 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 "../../../../components/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, + 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]); + + useEffect(() => { + if (suggestedMatch) { + linkToNeon.mutate({ + id: projectId, + neonProjectId: suggestedMatch.id, + }); + } + }, [suggestedMatch, linkToNeon.mutate, projectId]); + + 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." + > +
+

+ {linkToNeon.isPending + ? "Connecting..." + : "Not connected to a cloud project."} +

+ {!linkToNeon.isPending && ( + + )} +
+
+ )} +
+ + {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/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx new file mode 100644 index 00000000000..760e4e30c2f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/cloud/secrets/components/SecretsSettings/components/AddSecretSheet/AddSecretSheet.tsx @@ -0,0 +1,357 @@ +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, + }); + } + toast.success( + validEntries.length === 1 + ? `Added ${validEntries[0].key.trim()}` + : `Added ${validEntries.length} environment variables`, + ); + onSaved(); + onOpenChange(false); + } catch (err) { + console.error("[secrets/upsert] Failed to save:", err); + toast.error("Failed to save environment variables"); + } 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]" + /> +