V3 API keys page and Environment Variables (#907)

* 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
This commit is contained in:
Matt Aitken
2024-02-27 14:35:43 +00:00
committed by GitHub
parent 8d6e710dc2
commit d7c79ee651
20 changed files with 1993 additions and 162 deletions
@@ -15,7 +15,7 @@ export function EnvironmentLabel({
return (
<span
className={cn(
"inline-flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900",
"inline-flex h-4 items-center justify-center rounded-[2px] px-1 text-xxs font-medium uppercase tracking-wider text-midnight-900 whitespace-nowrap",
environmentColorClassName(environment),
className
)}
@@ -32,7 +32,7 @@ export function environmentTitle(environment: Environment, username?: string) {
case "STAGING":
return "Staging";
case "DEVELOPMENT":
return username ? `Dev: ${username}` : "Dev";
return username ? `Dev: ${username}` : "Dev: You";
case "PREVIEW":
return "Preview";
}
@@ -4,6 +4,8 @@ import {
ArrowRightOnRectangleIcon,
ChartBarIcon,
CursorArrowRaysIcon,
IdentificationIcon,
KeyIcon,
ShieldCheckIcon,
} from "@heroicons/react/20/solid";
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
@@ -36,6 +38,8 @@ import {
projectSettingsPath,
projectSetupPath,
projectTriggersPath,
v3ApiKeysPath,
v3EnvironmentVariablesPath,
v3ProjectPath,
v3RunsPath,
} from "~/utils/pathBuilder";
@@ -506,13 +510,21 @@ function V3ProjectSideMenu({
iconColor="text-teal-500"
to={v3RunsPath(organization, project)}
/>
{/* <SideMenuItem
name="Environments & API Keys"
icon="environment"
iconColor="text-rose-500"
to={projectEnvironmentsPath(organization, project)}
data-action="environments & api keys"
<SideMenuItem
name="API Keys"
icon={KeyIcon}
iconColor="text-amber-500"
to={v3ApiKeysPath(organization, project)}
data-action="api keys"
/>
<SideMenuItem
name="Environment variables"
icon={IdentificationIcon}
iconColor="text-pink-500"
to={v3EnvironmentVariablesPath(organization, project)}
data-action="environment variables"
/>
{/*
<SideMenuItem
name="Project settings"
icon="settings"
@@ -1,7 +1,7 @@
import { CheckIcon } from "@heroicons/react/20/solid";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "~/utils/cn";
import { Button } from "./Buttons";
import { useCallback, useRef, useState } from "react";
import { CheckIcon } from "@heroicons/react/20/solid";
import { IconNames, NamedIcon } from "./NamedIcon";
const variations = {
@@ -100,6 +100,10 @@ export function ClipboardField({
[value]
);
useEffect(() => {
setIsSecure(secure !== undefined && secure);
}, [secure]);
const { container, input, buttonVariant, button } = variations[variant];
const iconClassName = variations[variant].iconSize;
const iconPosition = variations[variant].iconPadding;
+19 -7
View File
@@ -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<any>;
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 <NamedIcon name={props.icon} className={props.className ?? ""} fallback={<></>} />;
}
const Icon = props.icon;
if (!Icon) {
return <></>;
if (typeof props.icon === "function") {
const Icon = props.icon;
return <Icon className={props.className} />;
}
return <Icon className={props.className} />;
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<FunctionComponent<any>>(props.icon as any, { className: props.className } as any);
}
console.error("Invalid icon", props);
return null;
}
export function IconInBox({ boxClassName, ...props }: IconProps & { boxClassName?: string }) {
+39 -28
View File
@@ -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<HTMLInputElement> & {
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, shortcut, fullWidth = true, variant = "medium", icon, ...props }, ref) => {
const innerRef = useRef<HTMLInputElement>(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 (
<div className={cn("relative", fullWidth ? "w-full" : "max-w-max")}>
<div
className={cn("flex items-center", containerBase, containerClassName, fullWidth ? "w-full" : "max-w-max")}
onClick={() => innerRef.current && innerRef.current.focus()}
>
{icon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center">
<div
className="pointer-events-none flex items-center"
>
<Icon icon={icon} className={cn(iconClassName, "text-dimmed")} />
</div>
)}
<input
type={type}
className={cn(inputClassName, icon ? iconOffsetClassName : "", className)}
ref={ref}
className={cn("grow", inputBase, inputClassName, className)}
ref={innerRef}
{...props}
/>
{shortcut && <div className={cn(shortcutClassName, "absolute")}>{shortcut}</div>}
{shortcut && <div className={cn(shortcutBase, shortcutClassName)}>{shortcut}</div>}
</div>
);
}
@@ -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<typeof Forms> = {
title: "Primitives/Forms",
@@ -57,6 +58,10 @@ export const Search: Story = {
render: (args) => <SearchForm />,
};
export const Inputs: Story = {
render: (args) => <InputFields />,
};
function Forms() {
return (
<MainCenteredContainer>
@@ -180,3 +185,69 @@ function SearchForm() {
</MainCenteredContainer>
);
}
function InputFields() {
return (
<div className="flex gap-16">
<InputFieldSet />
<InputFieldSet disabled />
</div>
);
}
function InputFieldSet({ disabled }: { disabled?: boolean }) {
return (
<div>
<div className="m-8 flex w-64 flex-col gap-4">
<Input disabled={disabled} variant="large" placeholder="Name" autoFocus type="text" />
<Input disabled={disabled} variant="medium" placeholder="Name" type="text" />
<Input disabled={disabled} variant="small" placeholder="Name" type="text" />
<Input disabled={disabled} variant="tertiary" placeholder="Name" type="text" />
</div>
<div className="m-8 flex w-64 flex-col gap-4">
<Input disabled={disabled} variant="large" placeholder="Search" icon="search" shortcut="⌘K" />
<Input disabled={disabled} variant="medium" placeholder="Search" icon="search" shortcut="⌘K" />
<Input disabled={disabled} variant="small" placeholder="Search" icon="search" shortcut="⌘K" />
<Input disabled={disabled} variant="tertiary" placeholder="Search" icon="search" shortcut="⌘K" />
</div>
<div className="m-8 flex w-64 flex-col gap-4">
<Input disabled={disabled}
variant="large"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
shortcut="⌘K"
/>
<Input disabled={disabled}
variant="medium"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
shortcut="⌘K"
/>
<Input disabled={disabled}
variant="small"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
shortcut="⌘K"
/>
<Input disabled={disabled}
variant="tertiary"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />}
shortcut="⌘K"
/>
<Input disabled={disabled}
variant="tertiary"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "STAGING" }} />}
shortcut="⌘K"
/>
<Input disabled={disabled}
variant="tertiary"
placeholder="Search"
icon={<EnvironmentLabel environment={{ type: "PRODUCTION" }} />}
shortcut="⌘K"
/>
</div>
</div>
);
}
@@ -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,
}))
),
};
}
}
@@ -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<ReturnType<EnvironmentVariablesPresenter["call"]>>;
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<string, { value: string | undefined; environment: { type: string; id: string } }>),
};
}),
environments: sortedEnvironments.map((environment) => ({
id: environment.id,
type: environment.type,
})),
};
}
}
@@ -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) => <BreadcrumbLink to={match.pathname} title="Environments & API Keys" />,
};
export default function Page() {
const { environments } = useTypedLoaderData<typeof loader>();
const project = useProject();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="API Keys" />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("/documentation/concepts/environments-endpoints#environments")}
variant="secondary/small"
>
API keys docs
</LinkButton>
</PageButtons>
</PageTitleRow>
</PageHeader>
<PageBody>
<div className={cn("h-full")}>
<Header3 spacing>Server API keys</Header3>
<Paragraph variant="small" spacing>
Server API keys should be used on your server they give full API access.
</Paragraph>
<Header3 spacing>Public API keys</Header3>
<Paragraph variant="small" spacing>
These keys have limited read-only access and should be used in your frontend.
</Paragraph>
<div className="mt-4 flex flex-col gap-6">
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Environment</TableHeaderCell>
<TableHeaderCell>Server API key</TableHeaderCell>
<TableHeaderCell>Public API key</TableHeaderCell>
<TableHeaderCell>Keys generated</TableHeaderCell>
<TableHeaderCell>Latest version</TableHeaderCell>
<TableHeaderCell>Env vars</TableHeaderCell>
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environments.map((environment) => (
<TableRow key={environment.id}>
<TableCell>
<EnvironmentLabel environment={environment} />
</TableCell>
<TableCell>
<ClipboardField
className="w-full max-w-none"
secure
value={environment.apiKey}
variant={"tertiary/small"}
/>
</TableCell>
<TableCell>
<ClipboardField
className="w-full max-w-none"
value={environment.pkApiKey}
variant={"tertiary/small"}
/>
</TableCell>
<TableCell>
<DateTime date={environment.updatedAt} />
</TableCell>
<TableCell>{environment.latestVersion ?? ""}</TableCell>
<TableCell>{environment.environmentVariableCount}</TableCell>
<TableCellMenu isSticky>
<RegenerateApiKeyModal
id={environment.id}
title={environmentTitle(environment)}
/>
</TableCellMenu>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</PageBody>
</PageContainer>
);
}
@@ -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<typeof loader>();
const lastSubmission = useActionData();
const navigation = useNavigation();
const navigate = useNavigate();
const organization = useOrganization();
const project = useProject();
const keyFieldRef = useRef<HTMLInputElement>(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 (
<Dialog
open={isOpen}
onOpenChange={(o) => {
if (!o) {
navigate(v3EnvironmentVariablesPath(organization, project));
}
}}
>
<DialogContent>
<DialogHeader>New environment variable</DialogHeader>
<Form method="post" {...form.props}>
<Fieldset className="mt-2">
<InputGroup fullWidth>
<Label>Key</Label>
<Input
{...conform.input(key)}
placeholder="e.g. CLIENT_KEY"
autoFocus
ref={keyFieldRef}
/>
</InputGroup>
<InputGroup fullWidth>
<Label>Values</Label>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
{environments.map((environment, index) => {
return (
<Fragment key={environment.id}>
<input
type="hidden"
name={`values[${index}].environmentId`}
value={environment.id}
/>
<label className="flex items-center justify-end" htmlFor={`values[${index}].value`}>
<EnvironmentLabel environment={environment} className="h-5 px-2" />
</label>
<Input
name={`values[${index}].value`}
placeholder="Not set"
/>
</Fragment>
);
})}
</div>
</InputGroup>
<FormError id={key.errorId}>{key.error}</FormError>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<div className="flex flex-row-reverse items-center gap-2">
<Button
type="submit"
variant="primary/small"
disabled={isLoading}
name="action"
value="create-more"
>
{isLoading ? "Saving" : "Save and add another"}
</Button>
<Button
variant="secondary/small"
disabled={isLoading}
name="action"
value="create"
>
{isLoading ? "Saving" : "Save"}
</Button>
</div>
}
cancelButton={
<LinkButton
to={v3EnvironmentVariablesPath(organization, project)}
variant="secondary/small"
>
Cancel
</LinkButton>
}
/>
</Fieldset>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -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) => <BreadcrumbLink to={match.pathname} title="Environments & API Keys" />,
};
export default function Page() {
const [revealAll, setRevealAll] = useState(false);
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
const project = useProject();
const organization = useOrganization();
return (
<PageContainer>
<PageHeader>
<PageTitleRow>
<PageTitle title="Environment variables" />
<PageButtons>
<LinkButton
LeadingIcon={"docs"}
to={docsPath("/documentation/concepts/environments-endpoints#environments")}
variant="secondary/small"
>
Environment variables docs
</LinkButton>
</PageButtons>
</PageTitleRow>
</PageHeader>
<PageBody>
<div className={cn("flex h-full flex-col gap-3")}>
<div className="flex items-center justify-end gap-2">
<Switch
variant="small"
label="Reveal values"
checked={revealAll}
onCheckedChange={(e) => setRevealAll(e.valueOf())}
/>
<LinkButton
to={v3NewEnvironmentVariablesPath(organization, project)}
variant="primary/small"
LeadingIcon="plus"
leadingIconClassName="text-white"
>
New environment variable
</LinkButton>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
{environments.map((environment) => (
<TableHeaderCell key={environment.id}>
<EnvironmentLabel environment={environment} />
</TableHeaderCell>
))}
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{environmentVariables.length > 0 ? (
environmentVariables.map((variable) => (
<TableRow key={variable.id}>
<TableCell>{variable.key}</TableCell>
{environments.map((environment) => {
const value = variable.values[environment.id]?.value;
if (!value) {
return <TableCell key={environment.id}>Not set</TableCell>;
}
return (
<TableCell key={environment.id}>
<ClipboardField
className="w-full max-w-none"
secure={!revealAll}
value={value}
variant={"tertiary/small"}
/>
</TableCell>
);
})}
<TableCellMenu isSticky>
<EditEnvironmentVariablePanel
environments={environments}
variable={variable}
/>
<DeleteEnvironmentVariableButton variable={variable} />
</TableCellMenu>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={environments.length + 2}>
<div className="flex items-center justify-center">
<Paragraph>No environment variables have been set</Paragraph>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<Outlet />
</PageBody>
</PageContainer>
);
}
function EditEnvironmentVariablePanel({
variable,
environments,
}: {
variable: EnvironmentVariableWithSetValues;
environments: Pick<RuntimeEnvironment, "id" | "type">[];
}) {
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="small-menu-item"
LeadingIcon={PencilSquareIcon}
leadingIconClassName="text-slate-500"
className="text-xs"
fullWidth
textAlignLeft
>
Edit
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
Edit {variable.key}
</DialogHeader>
<Form method="post" {...form.props}>
<input type="hidden" name="action" value="edit" />
<input type="hidden" name="id" value={variable.id} />
<input type="hidden" name="key" value={variable.key} />
<FormError id={id.errorId}>{id.error}</FormError>
<Fieldset>
<InputGroup fullWidth className="mt-2 mb-4">
<Label>Key</Label>
<InlineCode>{variable.key}</InlineCode>
</InputGroup>
</Fieldset>
<Fieldset>
<InputGroup fullWidth>
<Label>Values</Label>
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
{environments.map((environment, index) => {
const value = variable.values[environment.id]?.value;
return (
<Fragment key={environment.id}>
<input
type="hidden"
name={`values[${index}].environmentId`}
value={environment.id}
/>
<label className="flex items-center justify-end" htmlFor={`values[${index}].value`}>
<EnvironmentLabel environment={environment} className="h-5 px-2" />
</label>
<Input
name={`values[${index}].value`}
placeholder="Not set"
defaultValue={value}
/>
</Fragment>
);
})}
</div>
</InputGroup>
<FormError>{form.error}</FormError>
<FormButtons
confirmButton={
<Button type="submit" variant="primary/small" disabled={isLoading}>
{isLoading ? "Saving" : "Edit"}
</Button>
}
cancelButton={
<Button
onClick={() => setIsOpen(false)}
variant="secondary/small"
type="button"
>
Cancel
</Button>
}
/>
</Fieldset>
</Form>
</DialogContent>
</Dialog>
);
}
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 (
<Form method="post" {...form.props}>
<input type="hidden" name="id" value={variable.id} />
<input type="hidden" name="key" value={variable.key} />
<Button
name="action"
value="delete"
type="submit"
variant="small-menu-item"
LeadingIcon={TrashIcon}
leadingIconClassName="text-rose-500"
className="text-xs"
>
{isLoading ? "Deleting" : "Delete"}
</Button>
</Form>
);
}
@@ -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<typeof SecretStoreOptionsSchema>;
@@ -18,10 +19,12 @@ type ProviderInitializationOptions = {
export interface SecretStoreProvider {
getSecret<T>(schema: z.Schema<T>, key: string): Promise<T | undefined>;
getSecrets<T>(schema: z.Schema<T>, keyPrefix: string): Promise<{ key: string; value: T }[]>;
setSecret<T extends object>(key: string, value: T): Promise<void>;
deleteSecret(key: string): Promise<void>;
}
/** 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<T extends object>(key: string, value: T): Promise<void> {
return this.provider.setSecret(key, value);
}
getSecrets<T>(schema: z.Schema<T>, keyPrefix: string): Promise<{ key: string; value: T }[]> {
return this.provider.getSecrets(schema, keyPrefix);
}
deleteSecret(key: string): Promise<void> {
return this.provider.deleteSecret(key);
}
}
const EncryptedSecretValueSchema = z.object({
@@ -97,6 +108,51 @@ class PrismaSecretStore implements SecretStoreProvider {
return schema.parse(parsedDecrypted);
}
async getSecrets<T>(
schema: z.Schema<T>,
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<T extends object>(key: string, value: T): Promise<void> {
const encrypted = await this.#encrypt(JSON.stringify(value));
@@ -116,6 +172,14 @@ class PrismaSecretStore implements SecretStoreProvider {
});
}
async deleteSecret(key: string): Promise<void> {
await this.#prismaClient.secretStore.delete({
where: {
key,
},
});
}
async #decrypt(nonce: string, ciphertext: string, tag: string): Promise<string> {
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": {
+12
View File
@@ -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,
@@ -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<Result> {
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<Result> {
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<ProjectEnvironmentVariable[]> {
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<EnvironmentVariable[]> {
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<Result> {
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",
};
}
}
}
@@ -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<typeof CreateEnvironmentVariable>;
export const EditEnvironmentVariable = z.object({
id: z.string(),
values: z.array(
z.object({
environmentId: z.string(),
value: z.string(),
})
),
});
export type EditEnvironmentVariable = z.infer<typeof EditEnvironmentVariable>;
export const DeleteEnvironmentVariable = z.object({
id: z.string(),
});
export type DeleteEnvironmentVariable = z.infer<typeof DeleteEnvironmentVariable>;
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<Result>;
edit(projectId: string, userId: string, options: EditEnvironmentVariable): Promise<Result>;
getProject(projectId: string, userId: string): Promise<ProjectEnvironmentVariable[]>;
getEnvironment(
projectId: string,
userId: string,
environmentId: string
): Promise<EnvironmentVariable[]>;
delete(projectId: string, userId: string, options: DeleteEnvironmentVariable): Promise<Result>;
}
+1 -1
View File
@@ -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"
@@ -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;
@@ -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");
+55 -25
View File
@@ -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])
}
+85 -89
View File
@@ -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