diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/CreateTargetVariableDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/CreateTargetVariableDialog.tsx new file mode 100644 index 000000000..9ceb145d5 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/CreateTargetVariableDialog.tsx @@ -0,0 +1,221 @@ +import React, { useState } from "react"; +import { z } from "zod"; + +import { Button } from "@ctrlplane/ui/button"; +import { Checkbox } from "@ctrlplane/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; + +import { api } from "~/trpc/react"; + +type CreateTargetVariableDialogProps = { + targetId: string; + existingKeys: string[]; + children: React.ReactNode; +}; + +export const CreateTargetVariableDialog: React.FC< + CreateTargetVariableDialogProps +> = ({ targetId, existingKeys, children }) => { + const [open, setOpen] = useState(false); + const createTargetVariable = api.target.variable.create.useMutation(); + const schema = z.object({ + key: z + .string() + .refine((k) => k.length > 0, { message: "Key is required" }) + .refine((k) => !existingKeys.includes(k), { + message: "Variable key must be unique", + }), + type: z.enum(["string", "number", "boolean"]), + value: z + .union([z.string(), z.number(), z.boolean()]) + .refine((v) => (typeof v === "string" ? v.length > 0 : true), { + message: "Value is required", + }), + sensitive: z.boolean().default(false), + }); + const form = useForm({ + schema, + defaultValues: { key: "", value: "", type: "string" }, + }); + const { sensitive, type } = form.watch(); + + const utils = api.useUtils(); + const onSubmit = form.handleSubmit((data) => + createTargetVariable + .mutateAsync({ targetId, ...data }) + .then(() => utils.target.byId.invalidate(targetId)) + .then(() => form.reset()) + .then(() => setOpen(false)), + ); + + return ( + + {children} + + Create Target Variable +
+ + ( + + Key + + + + + + )} + /> + { + const onTypeChange = (type: string) => { + if (type === "string") form.setValue("value", ""); + if (type === "number") form.setValue("value", 0); + if (type === "boolean") form.setValue("value", false); + if (type !== "string") form.setValue("sensitive", false); + onChange(type); + }; + + return ( + + Type + + + + + + ); + }} + /> + + {type === "string" && ( + ( + + Value + + + + + + )} + /> + )} + + {type === "number" && ( + ( + + Value + + + + + )} + /> + )} + + {type === "boolean" && ( + ( + + Value + + + + + )} + /> + )} + + {type === "string" && ( + ( + + +
+ + +
+
+
+ )} + /> + )} + + + + + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/DeleteTargetVariableDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/DeleteTargetVariableDialog.tsx new file mode 100644 index 000000000..0f5b23a2a --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/DeleteTargetVariableDialog.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTrigger, +} from "@ctrlplane/ui/alert-dialog"; +import { buttonVariants } from "@ctrlplane/ui/button"; + +import { api } from "~/trpc/react"; + +type DeleteTargetVariableDialogProps = { + variableId: string; + targetId: string; + onClose: () => void; + children: React.ReactNode; +}; + +export const DeleteTargetVariableDialog: React.FC< + DeleteTargetVariableDialogProps +> = ({ variableId, targetId, onClose, children }) => { + const [open, setOpen] = useState(false); + const deleteTargetVariable = api.target.variable.delete.useMutation(); + const utils = api.useUtils(); + + const onDelete = () => + deleteTargetVariable + .mutateAsync(variableId) + .then(() => utils.target.byId.invalidate(targetId)) + .then(() => setOpen(false)); + + return ( + { + setOpen(o); + if (!o) onClose(); + }} + > + {children} + + Are you sure? + + Deleting a target variable can change what values are passed to + pipelines running for this target. + + + + Cancel +
+ + Delete + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/EditTargetVariableDialog.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/EditTargetVariableDialog.tsx new file mode 100644 index 000000000..76602fbc8 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/EditTargetVariableDialog.tsx @@ -0,0 +1,243 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React, { useState } from "react"; +import { z } from "zod"; + +import { Button } from "@ctrlplane/ui/button"; +import { Checkbox } from "@ctrlplane/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTrigger, +} from "@ctrlplane/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + useForm, +} from "@ctrlplane/ui/form"; +import { Input } from "@ctrlplane/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@ctrlplane/ui/select"; + +import { api } from "~/trpc/react"; + +type EditTargetVariableDialogProps = { + targetVariable: SCHEMA.TargetVariable; + existingKeys: string[]; + children: React.ReactNode; + onClose: () => void; +}; + +export const EditTargetVariableDialog: React.FC< + EditTargetVariableDialogProps +> = ({ targetVariable, existingKeys, children, onClose }) => { + const [open, setOpen] = useState(false); + const updateTargetVariable = api.target.variable.update.useMutation(); + const utils = api.useUtils(); + const keysWithoutCurrent = existingKeys.filter( + (k) => k !== targetVariable.key, + ); + const schema = z.object({ + key: z + .string() + .refine((k) => k.length > 0, { message: "Key is required" }) + .refine((k) => !keysWithoutCurrent.includes(k), { + message: "Variable key must be unique", + }), + type: z.enum(["string", "number", "boolean"]), + value: z + .union([z.string(), z.number(), z.boolean()]) + .refine((v) => (typeof v === "string" ? v.length > 0 : true), { + message: "Value is required", + }), + sensitive: z.boolean(), + }); + const defaultValues = { + key: targetVariable.key, + type: typeof targetVariable.value, + value: targetVariable.value, + sensitive: targetVariable.sensitive, + }; + const form = useForm({ schema, defaultValues }); + + const onSubmit = form.handleSubmit((data) => + updateTargetVariable + .mutateAsync({ id: targetVariable.id, data }) + .then(() => form.reset(data)) + .then(() => utils.target.byId.invalidate(targetVariable.targetId)) + .then(() => setOpen(false)), + ); + + const { sensitive, type } = form.watch(); + + return ( + { + setOpen(o); + if (!o) onClose(); + }} + > + {children} + + Edit Target Variable +
+ + ( + + Key + + + + + + )} + /> + { + const onTypeChange = (type: string) => { + if (type === "string") form.setValue("value", ""); + if (type === "number") form.setValue("value", 0); + if (type === "boolean") form.setValue("value", false); + if (type !== "string") form.setValue("sensitive", false); + onChange(type); + }; + + return ( + + Type + + + + + + ); + }} + /> + + {type === "string" && ( + ( + + Value + + + + + + )} + /> + )} + + {type === "number" && ( + ( + + Value + + + + + )} + /> + )} + + {type === "boolean" && ( + ( + + Value + + + + + )} + /> + )} + + { + const onSensitiveChange = (checked: boolean) => { + if (!checked) form.setValue("value", ""); + onChange(checked); + }; + return ( + + +
+ + +
+
+
+ ); + }} + /> + + + + + + +
+
+ ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetDrawer.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetDrawer.tsx index 81ab4dcf0..8b1927780 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetDrawer.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetDrawer.tsx @@ -191,7 +191,10 @@ export const TargetDrawer: React.FC = () => { )} {activeTab === "jobs" && } {activeTab === "variables" && ( - + )}
diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetVariableDropdown.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetVariableDropdown.tsx new file mode 100644 index 000000000..0dc80ced6 --- /dev/null +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/TargetVariableDropdown.tsx @@ -0,0 +1,61 @@ +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React, { useState } from "react"; +import { IconPencil, IconTrash } from "@tabler/icons-react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ctrlplane/ui/dropdown-menu"; + +import { DeleteTargetVariableDialog } from "./DeleteTargetVariableDialog"; +import { EditTargetVariableDialog } from "./EditTargetVariableDialog"; + +type TargetVariableDropdownProps = { + targetVariable: SCHEMA.TargetVariable; + existingKeys: string[]; + children: React.ReactNode; +}; + +export const TargetVariableDropdown: React.FC = ({ + targetVariable, + existingKeys, + children, +}) => { + const [open, setOpen] = useState(false); + + return ( + + {children} + + setOpen(false)} + > + e.preventDefault()} + > + + Edit + + + setOpen(false)} + > + e.preventDefault()} + > + + Delete + + + + + ); +}; diff --git a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/VariablesContent.tsx b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/VariablesContent.tsx index 0a645ed15..d8f874f8d 100644 --- a/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/VariablesContent.tsx +++ b/apps/webservice/src/app/[workspaceSlug]/_components/target-drawer/VariablesContent.tsx @@ -1,5 +1,10 @@ "use client"; +import type * as SCHEMA from "@ctrlplane/db/schema"; +import React from "react"; +import { IconDots, IconLock, IconPlus } from "@tabler/icons-react"; + +import { Button } from "@ctrlplane/ui/button"; import { Table, TableBody, @@ -10,60 +15,143 @@ import { } from "@ctrlplane/ui/table"; import { api } from "~/trpc/react"; +import { CreateTargetVariableDialog } from "./CreateTargetVariableDialog"; +import { TargetVariableDropdown } from "./TargetVariableDropdown"; + +const TargetVariableSection: React.FC<{ + targetId: string; + targetVariables: SCHEMA.TargetVariable[]; +}> = ({ targetId, targetVariables }) => ( +
+
+ Target Variables + v.key)} + > + + +
+ + + + Key + Value + + + + + {targetVariables.map((v) => ( + + + {v.key} + {v.sensitive && ( + + )} + + {v.sensitive ? "*****" : String(v.value)} + +
+ v.key)} + > + + +
+
+
+ ))} +
+
+
+); const VariableRow: React.FC<{ varKey: string; description: string; value?: string | null; -}> = ({ varKey, description, value }) => ( + isTargetVar: boolean; + sensitive: boolean; +}> = ({ varKey, description, value, isTargetVar, sensitive }) => ( - +
-
{varKey}
+ {varKey} + {sensitive && }
{description}
- - {value ??
NULL
} + + {sensitive &&
*****
} + {!sensitive && + (value ??
NULL
)} + {isTargetVar && ( +
(target)
+ )} + {!isTargetVar && ( +
(deployment)
+ )}
); -export const VariableContent: React.FC<{ targetId: string }> = ({ - targetId, -}) => { +export const VariableContent: React.FC<{ + targetId: string; + targetVariables: SCHEMA.TargetVariable[]; +}> = ({ targetId, targetVariables }) => { const deployments = api.deployment.byTargetId.useQuery(targetId); const variables = api.deployment.variable.byTargetId.useQuery(targetId); return ( -
- {deployments.data?.map((deployment) => { - return ( -
-
{deployment.name}
- - - - - Keys - Value - - - - {variables.data - ?.filter((v) => v.deploymentId === deployment.id) - .map((v) => ( - - ))} - -
-
- ); - })} +
+ +
Deployment Variables
+ {deployments.data + ?.filter((d) => { + const vars = variables.data?.filter((v) => v.deploymentId === d.id); + return vars != null && vars.length > 0; + }) + .map((deployment) => { + return ( +
+
{deployment.name}
+ + + + Keys + Value + + + + {variables.data + ?.filter((v) => v.deploymentId === deployment.id) + .map((v) => { + const targetVar = targetVariables.find( + (tv) => tv.key === v.key, + ); + return ( + + ); + })} + +
+
+ ); + })}
); }; diff --git a/packages/api/package.json b/packages/api/package.json index c0dd467de..691eef381 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -23,6 +23,7 @@ "@ctrlplane/db": "workspace:*", "@ctrlplane/job-dispatch": "workspace:*", "@ctrlplane/logger": "workspace:*", + "@ctrlplane/secrets": "workspace:*", "@ctrlplane/validators": "workspace:*", "@octokit/auth-app": "^7.1.0", "@octokit/rest": "catalog:", diff --git a/packages/api/src/router/target.ts b/packages/api/src/router/target.ts index 3911c1adf..d670a487c 100644 --- a/packages/api/src/router/target.ts +++ b/packages/api/src/router/target.ts @@ -15,6 +15,7 @@ import { takeFirstOrNull, } from "@ctrlplane/db"; import * as schema from "@ctrlplane/db/schema"; +import { variablesAES256 } from "@ctrlplane/secrets"; import { Permission } from "@ctrlplane/validators/auth"; import { targetCondition } from "@ctrlplane/validators/targets"; @@ -184,6 +185,79 @@ const targetViews = createTRPCRouter({ }), }); +const targetVariables = createTRPCRouter({ + create: protectedProcedure + .input(schema.createTargetVariable) + .meta({ + authorizationCheck: ({ canUser, input }) => + canUser + .perform(Permission.TargetUpdate) + .on({ type: "target", id: input.targetId }), + }) + .mutation(async ({ ctx, input }) => { + const { sensitive } = input; + const value = sensitive + ? variablesAES256().encrypt(String(input.value)) + : input.value; + const data = { ...input, value }; + return ctx.db.insert(schema.targetVariable).values(data).returning(); + }), + + update: protectedProcedure + .input( + z.object({ id: z.string().uuid(), data: schema.updateTargetVariable }), + ) + .meta({ + authorizationCheck: async ({ ctx, canUser, input }) => { + const variable = await ctx.db + .select() + .from(schema.targetVariable) + .where(eq(schema.targetVariable.id, input.id)) + .then(takeFirstOrNull); + if (!variable) return false; + + return canUser + .perform(Permission.TargetUpdate) + .on({ type: "target", id: variable.targetId }); + }, + }) + .mutation(async ({ ctx, input }) => { + const { sensitive } = input.data; + const value = sensitive + ? variablesAES256().encrypt(String(input.data.value)) + : input.data.value; + const data = { ...input.data, value }; + return ctx.db + .update(schema.targetVariable) + .set(data) + .where(eq(schema.targetVariable.id, input.id)) + .returning() + .then(takeFirst); + }), + + delete: protectedProcedure + .input(z.string().uuid()) + .meta({ + authorizationCheck: async ({ ctx, canUser, input }) => { + const variable = await ctx.db + .select() + .from(schema.targetVariable) + .where(eq(schema.targetVariable.id, input)) + .then(takeFirstOrNull); + if (!variable) return false; + + return canUser + .perform(Permission.TargetUpdate) + .on({ type: "target", id: variable.targetId }); + }, + }) + .mutation(async ({ ctx, input }) => + ctx.db + .delete(schema.targetVariable) + .where(eq(schema.targetVariable.id, input)), + ), +}); + type _StringStringRecord = Record; const targetQuery = (db: Tx, checks: Array>) => db @@ -218,6 +292,7 @@ export const targetRouter = createTRPCRouter({ provider: targetProviderRouter, relations: targetRelations, view: targetViews, + variable: targetVariables, byId: protectedProcedure .meta({ @@ -225,27 +300,19 @@ export const targetRouter = createTRPCRouter({ canUser.perform(Permission.TargetGet).on({ type: "target", id: input }), }) .input(z.string().uuid()) - .query(async ({ ctx, input }) => { - const metadata = await ctx.db - .select() - .from(schema.targetMetadata) - .where(eq(schema.targetMetadata.targetId, input)) - .then((lbs) => Object.fromEntries(lbs.map((lb) => [lb.key, lb.value]))); - return ctx.db - .select() - .from(schema.target) - .leftJoin( - schema.targetProvider, - eq(schema.target.providerId, schema.targetProvider.id), - ) - .where(eq(schema.target.id, input)) - .then(takeFirstOrNull) - .then((a) => - a == null - ? null - : { ...a.target, metadata, provider: a.target_provider }, - ); - }), + .query(({ ctx, input }) => + ctx.db.query.target + .findFirst({ + where: eq(schema.target.id, input), + with: { metadata: true, variables: true, provider: true }, + }) + .then((t) => { + if (t == null) return null; + const pairs = t.metadata.map((m) => [m.key, m.value]); + const metadata = Object.fromEntries(pairs); + return { ...t, metadata }; + }), + ), byWorkspaceId: createTRPCRouter({ list: protectedProcedure diff --git a/packages/db/src/schema/target-provider.ts b/packages/db/src/schema/target-provider.ts index 01fd8c5e9..cc2279aa0 100644 --- a/packages/db/src/schema/target-provider.ts +++ b/packages/db/src/schema/target-provider.ts @@ -1,4 +1,5 @@ import type { InferSelectModel } from "drizzle-orm"; +import { relations } from "drizzle-orm"; import { pgTable, text, @@ -9,6 +10,7 @@ import { import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { target } from "./target.js"; import { workspace } from "./workspace.js"; export const targetProvider = pgTable( @@ -24,6 +26,11 @@ export const targetProvider = pgTable( (t) => ({ uniq: uniqueIndex().on(t.workspaceId, t.name) }), ); +export const targetProviderRelations = relations( + targetProvider, + ({ many }) => ({ targets: many(target) }), +); + export const createTargetProvider = createInsertSchema(targetProvider).omit({ id: true, }); diff --git a/packages/db/src/schema/target.ts b/packages/db/src/schema/target.ts index 1f029fa29..2d0b18a04 100644 --- a/packages/db/src/schema/target.ts +++ b/packages/db/src/schema/target.ts @@ -3,7 +3,7 @@ import type { TargetCondition, } from "@ctrlplane/validators/targets"; import type { InferInsertModel, InferSelectModel, SQL } from "drizzle-orm"; -import { exists, like, not, notExists, or, sql } from "drizzle-orm"; +import { exists, like, not, notExists, or, relations, sql } from "drizzle-orm"; import { boolean, json, @@ -51,6 +51,15 @@ export const target = pgTable( (t) => ({ uniq: uniqueIndex().on(t.identifier, t.workspaceId) }), ); +export const targetRelations = relations(target, ({ one, many }) => ({ + metadata: many(targetMetadata), + variables: many(targetVariable), + provider: one(targetProvider, { + fields: [target.providerId], + references: [targetProvider.id], + }), +})); + export type Target = InferSelectModel; export const createTarget = createInsertSchema(target, { @@ -110,6 +119,13 @@ export const targetMetadata = pgTable( (t) => ({ uniq: uniqueIndex().on(t.key, t.targetId) }), ); +export const targetMetadataRelations = relations(targetMetadata, ({ one }) => ({ + target: one(target, { + fields: [targetMetadata.targetId], + references: [target.id], + }), +})); + const buildMetadataCondition = (tx: Tx, cond: MetadataCondition): SQL => { if (cond.operator === "null") return notExists( @@ -227,15 +243,22 @@ export const targetVariable = pgTable( .notNull(), key: text("key").notNull(), - value: jsonb("value").notNull(), + value: jsonb("value").$type().notNull(), sensitive: boolean("sensitive").notNull().default(false), }, (t) => ({ uniq: uniqueIndex().on(t.targetId, t.key) }), ); -export const createTargetVariable = createInsertSchema(targetVariable).omit({ - id: true, -}); +export const targetVariableRelations = relations(targetVariable, ({ one }) => ({ + target: one(target, { + fields: [targetVariable.targetId], + references: [target.id], + }), +})); + +export const createTargetVariable = createInsertSchema(targetVariable, { + value: z.union([z.string(), z.number(), z.boolean()]), +}).omit({ id: true }); export const updateTargetVariable = createTargetVariable.partial(); export type TargetVariable = InferSelectModel; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fa80037d..d5f564204 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -890,6 +890,9 @@ importers: '@ctrlplane/logger': specifier: workspace:* version: link:../logger + '@ctrlplane/secrets': + specifier: workspace:* + version: link:../secrets '@ctrlplane/validators': specifier: workspace:* version: link:../validators