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:
@@ -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;
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
+145
@@ -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>
|
||||
);
|
||||
}
|
||||
+231
@@ -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>
|
||||
);
|
||||
}
|
||||
+416
@@ -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": {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+40
@@ -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;
|
||||
+12
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Generated
+85
-89
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user