From d7c79ee651faea94da2b27e43d0b0c8d08cafe00 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 27 Feb 2024 14:35:43 +0000 Subject: [PATCH] V3 API keys page and Environment Variables (#907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New API keys page working * Added EnvironmentVariable and EnvironmentVariableValue tables * Environment variables presenter and repository started * Icon now accepts a ReactNode as well * The create form is working * Improvements to validation * Getting all the environment variables is working * Editing an env var working * Fix for deleting a value from an env var * Separate route for adding new env vars. Works with “Save and add another” * Editing is working properly, and revealing all values * Deleting env vars * Get environment variables with the environment id * Added th env vars count to the Environment variables page * Removed the commented out old Env & API keys side menu item * Fix imports in ClipboardField * Added a storybook page for all the variants of input fields * Upgraded Tailwind for the :has selector * Fix for the edit button not filling the width * Reworked the input fields so they work with a variable width icon on the left * Improvements to the new env var modal * Improve the style of the edit environment variable panel * Fix for when Input is type=“text” * Icon now works with FunctionComponents and ComponentTypes properly * Removed a console log * Removed unused imports * Use a const schema for SecretValue * Added friendlyId to EnvironmentVariable --- .../environments/EnvironmentLabel.tsx | 4 +- .../app/components/navigation/SideMenu.tsx | 24 +- .../components/primitives/ClipboardField.tsx | 8 +- .../webapp/app/components/primitives/Icon.tsx | 26 +- .../app/components/primitives/Input.tsx | 67 ++- .../app/components/stories/Forms.stories.tsx | 71 +++ .../presenters/v3/ApiKeysPresenter.server.ts | 79 +++ .../EnvironmentVariablesPresenter.server.ts | 115 ++++ .../route.tsx | 145 +++++ .../route.tsx | 231 ++++++++ .../route.tsx | 416 ++++++++++++++ .../services/secrets/secretStore.server.ts | 68 ++- apps/webapp/app/utils/pathBuilder.ts | 12 + .../environmentVariablesRepository.server.ts | 509 ++++++++++++++++++ .../app/v3/environmentVariables/repository.ts | 72 +++ apps/webapp/package.json | 2 +- .../migration.sql | 40 ++ .../migration.sql | 12 + packages/database/prisma/schema.prisma | 80 ++- pnpm-lock.yaml | 174 +++--- 20 files changed, 1993 insertions(+), 162 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx create mode 100644 apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts create mode 100644 apps/webapp/app/v3/environmentVariables/repository.ts create mode 100644 packages/database/prisma/migrations/20240223121156_added_environment_variable_and_environment_variable_value_tables/migration.sql create mode 100644 packages/database/prisma/migrations/20240227142146_environment_variable_added_friendly_id/migration.sql diff --git a/apps/webapp/app/components/environments/EnvironmentLabel.tsx b/apps/webapp/app/components/environments/EnvironmentLabel.tsx index 65a4bd2db..4df18297b 100644 --- a/apps/webapp/app/components/environments/EnvironmentLabel.tsx +++ b/apps/webapp/app/components/environments/EnvironmentLabel.tsx @@ -15,7 +15,7 @@ export function EnvironmentLabel({ return ( - {/* + + {/* { + setIsSecure(secure !== undefined && secure); + }, [secure]); + const { container, input, buttonVariant, button } = variations[variant]; const iconClassName = variations[variant].iconSize; const iconPosition = variations[variant].iconPadding; diff --git a/apps/webapp/app/components/primitives/Icon.tsx b/apps/webapp/app/components/primitives/Icon.tsx index 470d4cc80..a2907054b 100644 --- a/apps/webapp/app/components/primitives/Icon.tsx +++ b/apps/webapp/app/components/primitives/Icon.tsx @@ -1,7 +1,9 @@ +import React, { FunctionComponent, ReactElement, createElement } from "react"; import { IconNamesOrString, NamedIcon } from "./NamedIcon"; import { cn } from "~/utils/cn"; +import { render } from "react-dom"; -export type RenderIcon = IconNamesOrString | React.ComponentType; +export type RenderIcon = IconNamesOrString | FunctionComponent<{className?: string}> | React.ReactNode; type IconProps = { icon?: RenderIcon; @@ -10,17 +12,27 @@ type IconProps = { /** Use this icon to either render a passed in React component, or a NamedIcon/CompanyIcon */ export function Icon(props: IconProps) { + if (!props.icon) return null; + if (typeof props.icon === "string") { return } />; } - - const Icon = props.icon; - - if (!Icon) { - return <>; + + if (typeof props.icon === "function") { + const Icon = props.icon; + return ; } - return ; + if (React.isValidElement(props.icon)) { + return <>{props.icon}; + } + + if (props.icon && typeof props.icon === 'object' && ('type' in props.icon || '$$typeof' in props.icon)) { + return createElement>(props.icon as any, { className: props.className } as any); + } + + console.error("Invalid icon", props); + return null; } export function IconInBox({ boxClassName, ...props }: IconProps & { boxClassName?: string }) { diff --git a/apps/webapp/app/components/primitives/Input.tsx b/apps/webapp/app/components/primitives/Input.tsx index 1173882b8..cc14bb5dd 100644 --- a/apps/webapp/app/components/primitives/Input.tsx +++ b/apps/webapp/app/components/primitives/Input.tsx @@ -1,45 +1,47 @@ import * as React from "react"; +import { useImperativeHandle, useRef } from "react"; import { cn } from "~/utils/cn"; -import type { IconNamesOrString } from "./NamedIcon"; -import { NamedIcon } from "./NamedIcon"; import { Icon, RenderIcon } from "./Icon"; +const containerBase = "has-[:focus-visible]:outline-none has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-0 has-[:focus]:border-ring has-[:focus]:outline-none has-[:focus]:ring-2 has-[:focus]:ring-ring has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 ring-offset-background transition cursor-text" + +const inputBase = "h-full w-full text-bright bg-transparent file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 disabled:cursor-not-allowed outline-none ring-0 border-none" + +const shortcutBase = "grid h-fit place-content-center border border-dimmed/40 font-normal text-dimmed" + const variants = { large: { + container: + "px-1 w-full h-10 rounded-[3px] border border-slate-800 bg-slate-850 hover:border-slate-750 hover:bg-slate-800", input: - "px-3 flex h-10 w-full text-bright rounded-[3px] border border-slate-800 bg-slate-850 text-sm ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - - iconSize: "h-4 w-4 ml-3", - iconOffset: "pl-[34px]", + "px-2 text-sm", + iconSize: "h-4 w-4 ml-1", shortcut: - "right-2 top-[9px] grid h-fit min-w-[22px] place-content-center rounded-sm border border-dimmed/40 py-[3px] px-[5px] text-[0.6rem] font-normal text-dimmed", + "mr-1 min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem] select-none", }, medium: { + container: "px-1 h-8 w-full rounded border border-slate-800 bg-slate-850 hover:border-slate-750 hover:bg-slate-800", input: - "px-3 flex h-8 w-full text-bright rounded border border-slate-800 bg-slate-850 text-sm ring-offset-background transition file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - - iconSize: "h-4 w-4 ml-2.5", - iconOffset: "pl-[36px]", + "px-1.5 rounded text-sm", + iconSize: "h-4 w-4 ml-0.5", shortcut: - "right-2 top-[9px] grid h-fit min-w-[22px] place-content-center rounded-sm border border-dimmed/40 py-[3px] px-[5px] text-[0.6rem] font-normal text-dimmed", + "min-w-[22px] rounded-sm py-[3px] px-[5px] text-[0.6rem]", }, small: { + container: "px-0.5 h-6 w-full rounded border border-slate-800 bg-slate-850 hover:border-slate-750 hover:bg-slate-800", input: - "px-2 flex h-6 w-full text-bright rounded border border-slate-800 bg-slate-850 text-xs ring-offset-background transition file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground hover:border-slate-750 hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50", - - iconSize: "h-3 w-3 ml-1.5", - iconOffset: "pl-[24px]", + "px-1 rounded text-xs", + iconSize: "h-3 w-3 ml-0.5", shortcut: - "right-1 top-1 grid h-fit min-w-[22px] place-content-center rounded-[2px] border border-dimmed/40 py-px px-[3px] text-[0.5rem] font-normal text-dimmed", + "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]", }, tertiary: { + container: "px-0.5 h-6 w-full rounded border border-transparent hover:border-slate-800 hover:bg-slate-850", input: - "px-1 flex h-6 w-full text-bright rounded bg-transparent border border-transparent hover:border-slate-800 hover:bg-slate-850 focus:border-slate-800 focus:bg-slate-850 text-xs ring-offset-background transition file:border-0 file:bg-transparent file:text-xs file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", - - iconSize: "h-3 w-3 ml-1.5", - iconOffset: "pl-[21px]", + "px-1 rounded text-xs", + iconSize: "h-3 w-3 ml-0.5", shortcut: - "right-1 top-1 grid h-fit min-w-[22px] place-content-center rounded-[2px] border border-dimmed/40 py-px px-[3px] text-[0.5rem] font-normal text-dimmed", + "min-w-[22px] rounded-[2px] py-px px-[3px] text-[0.5rem]" }, }; @@ -52,24 +54,33 @@ export type InputProps = React.InputHTMLAttributes & { const Input = React.forwardRef( ({ className, type, shortcut, fullWidth = true, variant = "medium", icon, ...props }, ref) => { + const innerRef = useRef(null); + useImperativeHandle(ref, () => innerRef.current as HTMLInputElement); + + const containerClassName = variants[variant].container; const inputClassName = variants[variant].input; const iconClassName = variants[variant].iconSize; - const iconOffsetClassName = variants[variant].iconOffset; const shortcutClassName = variants[variant].shortcut; + return ( -
+
innerRef.current && innerRef.current.focus()} + > {icon && ( -
+
)} - {shortcut &&
{shortcut}
} + {shortcut &&
{shortcut}
}
); } diff --git a/apps/webapp/app/components/stories/Forms.stories.tsx b/apps/webapp/app/components/stories/Forms.stories.tsx index e806408cf..eeb66b1fa 100644 --- a/apps/webapp/app/components/stories/Forms.stories.tsx +++ b/apps/webapp/app/components/stories/Forms.stories.tsx @@ -15,6 +15,7 @@ import { Paragraph } from "../primitives/Paragraph"; import { LogoIcon } from "../LogoIcon"; import { Label } from "../primitives/Label"; import { TextLink } from "../primitives/TextLink"; +import { EnvironmentLabel } from "../environments/EnvironmentLabel"; const meta: Meta = { title: "Primitives/Forms", @@ -57,6 +58,10 @@ export const Search: Story = { render: (args) => , }; +export const Inputs: Story = { + render: (args) => , +}; + function Forms() { return ( @@ -180,3 +185,69 @@ function SearchForm() { ); } + +function InputFields() { + return ( +
+ + +
+ ); +} + +function InputFieldSet({ disabled }: { disabled?: boolean }) { + return ( +
+
+ + + + +
+
+ + + + +
+
+ } + shortcut="⌘K" + /> + } + shortcut="⌘K" + /> + } + shortcut="⌘K" + /> + } + shortcut="⌘K" + /> + } + shortcut="⌘K" + /> + } + shortcut="⌘K" + /> +
+
+ ); +} \ No newline at end of file diff --git a/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts new file mode 100644 index 000000000..2c783b0f5 --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiKeysPresenter.server.ts @@ -0,0 +1,79 @@ +import { PrismaClient, prisma } from "~/db.server"; +import { Project } from "~/models/project.server"; +import { User } from "~/models/user.server"; +import { sortEnvironments } from "~/services/environmentSort.server"; + +export class ApiKeysPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + apiKey: true, + pkApiKey: true, + type: true, + slug: true, + updatedAt: true, + orgMember: { + select: { + userId: true, + }, + }, + backgroundWorkers: { + select: { + version: true, + }, + take: 1, + orderBy: { + version: "desc", + }, + }, + _count: { + select: { + environmentVariableValues: true, + }, + }, + }, + where: { + project: { + slug: projectSlug, + }, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }); + + //filter out environments the only development ones belong to the current user + const filtered = environments.filter((environment) => { + if (environment.type === "DEVELOPMENT") { + return environment.orgMember?.userId === userId; + } + return true; + }); + + return { + environments: sortEnvironments( + filtered.map((environment) => ({ + id: environment.id, + apiKey: environment.apiKey, + pkApiKey: environment.pkApiKey, + type: environment.type, + slug: environment.slug, + updatedAt: environment.updatedAt, + latestVersion: environment.backgroundWorkers.at(0)?.version, + environmentVariableCount: environment._count.environmentVariableValues, + })) + ), + }; + } +} diff --git a/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts new file mode 100644 index 000000000..cff76a397 --- /dev/null +++ b/apps/webapp/app/presenters/v3/EnvironmentVariablesPresenter.server.ts @@ -0,0 +1,115 @@ +import { PrismaClient, prisma } from "~/db.server"; +import { Project } from "~/models/project.server"; +import { User } from "~/models/user.server"; +import { sortEnvironments } from "~/services/environmentSort.server"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; + +type Result = Awaited>; +export type EnvironmentVariableWithSetValues = Result["environmentVariables"][number]; + +export class EnvironmentVariablesPresenter { + #prismaClient: PrismaClient; + + constructor(prismaClient: PrismaClient = prisma) { + this.#prismaClient = prismaClient; + } + + public async call({ userId, projectSlug }: { userId: User["id"]; projectSlug: Project["slug"] }) { + const project = await this.#prismaClient.project.findUnique({ + select: { + id: true, + }, + where: { + slug: projectSlug, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }); + + if (!project) { + throw new Error("Project not found"); + } + + const environmentVariables = await this.#prismaClient.environmentVariable.findMany({ + select: { + id: true, + key: true, + values: { + select: { + id: true, + environmentId: true, + valueReference: { + select: { + key: true, + }, + }, + }, + }, + }, + where: { + project: { + slug: projectSlug, + organization: { + members: { + some: { + userId, + }, + }, + }, + }, + }, + }); + + const environments = await this.#prismaClient.runtimeEnvironment.findMany({ + select: { + id: true, + type: true, + orgMember: { + select: { + userId: true, + }, + }, + }, + where: { + project: { + slug: projectSlug, + }, + }, + }); + + const sortedEnvironments = sortEnvironments(environments).filter( + (e) => e.orgMember?.userId === userId || e.orgMember === null + ); + + const repository = new EnvironmentVariablesRepository(this.#prismaClient); + const variables = await repository.getProject(project.id, userId); + + return { + environmentVariables: environmentVariables.map((environmentVariable) => { + const variable = variables.find((v) => v.key === environmentVariable.key); + + return { + id: environmentVariable.id, + key: environmentVariable.key, + values: sortedEnvironments.reduce((previous, env) => { + const val = variable?.values.find((v) => v.environment.id === env.id); + previous[env.id] = { + value: val?.value, + environment: { type: env.type, id: env.id }, + }; + return { ...previous }; + }, {} as Record), + }; + }), + environments: sortedEnvironments.map((environment) => ({ + id: environment.id, + type: environment.type, + })), + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx new file mode 100644 index 000000000..5345ed94e --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.apikeys/route.tsx @@ -0,0 +1,145 @@ +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; +import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Header3 } from "~/components/primitives/Headers"; +import { + PageButtons, + PageHeader, + PageTitle, + PageTitleRow, +} from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { useProject } from "~/hooks/useProject"; +import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { Handle } from "~/utils/handle"; +import { ProjectParamSchema, docsPath } from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + try { + const presenter = new ApiKeysPresenter(); + const { environments } = await presenter.call({ + userId, + projectSlug: projectParam, + }); + + return typedjson({ + environments, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +export const handle: Handle = { + breadcrumb: (match) => , +}; + +export default function Page() { + const { environments } = useTypedLoaderData(); + const project = useProject(); + + return ( + + + + + + + API keys docs + + + + + +
+ Server API keys + + Server API keys should be used on your server – they give full API access. + + Public API keys + + These keys have limited read-only access and should be used in your frontend. + +
+ + + + Environment + Server API key + Public API key + Keys generated + Latest version + Env vars + Actions + + + + {environments.map((environment) => ( + + + + + + + + + + + + + + {environment.latestVersion ?? "–"} + {environment.environmentVariableCount} + + + + + ))} + +
+
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx new file mode 100644 index 000000000..2aab13cc0 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables.new/route.tsx @@ -0,0 +1,231 @@ +import { Submission, conform, useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { Form, useActionData, useLocation, useNavigate, useNavigation } from "@remix-run/react"; +import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { + ProjectParamSchema, + v3EnvironmentVariablesPath, + v3NewEnvironmentVariablesPath, +} from "~/utils/pathBuilder"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { CreateEnvironmentVariable } from "~/v3/environmentVariables/repository"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + try { + const presenter = new EnvironmentVariablesPresenter(); + const { environmentVariables, environments } = await presenter.call({ + userId, + projectSlug: projectParam, + }); + + return typedjson({ + environmentVariables, + environments, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +const schema = z.object({ + action: z.enum(["create", "create-more"]), + ...CreateEnvironmentVariable.shape, +}); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + if (request.method.toUpperCase() !== "POST") { + return { status: 405, body: "Method Not Allowed" }; + } + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return json(submission); + } + + const project = await prisma.project.findUnique({ + where: { + slug: params.projectParam, + }, + select: { + id: true, + }, + }); + if (!project) { + submission.error.key = "Project not found"; + return json(submission); + } + + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.create(project.id, userId, submission.value); + + if (!result.success) { + submission.error.key = result.error; + return json(submission); + } + + switch (submission.value.action) { + case "create": + return redirect( + v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }) + ); + case "create-more": + return redirectWithSuccessMessage( + v3NewEnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Created ${submission.value.key} environment variable` + ); + } +}; + +export default function Page() { + const [isOpen, setIsOpen] = useState(false); + const { environmentVariables, environments } = useTypedLoaderData(); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + const navigate = useNavigate(); + const organization = useOrganization(); + const project = useProject(); + const keyFieldRef = useRef(null); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "create"; + + const [form, { key }] = useForm({ + id: "create-environment-variable", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + useEffect(() => { + setIsOpen(true); + }, []); + + useEffect(() => { + if (navigation.state !== "idle") return; + if (lastSubmission !== undefined) return; + + form.ref.current?.reset(); + keyFieldRef.current?.focus(); + }, [navigation.state, lastSubmission]); + + return ( + { + if (!o) { + navigate(v3EnvironmentVariablesPath(organization, project)); + } + }} + > + + New environment variable +
+
+ + + + + + +
+ {environments.map((environment, index) => { + return ( + + + + + + ); + })} +
+
+ + {key.error} + {form.error} + + + +
+ } + cancelButton={ + + Cancel + + } + /> + + + + + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx new file mode 100644 index 000000000..aea25e1ee --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.environment-variables/route.tsx @@ -0,0 +1,416 @@ +import { useForm } from "@conform-to/react"; +import { parse } from "@conform-to/zod"; +import { PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; +import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react"; +import { + ActionFunctionArgs, + LoaderFunctionArgs, + json, + redirectDocument +} from "@remix-run/server-runtime"; +import { RuntimeEnvironment } from "@trigger.dev/database"; +import { Fragment, useState } from "react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { InlineCode } from "~/components/code/InlineCode"; +import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { BreadcrumbLink } from "~/components/navigation/Breadcrumb"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { ClipboardField } from "~/components/primitives/ClipboardField"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Fieldset } from "~/components/primitives/Fieldset"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { + PageButtons, + PageHeader, + PageTitle, + PageTitleRow, +} from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Switch } from "~/components/primitives/Switch"; +import { + Table, + TableBody, + TableCell, + TableCellMenu, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { prisma } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { redirectWithSuccessMessage } from "~/models/message.server"; +import { + EnvironmentVariableWithSetValues, + EnvironmentVariablesPresenter, +} from "~/presenters/v3/EnvironmentVariablesPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { cn } from "~/utils/cn"; +import { Handle } from "~/utils/handle"; +import { + ProjectParamSchema, + docsPath, + v3EnvironmentVariablesPath, + v3NewEnvironmentVariablesPath, +} from "~/utils/pathBuilder"; +import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server"; +import { + DeleteEnvironmentVariable, + EditEnvironmentVariable +} from "~/v3/environmentVariables/repository"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam } = ProjectParamSchema.parse(params); + + try { + const presenter = new EnvironmentVariablesPresenter(); + const { environmentVariables, environments } = await presenter.call({ + userId, + projectSlug: projectParam, + }); + + return typedjson({ + environmentVariables, + environments, + }); + } catch (error) { + console.error(error); + throw new Response(undefined, { + status: 400, + statusText: "Something went wrong, if this problem persists please contact support.", + }); + } +}; + +const schema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("edit"), key: z.string(), ...EditEnvironmentVariable.shape }), + z.object({ action: z.literal("delete"), key: z.string(), ...DeleteEnvironmentVariable.shape }), +]); + +export const action = async ({ request, params }: ActionFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam } = ProjectParamSchema.parse(params); + + if (request.method.toUpperCase() !== "POST") { + return { status: 405, body: "Method Not Allowed" }; + } + + const formData = await request.formData(); + const submission = parse(formData, { schema }); + + if (!submission.value) { + return json(submission); + } + + const project = await prisma.project.findUnique({ + where: { + slug: params.projectParam, + }, + select: { + id: true, + }, + }); + if (!project) { + submission.error.key = "Project not found"; + return json(submission); + } + + switch (submission.value.action) { + case "edit": { + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.edit(project.id, userId, submission.value); + + if (!result.success) { + submission.error.key = result.error; + return json(submission); + } + + //use redirectDocument because it reloads the page + return redirectDocument( + v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }), + { + headers: { + refresh: "true", + }, + } + ); + } + case "delete": { + const repository = new EnvironmentVariablesRepository(prisma); + const result = await repository.delete(project.id, userId, submission.value); + + if (!result.success) { + submission.error.key = result.error; + return json(submission); + } + + return redirectWithSuccessMessage( + v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }), + request, + `Deleted ${submission.value.key} environment variable` + ); + } + } +}; + +export const handle: Handle = { + breadcrumb: (match) => , +}; + +export default function Page() { + const [revealAll, setRevealAll] = useState(false); + const { environmentVariables, environments } = useTypedLoaderData(); + const project = useProject(); + const organization = useOrganization(); + + return ( + + + + + + + Environment variables docs + + + + + +
+
+ setRevealAll(e.valueOf())} + /> + + New environment variable + +
+ + + + Key + {environments.map((environment) => ( + + + + ))} + Actions + + + + {environmentVariables.length > 0 ? ( + environmentVariables.map((variable) => ( + + {variable.key} + {environments.map((environment) => { + const value = variable.values[environment.id]?.value; + + if (!value) { + return Not set; + } + return ( + + + + ); + })} + + + + + + )) + ) : ( + + +
+ No environment variables have been set +
+
+
+ )} +
+
+
+ +
+
+ ); +} + +function EditEnvironmentVariablePanel({ + variable, + environments, +}: { + variable: EnvironmentVariableWithSetValues; + environments: Pick[]; +}) { + const [isOpen, setIsOpen] = useState(false); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "edit"; + + const [form, { id }] = useForm({ + id: "edit-environment-variable", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + return ( + + + + + + + Edit {variable.key} + +
+ + + + {id.error} +
+ + + {variable.key} + +
+
+ + +
+ {environments.map((environment, index) => { + const value = variable.values[environment.id]?.value; + return ( + + + + + + ); + })} +
+
+ + {form.error} + + + {isLoading ? "Saving" : "Edit"} + + } + cancelButton={ + + } + /> + +
+
+
+
+ ); +} + +function DeleteEnvironmentVariableButton({ + variable, +}: { + variable: EnvironmentVariableWithSetValues; +}) { + const lastSubmission = useActionData(); + const navigation = useNavigation(); + + const isLoading = + navigation.state !== "idle" && + navigation.formMethod === "post" && + navigation.formData?.get("action") === "delete"; + + const [form, { id }] = useForm({ + id: "delete-environment-variable", + // TODO: type this + lastSubmission: lastSubmission as any, + onValidate({ formData }) { + return parse(formData, { schema }); + }, + shouldRevalidate: "onSubmit", + }); + + return ( +
+ + + +
+ ); +} diff --git a/apps/webapp/app/services/secrets/secretStore.server.ts b/apps/webapp/app/services/secrets/secretStore.server.ts index 864e3a48d..45673d7f0 100644 --- a/apps/webapp/app/services/secrets/secretStore.server.ts +++ b/apps/webapp/app/services/secrets/secretStore.server.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { env } from "~/env.server"; import nodeCrypto from "node:crypto"; import { safeJsonParse } from "~/utils/json"; +import { logger } from "../logger.server"; export const SecretStoreOptionsSchema = z.enum(["DATABASE", "AWS_PARAM_STORE"]); export type SecretStoreOptions = z.infer; @@ -18,10 +19,12 @@ type ProviderInitializationOptions = { export interface SecretStoreProvider { getSecret(schema: z.Schema, key: string): Promise; + getSecrets(schema: z.Schema, keyPrefix: string): Promise<{ key: string; value: T }[]>; setSecret(key: string, value: T): Promise; + deleteSecret(key: string): Promise; } -/** The SecretStore will use the passed in provider. We do NOT recommend using "DATABASE" outside of localhost. */ +/** The SecretStore will use the passed in provider. */ export class SecretStore { constructor(private provider: SecretStoreProvider) {} @@ -42,6 +45,14 @@ export class SecretStore { setSecret(key: string, value: T): Promise { return this.provider.setSecret(key, value); } + + getSecrets(schema: z.Schema, keyPrefix: string): Promise<{ key: string; value: T }[]> { + return this.provider.getSecrets(schema, keyPrefix); + } + + deleteSecret(key: string): Promise { + return this.provider.deleteSecret(key); + } } const EncryptedSecretValueSchema = z.object({ @@ -97,6 +108,51 @@ class PrismaSecretStore implements SecretStoreProvider { return schema.parse(parsedDecrypted); } + async getSecrets( + schema: z.Schema, + keyPrefix: string + ): Promise<{ key: string; value: T }[]> { + const secrets = await this.#prismaClient.secretStore.findMany({ + where: { + key: { + startsWith: keyPrefix, + }, + }, + }); + + const results = [] as { key: string; value: T }[]; + + for (const secret of secrets) { + if (secret.version === "1") { + results.push({ key: secret.key, value: schema.parse(secret.value) }); + } + + const encryptedData = EncryptedSecretValueSchema.safeParse(secret.value); + + if (!encryptedData.success) { + throw new Error( + `Unable to parse encrypted secret ${secret.key}: ${encryptedData.error.message}` + ); + } + + const decrypted = await this.#decrypt( + encryptedData.data.nonce, + encryptedData.data.ciphertext, + encryptedData.data.tag + ); + + const parsedDecrypted = safeJsonParse(decrypted); + if (!parsedDecrypted) { + logger.error(`Secret isn't JSON ${secret.key}`); + continue; + } + + results.push({ key: secret.key, value: schema.parse(parsedDecrypted) }); + } + + return results; + } + async setSecret(key: string, value: T): Promise { const encrypted = await this.#encrypt(JSON.stringify(value)); @@ -116,6 +172,14 @@ class PrismaSecretStore implements SecretStoreProvider { }); } + async deleteSecret(key: string): Promise { + await this.#prismaClient.secretStore.delete({ + where: { + key, + }, + }); + } + async #decrypt(nonce: string, ciphertext: string, tag: string): Promise { const decipher = nodeCrypto.createDecipheriv( "aes-256-gcm", @@ -154,7 +218,7 @@ class PrismaSecretStore implements SecretStoreProvider { export function getSecretStore< K extends SecretStoreOptions, - TOptions extends ProviderInitializationOptions[K], + TOptions extends ProviderInitializationOptions[K] >(provider: K, options?: TOptions): SecretStore { switch (provider) { case "DATABASE": { diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index d183a8a3d..3b7ad4d8e 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -295,6 +295,18 @@ export function v3ProjectPath(organization: OrgForPath, project: ProjectForPath) return `/orgs/${organizationParam(organization)}/projects/v3/${projectParam(project)}`; } +export function v3ApiKeysPath(organization: OrgForPath, project: ProjectForPath) { + return `${v3ProjectPath(organization, project)}/apikeys`; +} + +export function v3EnvironmentVariablesPath(organization: OrgForPath, project: ProjectForPath) { + return `${v3ProjectPath(organization, project)}/environment-variables`; +} + +export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project: ProjectForPath) { + return `${v3EnvironmentVariablesPath(organization, project)}/new`; +} + export function v3RunsPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts new file mode 100644 index 000000000..26ec10357 --- /dev/null +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -0,0 +1,509 @@ +import { Prisma, PrismaClient } from "@trigger.dev/database"; +import { z } from "zod"; +import { $transaction, prisma } from "~/db.server"; +import { getSecretStore } from "~/services/secrets/secretStore.server"; +import { generateFriendlyId } from "../friendlyIdentifiers"; +import { EnvironmentVariable, ProjectEnvironmentVariable, Repository, Result } from "./repository"; + +function secretKeyProjectPrefix(projectId: string) { + return `environmentvariable:${projectId}:`; +} + +function secretKeyEnvironmentPrefix(projectId: string, environmentId: string) { + return `${secretKeyProjectPrefix(projectId)}${environmentId}:`; +} + +function secretKey(projectId: string, environmentId: string, key: string) { + return `${secretKeyEnvironmentPrefix(projectId, environmentId)}${key}`; +} + +function parseSecretKey(key: string) { + const parts = key.split(":"); + return { + projectId: parts[1], + environmentId: parts[2], + key: parts[3], + }; +} + +const SecretValue = z.object({ secret: z.string() }); + +export class EnvironmentVariablesRepository implements Repository { + constructor(private prismaClient: PrismaClient = prisma) {} + + async create( + projectId: string, + userId: string, + options: { key: string; values: { value: string; environmentId: string }[] } + ): Promise { + const project = await this.prismaClient.project.findUnique({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + }, + }, + }); + + if (!project) { + return { success: false as const, error: "Project not found" }; + } + + if (options.values.every((v) => !project.environments.some((e) => e.id === v.environmentId))) { + return { success: false as const, error: `Environment not found` }; + } + + //get rid of empty strings + const values = options.values.filter((v) => v.value.trim() !== ""); + + if (values.length === 0) { + return { success: false as const, error: `You must set at least one value` }; + } + + try { + const result = await $transaction(this.prismaClient, async (tx) => { + const environmentVariable = await tx.environmentVariable.create({ + data: { + key: options.key, + friendlyId: generateFriendlyId("envvar"), + project: { + connect: { + id: projectId, + }, + }, + }, + }); + + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + //create the secret values and references + for (const value of values) { + const key = secretKey(projectId, value.environmentId, options.key); + + //create the secret reference + const secretReference = await tx.secretReference.create({ + data: { + key, + provider: "DATABASE", + }, + }); + + const variableValue = await tx.environmentVariableValue.create({ + data: { + variableId: environmentVariable.id, + environmentId: value.environmentId, + valueReferenceId: secretReference.id, + }, + }); + + await secretStore.setSecret<{ secret: string }>(key, { + secret: value.value, + }); + } + }); + + return { + success: true as const, + }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + // The error code for unique constraint violation in Prisma is P2002 + if (error.code === "P2002") { + return { + success: false as const, + error: `There's already an environment variable called ${options.key}.`, + }; + } + } + + return { + success: false as const, + error: error instanceof Error ? error.message : "Something went wrong", + }; + } + } + + async edit( + projectId: string, + userId: string, + options: { values: { value: string; environmentId: string }[]; id: string } + ): Promise { + const project = await this.prismaClient.project.findUnique({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + where: { + OR: [ + { + orgMember: null, + }, + { + orgMember: { + userId, + }, + }, + ], + }, + }, + }, + }); + + if (!project) { + return { success: false as const, error: "Project not found" }; + } + + if (options.values.every((v) => !project.environments.some((e) => e.id === v.environmentId))) { + return { success: false as const, error: `Environment not found` }; + } + + //get rid of empty strings + let values = options.values.filter((v) => v.value.trim() !== ""); + + //add in empty values for environments that don't have a value + const environmentIds = project.environments.map((e) => e.id); + for (const environmentId of environmentIds) { + if (!values.some((v) => v.environmentId === environmentId)) { + values.push({ + environmentId, + value: "", + }); + } + } + + const environmentVariable = await this.prismaClient.environmentVariable.findUnique({ + select: { + id: true, + key: true, + }, + where: { + id: options.id, + }, + }); + if (!environmentVariable) { + return { success: false as const, error: "Environment variable not found" }; + } + + try { + await $transaction(this.prismaClient, async (tx) => { + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + //create the secret values and references + for (const value of values) { + const key = secretKey(projectId, value.environmentId, environmentVariable.key); + const existingValue = await tx.environmentVariableValue.findUnique({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId: value.environmentId, + }, + }, + }); + + if (existingValue && existingValue.valueReferenceId) { + if (value.value === "") { + //delete the value + await secretStore.deleteSecret(key); + await tx.secretReference.delete({ + where: { + id: existingValue.valueReferenceId, + }, + }); + await tx.environmentVariableValue.delete({ + where: { + variableId_environmentId: { + variableId: environmentVariable.id, + environmentId: value.environmentId, + }, + }, + }); + } else { + await secretStore.setSecret<{ secret: string }>(key, { + secret: value.value, + }); + } + continue; + } + + //create the secret reference + const secretReference = await tx.secretReference.create({ + data: { + key, + provider: "DATABASE", + }, + }); + + const variableValue = await tx.environmentVariableValue.create({ + data: { + variableId: environmentVariable.id, + environmentId: value.environmentId, + valueReferenceId: secretReference.id, + }, + }); + + await secretStore.setSecret<{ secret: string }>(key, { + secret: value.value, + }); + } + }); + + return { + success: true as const, + }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : "Something went wrong", + }; + } + } + + async getProject(projectId: string, userId: string): Promise { + const project = await this.prismaClient.project.findUnique({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + type: true, + }, + }, + }, + }); + + if (!project) { + return []; + } + + const secretStore = getSecretStore("DATABASE", { + prismaClient: this.prismaClient, + }); + + const secrets = await secretStore.getSecrets( + SecretValue, + secretKeyProjectPrefix(projectId) + ); + + const values = secrets.map((secret) => { + const { projectId, environmentId, key } = parseSecretKey(secret.key); + return { + projectId, + environmentId, + key, + value: secret.value.secret, + }; + }); + + //now group the values together by key and environment ID into ProjectEnvironmentVariable[] + //and add the type of environment to the result + const results: ProjectEnvironmentVariable[] = []; + for (const value of values) { + const environment = project.environments.find((e) => e.id === value.environmentId); + if (!environment) { + throw new Error("Environment not found"); + } + + const existing = results.find((r) => r.key === value.key); + if (existing) { + existing.values.push({ + value: value.value, + environment: { + id: value.environmentId, + type: environment.type, + }, + }); + } else { + results.push({ + key: value.key, + values: [ + { + value: value.value, + environment: { + id: value.environmentId, + type: environment.type, + }, + }, + ], + }); + } + } + + return results; + } + + async getEnvironment( + projectId: string, + userId: string, + environmentId: string + ): Promise { + const project = await this.prismaClient.project.findUnique({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + where: { + id: environmentId, + }, + }, + }, + }); + + if (!project || project.environments.length === 0) { + return []; + } + + const secretStore = getSecretStore("DATABASE", { + prismaClient: this.prismaClient, + }); + + const secrets = await secretStore.getSecrets( + SecretValue, + secretKeyEnvironmentPrefix(projectId, environmentId) + ); + + return secrets.map((secret) => { + const { key } = parseSecretKey(secret.key); + return { + key, + value: secret.value.secret, + }; + }); + } + + async delete(projectId: string, userId: string, options: { id: string }): Promise { + const project = await this.prismaClient.project.findUnique({ + where: { + id: projectId, + organization: { + members: { + some: { + userId, + }, + }, + }, + deletedAt: null, + }, + select: { + environments: { + select: { + id: true, + }, + where: { + OR: [ + { + orgMember: null, + }, + { + orgMember: { + userId, + }, + }, + ], + }, + }, + }, + }); + + if (!project) { + return { success: false as const, error: "Project not found" }; + } + + const environmentVariable = await this.prismaClient.environmentVariable.findUnique({ + select: { + id: true, + key: true, + values: { + select: { + id: true, + environmentId: true, + }, + }, + }, + where: { + id: options.id, + }, + }); + if (!environmentVariable) { + return { success: false as const, error: "Environment variable not found" }; + } + + try { + await $transaction(this.prismaClient, async (tx) => { + await tx.environmentVariable.delete({ + where: { + id: options.id, + }, + }); + + const secretStore = getSecretStore("DATABASE", { + prismaClient: tx, + }); + + //create the secret values and references + for (const value of environmentVariable.values) { + const key = secretKey(projectId, value.environmentId, environmentVariable.key); + await secretStore.deleteSecret(key); + } + }); + + return { + success: true as const, + }; + } catch (error) { + return { + success: false as const, + error: error instanceof Error ? error.message : "Something went wrong", + }; + } + } +} diff --git a/apps/webapp/app/v3/environmentVariables/repository.ts b/apps/webapp/app/v3/environmentVariables/repository.ts new file mode 100644 index 000000000..5f23b52fa --- /dev/null +++ b/apps/webapp/app/v3/environmentVariables/repository.ts @@ -0,0 +1,72 @@ +import { RuntimeEnvironmentType } from "@trigger.dev/database"; +import { z } from "zod"; + +const EnvironmentVariable = z + .string() + .nonempty("Environment variable key is required") + .regex(/^\w+$/, "Environment variables can only contain alphanumeric characters and underscores"); + +export const CreateEnvironmentVariable = z.object({ + key: EnvironmentVariable, + values: z.array( + z.object({ + environmentId: z.string(), + value: z.string(), + }) + ), +}); + +export type CreateEnvironmentVariable = z.infer; + +export const EditEnvironmentVariable = z.object({ + id: z.string(), + values: z.array( + z.object({ + environmentId: z.string(), + value: z.string(), + }) + ), +}); +export type EditEnvironmentVariable = z.infer; + +export const DeleteEnvironmentVariable = z.object({ + id: z.string(), +}); +export type DeleteEnvironmentVariable = z.infer; + +export type Result = + | { + success: true; + } + | { + success: false; + error: string; + }; + +export type ProjectEnvironmentVariable = { + key: string; + values: { + value: string; + environment: { + id: string; + type: RuntimeEnvironmentType; + }; + }[]; +}; + +export type EnvironmentVariable = { + key: string; + value: string; +}; + +export interface Repository { + create(projectId: string, userId: string, options: CreateEnvironmentVariable): Promise; + edit(projectId: string, userId: string, options: EditEnvironmentVariable): Promise; + getProject(projectId: string, userId: string): Promise; + getEnvironment( + projectId: string, + userId: string, + environmentId: string + ): Promise; + delete(projectId: string, userId: string, options: DeleteEnvironmentVariable): Promise; +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index bd438d0ae..28972ea74 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -214,7 +214,7 @@ "storybook-addon-designs": "7.0.0-beta.2", "storybook-addon-variants": "^0.2.0", "tailwind-scrollbar": "^3.0.1", - "tailwindcss": "3.3.2", + "tailwindcss": "3.4.1", "ts-node": "^10.7.0", "tsconfig-paths": "^3.14.1", "typescript": "^5.1.6" diff --git a/packages/database/prisma/migrations/20240223121156_added_environment_variable_and_environment_variable_value_tables/migration.sql b/packages/database/prisma/migrations/20240223121156_added_environment_variable_and_environment_variable_value_tables/migration.sql new file mode 100644 index 000000000..66698780b --- /dev/null +++ b/packages/database/prisma/migrations/20240223121156_added_environment_variable_and_environment_variable_value_tables/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "EnvironmentVariable" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EnvironmentVariable_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EnvironmentVariableValue" ( + "id" TEXT NOT NULL, + "valueReferenceId" TEXT, + "variableId" TEXT NOT NULL, + "environmentId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EnvironmentVariableValue_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "EnvironmentVariable_projectId_key_key" ON "EnvironmentVariable"("projectId", "key"); + +-- CreateIndex +CREATE UNIQUE INDEX "EnvironmentVariableValue_variableId_environmentId_key" ON "EnvironmentVariableValue"("variableId", "environmentId"); + +-- AddForeignKey +ALTER TABLE "EnvironmentVariable" ADD CONSTRAINT "EnvironmentVariable_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EnvironmentVariableValue" ADD CONSTRAINT "EnvironmentVariableValue_valueReferenceId_fkey" FOREIGN KEY ("valueReferenceId") REFERENCES "SecretReference"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EnvironmentVariableValue" ADD CONSTRAINT "EnvironmentVariableValue_variableId_fkey" FOREIGN KEY ("variableId") REFERENCES "EnvironmentVariable"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EnvironmentVariableValue" ADD CONSTRAINT "EnvironmentVariableValue_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/migrations/20240227142146_environment_variable_added_friendly_id/migration.sql b/packages/database/prisma/migrations/20240227142146_environment_variable_added_friendly_id/migration.sql new file mode 100644 index 000000000..286ead1e2 --- /dev/null +++ b/packages/database/prisma/migrations/20240227142146_environment_variable_added_friendly_id/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[friendlyId]` on the table `EnvironmentVariable` will be added. If there are existing duplicate values, this will fail. + - Added the required column `friendlyId` to the `EnvironmentVariable` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "EnvironmentVariable" ADD COLUMN "friendlyId" TEXT NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "EnvironmentVariable_friendlyId_key" ON "EnvironmentVariable"("friendlyId"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8dd8a1f22..a184ec1e1 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -367,27 +367,28 @@ model RuntimeEnvironment { tunnelId String? - endpoints Endpoint[] - jobVersions JobVersion[] - events EventRecord[] - jobRuns JobRun[] - requestDeliveries HttpSourceRequestDelivery[] - jobAliases JobAlias[] - JobQueue JobQueue[] - sources TriggerSource[] - eventDispatchers EventDispatcher[] - scheduleSources ScheduleSource[] - ExternalAccount ExternalAccount[] - httpEndpointEnvironments TriggerHttpEndpointEnvironment[] - concurrencyLimitGroups ConcurrencyLimitGroup[] - keyValueItems KeyValueItem[] - webhookEnvironments WebhookEnvironment[] - webhookRequestDeliveries WebhookRequestDelivery[] - backgroundWorkers BackgroundWorker[] - backgroundWorkerTasks BackgroundWorkerTask[] - taskRuns TaskRun[] - taskQueues TaskQueue[] - batchTaskRuns BatchTaskRun[] + endpoints Endpoint[] + jobVersions JobVersion[] + events EventRecord[] + jobRuns JobRun[] + requestDeliveries HttpSourceRequestDelivery[] + jobAliases JobAlias[] + JobQueue JobQueue[] + sources TriggerSource[] + eventDispatchers EventDispatcher[] + scheduleSources ScheduleSource[] + ExternalAccount ExternalAccount[] + httpEndpointEnvironments TriggerHttpEndpointEnvironment[] + concurrencyLimitGroups ConcurrencyLimitGroup[] + keyValueItems KeyValueItem[] + webhookEnvironments WebhookEnvironment[] + webhookRequestDeliveries WebhookRequestDelivery[] + backgroundWorkers BackgroundWorker[] + backgroundWorkerTasks BackgroundWorkerTask[] + taskRuns TaskRun[] + taskQueues TaskQueue[] + batchTaskRuns BatchTaskRun[] + environmentVariableValues EnvironmentVariableValue[] @@unique([projectId, slug, orgMemberId]) @@unique([projectId, shortcode]) @@ -430,6 +431,7 @@ model Project { taskRuns TaskRun[] taskTags TaskTag[] taskQueues TaskQueue[] + environmentVariables EnvironmentVariable[] } enum ProjectVersion { @@ -1101,10 +1103,11 @@ model SecretReference { key String @unique provider SecretStoreProvider @default(DATABASE) - connections IntegrationConnection[] - integrations Integration[] - triggerSources TriggerSource[] - httpEndpoints TriggerHttpEndpoint[] + connections IntegrationConnection[] + integrations Integration[] + triggerSources TriggerSource[] + httpEndpoints TriggerHttpEndpoint[] + environmentVariableValues EnvironmentVariableValue[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1848,3 +1851,30 @@ enum BatchTaskRunItemStatus { CANCELED COMPLETED } + +model EnvironmentVariable { + id String @id @default(cuid()) + friendlyId String @unique + key String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + values EnvironmentVariableValue[] + + @@unique([projectId, key]) +} + +model EnvironmentVariableValue { + id String @id @default(cuid()) + valueReference SecretReference? @relation(fields: [valueReferenceId], references: [id], onDelete: SetNull, onUpdate: Cascade) + valueReferenceId String? + variable EnvironmentVariable @relation(fields: [variableId], references: [id], onDelete: Cascade, onUpdate: Cascade) + variableId String + environment RuntimeEnvironment @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + environmentId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([variableId, environmentId]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2fc177bd..8f37beb4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,7 +253,7 @@ importers: tailwind-merge: ^1.12.0 tailwind-scrollbar: ^3.0.1 tailwind-scrollbar-hide: ^1.1.7 - tailwindcss: 3.3.2 + tailwindcss: 3.4.1 tailwindcss-animate: ^1.0.5 tiny-invariant: ^1.2.0 ts-node: ^10.7.0 @@ -318,7 +318,7 @@ importers: '@remix-run/server-runtime': 2.1.0_typescript@5.2.2 '@remix-run/v1-meta': 0.1.3_ybjp5xbtg4zthziheradfmei64 '@tabler/icons-react': 2.40.0_react@18.2.0 - '@tailwindcss/container-queries': 0.1.1_tailwindcss@3.3.2 + '@tailwindcss/container-queries': 0.1.1_tailwindcss@3.4.1 '@tanstack/react-virtual': 3.0.4_biqbaboplfbrettd7655fr4n2y '@team-plain/typescript-sdk': 3.5.0 '@trigger.dev/billing': 1.0.10 @@ -383,7 +383,7 @@ importers: sqs-consumer: 7.5.0_hzguu36iioy52ghs7zxwltx2ia tailwind-merge: 1.12.0 tailwind-scrollbar-hide: 1.1.7 - tailwindcss-animate: 1.0.5_tailwindcss@3.3.2 + tailwindcss-animate: 1.0.5_tailwindcss@3.4.1 tiny-invariant: 1.3.1 ulid: 2.3.0 ulidx: 2.2.1 @@ -408,8 +408,8 @@ importers: '@storybook/testing-library': 0.0.14-next.2 '@swc/core': 1.3.26 '@swc/helpers': 0.4.14 - '@tailwindcss/forms': 0.5.3_tailwindcss@3.3.2 - '@tailwindcss/typography': 0.5.9_tailwindcss@3.3.2 + '@tailwindcss/forms': 0.5.3_tailwindcss@3.4.1 + '@tailwindcss/typography': 0.5.9_tailwindcss@3.4.1 '@total-typescript/ts-reset': 0.4.2 '@trigger.dev/tailwind-config': link:../../config-packages/tailwind-config '@types/bcryptjs': 2.4.2 @@ -449,8 +449,8 @@ importers: storybook: 7.0.9 storybook-addon-designs: 7.0.0-beta.2_gtov7sygms2deenjyn5i42ywqa storybook-addon-variants: 0.2.0_biqbaboplfbrettd7655fr4n2y - tailwind-scrollbar: 3.0.1_tailwindcss@3.3.2 - tailwindcss: 3.3.2_ts-node@10.9.1 + tailwind-scrollbar: 3.0.1_tailwindcss@3.4.1 + tailwindcss: 3.4.1_ts-node@10.9.1 ts-node: 10.9.1_v2fyojhutw2zqp3xtxirvaa4jq tsconfig-paths: 3.14.1 typescript: 5.2.2 @@ -17079,12 +17079,12 @@ packages: resolution: {integrity: sha512-VqKsBSX159cLFTnCzkCmGhZtSPJHNN0lM2sC4xe0HPOfPUnjiex7rDHDdut4oe4iKRecDDpwXwM9BcU6xCPlCg==} dev: false - /@tailwindcss/container-queries/0.1.1_tailwindcss@3.3.2: + /@tailwindcss/container-queries/0.1.1_tailwindcss@3.4.1: resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} peerDependencies: tailwindcss: '>=3.2.0' dependencies: - tailwindcss: 3.3.2_ts-node@10.9.1 + tailwindcss: 3.4.1_ts-node@10.9.1 dev: false /@tailwindcss/forms/0.5.3_tailwindcss@3.1.8: @@ -17096,13 +17096,13 @@ packages: tailwindcss: 3.1.8_postcss@8.4.21 dev: true - /@tailwindcss/forms/0.5.3_tailwindcss@3.3.2: + /@tailwindcss/forms/0.5.3_tailwindcss@3.4.1: resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==} peerDependencies: tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.3.2_ts-node@10.9.1 + tailwindcss: 3.4.1_ts-node@10.9.1 dev: true /@tailwindcss/typography/0.5.9_tailwindcss@3.1.8: @@ -17117,7 +17117,7 @@ packages: tailwindcss: 3.1.8_postcss@8.4.21 dev: true - /@tailwindcss/typography/0.5.9_tailwindcss@3.3.2: + /@tailwindcss/typography/0.5.9_tailwindcss@3.4.1: resolution: {integrity: sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==} peerDependencies: tailwindcss: '>=3.0.0 || insiders' @@ -17126,7 +17126,7 @@ packages: lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.3.2_ts-node@10.9.1 + tailwindcss: 3.4.1_ts-node@10.9.1 dev: true /@tanstack/query-core/5.0.0-beta.0: @@ -24571,6 +24571,7 @@ packages: glob-parent: 5.1.2 merge2: 1.4.1 micromatch: 4.0.5 + dev: true /fast-glob/3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} @@ -27833,6 +27834,10 @@ packages: resolution: {integrity: sha512-QAdOptna2NYiSSpv0O/BwoHBSmz4YhpzJHyi+fnMRTXFjp7B8i/YG5Z8IfusxB1ufjcD2Sre1F3R+nX3fvy7gg==} hasBin: true + /jiti/1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} + hasBin: true + /jju/1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} dev: false @@ -31528,17 +31533,6 @@ packages: read-cache: 1.0.0 resolve: 1.22.1 - /postcss-import/15.1.0_postcss@8.4.23: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - dependencies: - postcss: 8.4.23 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.4 - /postcss-import/15.1.0_postcss@8.4.29: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -31551,6 +31545,17 @@ packages: resolve: 1.22.4 dev: false + /postcss-import/15.1.0_postcss@8.4.31: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.31 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.4 + /postcss-js/4.0.0_postcss@8.4.21: resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==} engines: {node: ^12 || ^14 || >= 16} @@ -31561,15 +31566,6 @@ packages: postcss: 8.4.21 dev: true - /postcss-js/4.0.1_postcss@8.4.23: - resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - dependencies: - camelcase-css: 2.0.1 - postcss: 8.4.23 - /postcss-js/4.0.1_postcss@8.4.29: resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} @@ -31580,6 +31576,15 @@ packages: postcss: 8.4.29 dev: false + /postcss-js/4.0.1_postcss@8.4.31: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.31 + /postcss-load-config/3.1.4: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -31645,7 +31650,7 @@ packages: lilconfig: 2.1.0 yaml: 2.3.1 - /postcss-load-config/4.0.1_3fojqsmttcn75cbnzsztj3o6qa: + /postcss-load-config/4.0.1_7yjimpmjsytckvq3hfaydnm7um: resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} engines: {node: '>= 14'} peerDependencies: @@ -31658,7 +31663,7 @@ packages: optional: true dependencies: lilconfig: 2.1.0 - postcss: 8.4.23 + postcss: 8.4.31 ts-node: 10.9.1_v2fyojhutw2zqp3xtxirvaa4jq yaml: 2.3.1 @@ -31777,15 +31782,6 @@ packages: postcss-selector-parser: 6.0.11 dev: true - /postcss-nested/6.0.1_postcss@8.4.23: - resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - dependencies: - postcss: 8.4.23 - postcss-selector-parser: 6.0.11 - /postcss-nested/6.0.1_postcss@8.4.29: resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} engines: {node: '>=12.0'} @@ -31796,6 +31792,15 @@ packages: postcss-selector-parser: 6.0.11 dev: false + /postcss-nested/6.0.1_postcss@8.4.31: + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.31 + postcss-selector-parser: 6.0.11 + /postcss-safe-parser/6.0.0_postcss@8.4.29: resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} engines: {node: '>=12.0'} @@ -31853,14 +31858,6 @@ packages: picocolors: 1.0.0 source-map-js: 1.0.2 - /postcss/8.4.23: - resolution: {integrity: sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - /postcss/8.4.27: resolution: {integrity: sha512-gY/ACJtJPSmUFPDCHtX78+01fHa64FaU4zaaWfuh1MhGJISufJAH4cun6k/8fwsHYeK4UQmENQK+tRLCFJE8JQ==} engines: {node: ^10 || ^12 || >=14} @@ -31885,7 +31882,6 @@ packages: nanoid: 3.3.6 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /postgres-array/2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} @@ -33573,6 +33569,7 @@ packages: is-core-module: 2.13.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + dev: true /resolve/1.22.4: resolution: {integrity: sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==} @@ -35257,21 +35254,21 @@ packages: resolution: {integrity: sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==} dev: false - /tailwind-scrollbar/3.0.1_tailwindcss@3.3.2: + /tailwind-scrollbar/3.0.1_tailwindcss@3.4.1: resolution: {integrity: sha512-mM0ecSf/RGRGWw/qB0Zg1bWhuXIkpmleNAFgMxdb4eERgA6eQ0kVouYsF3/OvBqDSK8RJikZC/ynGPxnfXeddw==} engines: {node: '>=12.13.0'} peerDependencies: tailwindcss: 3.x dependencies: - tailwindcss: 3.3.2_ts-node@10.9.1 + tailwindcss: 3.4.1_ts-node@10.9.1 dev: true - /tailwindcss-animate/1.0.5_tailwindcss@3.3.2: + /tailwindcss-animate/1.0.5_tailwindcss@3.4.1: resolution: {integrity: sha512-UU3qrOJ4lFQABY+MVADmBm+0KW3xZyhMdRvejwtXqYOL7YjHYxmuREFAZdmVG5LPe5E9CAst846SLC4j5I3dcw==} peerDependencies: tailwindcss: '>=3.0.0 || insiders' dependencies: - tailwindcss: 3.3.2_ts-node@10.9.1 + tailwindcss: 3.4.1_ts-node@10.9.1 dev: false /tailwindcss/3.1.8_postcss@8.4.21: @@ -35307,37 +35304,6 @@ packages: - ts-node dev: true - /tailwindcss/3.3.2_ts-node@10.9.1: - resolution: {integrity: sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w==} - engines: {node: '>=14.0.0'} - hasBin: true - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.5.3 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.2.12 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.18.2 - lilconfig: 2.1.0 - micromatch: 4.0.5 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.23 - postcss-import: 15.1.0_postcss@8.4.23 - postcss-js: 4.0.1_postcss@8.4.23 - postcss-load-config: 4.0.1_3fojqsmttcn75cbnzsztj3o6qa - postcss-nested: 6.0.1_postcss@8.4.23 - postcss-selector-parser: 6.0.11 - postcss-value-parser: 4.2.0 - resolve: 1.22.2 - sucrase: 3.32.0 - transitivePeerDependencies: - - ts-node - /tailwindcss/3.3.3: resolution: {integrity: sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==} engines: {node: '>=14.0.0'} @@ -35369,6 +35335,36 @@ packages: - ts-node dev: false + /tailwindcss/3.4.1_ts-node@10.9.1: + resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.5.3 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.1 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.31 + postcss-import: 15.1.0_postcss@8.4.31 + postcss-js: 4.0.1_postcss@8.4.31 + postcss-load-config: 4.0.1_7yjimpmjsytckvq3hfaydnm7um + postcss-nested: 6.0.1_postcss@8.4.31 + postcss-selector-parser: 6.0.11 + resolve: 1.22.4 + sucrase: 3.32.0 + transitivePeerDependencies: + - ts-node + /tapable/2.2.1: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} @@ -37421,7 +37417,7 @@ packages: dependencies: '@types/node': 18.11.18 esbuild: 0.18.11 - postcss: 8.4.29 + postcss: 8.4.31 rollup: 3.29.1 optionalDependencies: fsevents: 2.3.3