diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..5578de126 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "command": "pnpm run dev --filter webapp", + "name": "Run webapp", + "request": "launch", + "type": "node-terminal", + "cwd": "${workspaceFolder}" + }, + { + "type": "chrome", + "request": "launch", + "name": "Chrome webapp", + "url": "http://localhost:3000", + "webRoot": "${workspaceFolder}/apps/webapp/app" + } + ] +} diff --git a/apps/webapp/app/assets/images/integrations/github.png b/apps/webapp/app/assets/images/integrations/github.png new file mode 100644 index 000000000..6cb3b705d Binary files /dev/null and b/apps/webapp/app/assets/images/integrations/github.png differ diff --git a/apps/webapp/app/components/navigation/OrganizationMenu.tsx b/apps/webapp/app/components/navigation/OrganizationMenu.tsx index d2ebff757..4d63bfa1d 100644 --- a/apps/webapp/app/components/navigation/OrganizationMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationMenu.tsx @@ -1,17 +1,15 @@ -import { Organization } from ".prisma/client"; import { Popover, Transition } from "@headlessui/react"; import { BookmarkIcon, - BriefcaseIcon, CheckIcon, ChevronDownIcon, PlusIcon, } from "@heroicons/react/24/solid"; import { Link } from "@remix-run/react"; import classNames from "classnames"; -import React, { Fragment } from "react"; +import { Fragment } from "react"; import { - useCurrentOrganizationSlug, + useCurrentOrganization, useOrganizations, } from "~/hooks/useOrganizations"; @@ -19,11 +17,7 @@ const actionClassNames = "text-green-500"; export function OrganizationMenu() { const organizations = useOrganizations(); - const currentOrganizationSlug = useCurrentOrganizationSlug(); - - const currentOrganization = organizations?.find( - (org) => org.slug === currentOrganizationSlug - ); + const currentOrganization = useCurrentOrganization(); if (organizations === undefined) { return null; @@ -74,7 +68,7 @@ export function OrganizationMenu() { to={`/orgs/${organization.slug}`} className={classNames( "flex items-center justify-between gap-1.5 mx-1 px-3 py-2 text-slate-600 rounded hover:bg-slate-100 transition", - organization.slug === currentOrganizationSlug && + organization.slug === currentOrganization?.slug && "!bg-slate-200" )} > @@ -87,7 +81,7 @@ export function OrganizationMenu() { {organization.title} - {organization.slug === currentOrganizationSlug && ( + {organization.slug === currentOrganization?.slug && ( )} diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index e88fe8f13..5deecccff 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -24,6 +24,7 @@ const EnvironmentSchema = z.object({ PRIMARY_REGION: z.string().optional(), FLY_REGION: z.string().optional(), SESSION_SECRET: z.string(), + PIZZLY_HOST: z.string(), }); export type Environment = z.infer; diff --git a/apps/webapp/app/hooks/useOrganizations.ts b/apps/webapp/app/hooks/useOrganizations.ts index c7918c758..63d55fb82 100644 --- a/apps/webapp/app/hooks/useOrganizations.ts +++ b/apps/webapp/app/hooks/useOrganizations.ts @@ -27,3 +27,13 @@ export function useCurrentOrganizationSlug(): string | undefined { const routeMatch = useMatchesData("routes/__app/orgs/$organizationSlug"); return routeMatch?.params?.organizationSlug; } + +export function useCurrentOrganization(): Organization | undefined { + const organizations = useOrganizations(); + const currentOrganizationSlug = useCurrentOrganizationSlug(); + + const currentOrganization = organizations?.find( + (org) => org.slug === currentOrganizationSlug + ); + return currentOrganization; +} diff --git a/apps/webapp/app/models/apiConnection.server.ts b/apps/webapp/app/models/apiConnection.server.ts new file mode 100644 index 000000000..f8c0262ae --- /dev/null +++ b/apps/webapp/app/models/apiConnection.server.ts @@ -0,0 +1,26 @@ +import type { APIConnection, Organization } from ".prisma/client"; +import { prisma } from "~/db.server"; + +export async function createAPIConnection({ + organizationId, + title, + apiIdentifier, + scopes, + type, +}: Pick & { + organizationId: Organization["id"]; +}) { + return await prisma.aPIConnection.create({ + data: { + title, + apiIdentifier, + type, + scopes, + organization: { + connect: { + id: organizationId, + }, + }, + }, + }); +} diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/integrations.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/integrations.tsx index d6ca211b4..529dbd434 100644 --- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/integrations.tsx +++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/integrations.tsx @@ -1,29 +1,24 @@ -import { useCallback } from "react"; -const Pizzly = require("@nangohq/pizzly-frontend"); +import invariant from "tiny-invariant"; +import { useCurrentOrganization } from "~/hooks/useOrganizations"; +import { Connect, integrations } from "~/routes/resources/connection"; export default function Integrations() { - const authenticateWithGitHub = useCallback(async () => { - const pizzly = new Pizzly("http://localhost:3004"); - pizzly - .auth("github", "test-connection") - .then((result: any) => { - console.log( - `OAuth flow succeeded for provider "${result.providerConfigKey}" and connection-id "${result.connectionId}"!` - ); - }) - .catch((error: any) => { - console.error( - `There was an error in the OAuth flow for integration "${error.providerConfigKey}" and connection-id "${error.connectionId}": ${error.error.type} - ${error.error.message}` - ); - }); - }, []); + const organization = useCurrentOrganization(); + invariant(organization, "Organization not found"); return (

Integrations

- + +
+ {integrations.map((integration) => ( + + ))} +
); } diff --git a/apps/webapp/app/routes/resources/connection.tsx b/apps/webapp/app/routes/resources/connection.tsx new file mode 100644 index 000000000..0ad5682f2 --- /dev/null +++ b/apps/webapp/app/routes/resources/connection.tsx @@ -0,0 +1,159 @@ +import githubLogo from "../../assets/images/integrations/github.png"; +import { useCallback, useEffect } from "react"; +import Pizzly from "@nangohq/pizzly-frontend"; +import { ActionArgs, json } from "@remix-run/server-runtime"; +import { requireUserId } from "~/services/session.server"; +import { env } from "~/env.server"; +import { z } from "zod"; +import { createAPIConnection } from "~/models/apiConnection.server"; +import { APIConnectionType } from ".prisma/client"; +import { useFetcher } from "@remix-run/react"; + +type Integration = { + key: string; + name: string; + logo: string; +}; + +export const integrations: Integration[] = [ + { + key: "github", + name: "GitHub", + logo: githubLogo, + }, +]; + +const createSchema = z.object({ + type: z.literal("create"), + organizationId: z.string(), + key: z.string(), +}); + +const updateSchema = z.object({ + type: z.literal("update"), + connectionId: z.string(), + externalId: z.number(), +}); +const requestSchema = z.discriminatedUnion("type", [ + createSchema, + updateSchema, +]); + +type CreateResponse = { + host: string; + integrationKey: string; + connectionId: string; +}; + +export const action = async ({ request, params }: ActionArgs) => { + const userId = await requireUserId(request); + if (userId === null) { + throw new Response("Unauthorized", { status: 401 }); + } + + if (request.method !== "POST") { + throw new Response("Method Not Allowed", { status: 405 }); + } + + try { + const formData = await request.formData(); + const body = Object.fromEntries(formData.entries()); + const parsed = requestSchema.parse(body); + + switch (parsed.type) { + case "create": { + const { organizationId, key } = parsed; + + const integrationInfo = integrations.find((i) => i.key === key); + if (!integrationInfo) { + throw new Error("Integration not found"); + } + + const connection = await createAPIConnection({ + organizationId, + title: integrationInfo.name, + apiIdentifier: key, + scopes: [], + type: APIConnectionType.HTTP, + }); + + const response: CreateResponse = { + host: env.PIZZLY_HOST, + integrationKey: key, + connectionId: connection.id, + }; + + return json(response); + } + case "update": { + const { connectionId, externalId } = parsed; + return {}; + } + } + } catch (error: any) { + return json({ message: error.message }, { status: 400 }); + } +}; + +export function Connect({ + integration, + organizationId, +}: { + integration: Integration; + organizationId: string; +}) { + const { createFetcher, status } = useCreateConnection(); + + return ( + + + + + + + ); +} + +type Status = "loading" | "idle"; + +export function useCreateConnection() { + const createConnectionFetcher = useFetcher(); + const status: Status = + createConnectionFetcher.state === "loading" ? "loading" : "idle"; + + useEffect(() => { + async function authenticationFlow() { + if (createConnectionFetcher.data === undefined) return; + + try { + const pizzly = new Pizzly(createConnectionFetcher.data.host); + + const result = await pizzly.auth( + createConnectionFetcher.data.integrationKey, + createConnectionFetcher.data.connectionId + ); + console.log( + `OAuth flow succeeded for provider "${result.providerConfigKey}" and connection-id "${result.connectionId}"!` + ); + } catch (error: any) { + console.error( + `There was an error in the OAuth flow for integration "${error.providerConfigKey}" and connection-id "${error.connectionId}": ${error.error.type} - ${error.error.message}` + ); + } + } + + authenticationFlow(); + }, [createConnectionFetcher.data]); + + return { + createFetcher: createConnectionFetcher, + status, + }; +} diff --git a/apps/webapp/prisma/migrations/20221212112045_api_connections/migration.sql b/apps/webapp/prisma/migrations/20221212112045_api_connections/migration.sql new file mode 100644 index 000000000..058a66c44 --- /dev/null +++ b/apps/webapp/prisma/migrations/20221212112045_api_connections/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "APIConnectionType" AS ENUM ('HTTP', 'GRAPHQL'); + +-- CreateTable +CREATE TABLE "APIConnection" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "apiIdentifier" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "scopes" TEXT[], + "type" "APIConnectionType" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "APIConnection_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "APIConnection" ADD CONSTRAINT "APIConnection_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/webapp/prisma/migrations/20221212134846_api_optional_external_id/migration.sql b/apps/webapp/prisma/migrations/20221212134846_api_optional_external_id/migration.sql new file mode 100644 index 000000000..6eb12f42c --- /dev/null +++ b/apps/webapp/prisma/migrations/20221212134846_api_optional_external_id/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "APIConnection" ALTER COLUMN "externalId" DROP NOT NULL; diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma index f4845b483..1c8b9c190 100644 --- a/apps/webapp/prisma/schema.prisma +++ b/apps/webapp/prisma/schema.prisma @@ -46,6 +46,28 @@ model Organization { users User[] workflows Workflow[] environments RuntimeEnvironment[] + apiConnections APIConnection[] +} + +model APIConnection { + id String @id @default(cuid()) + title String + + apiIdentifier String + externalId String? + scopes String[] + type APIConnectionType + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String +} + +enum APIConnectionType { + HTTP + GRAPHQL } model RuntimeEnvironment { @@ -75,3 +97,7 @@ model Workflow { @@unique([organizationId, slug]) } + +//todo Workflows have connection slots, which are filled with connections (can be empty) +//todo Workflow has one trigger (can also have a slot with connection) +//todo WorkflowRuns belong to a workflow + environment \ No newline at end of file diff --git a/turbo.json b/turbo.json index 24f7c8166..c158c7aa2 100644 --- a/turbo.json +++ b/turbo.json @@ -92,6 +92,7 @@ "SENTRY_DSN", "MAILGUN_KEY", "FROM_EMAIL", - "MERGENT_KEY" + "MERGENT_KEY", + "PIZZLY_HOST" ] }