From ab94c6b2ff8a920d655d217bb8800a0dec835e2d Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 4 Jun 2026 12:32:12 +0200 Subject: [PATCH 1/4] feat(icons): add VaultIcon From design system Icon/20px/Vault. Used by the upcoming Secrets page sidebar entry and empty state. --- src/ui/primitives/icons.tsx | 39 +++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/ui/primitives/icons.tsx b/src/ui/primitives/icons.tsx index 126e7797a..98c95a35a 100644 --- a/src/ui/primitives/icons.tsx +++ b/src/ui/primitives/icons.tsx @@ -2109,3 +2109,42 @@ export const WebhookIcon = ({ className, ...props }: IconProps) => ( ) + +export const VaultIcon = ({ className, ...props }: IconProps) => ( + + + + + + + +) From 539c878c707a89eca29451170397fc97c67a7826 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 4 Jun 2026 12:38:48 +0200 Subject: [PATCH 2/4] feat(secrets): scaffold page route, sidebar entry, layout config Add the Secrets page skeleton: route + error boundary under /dashboard/{teamSlug}/secrets, sidebar entry in the integration group with the new VaultIcon, layout title config, and the empty-state page content (search input, helper copy, total label, table with 'No secrets added yet'). The 'Add a secret' button is rendered but inert; the next commit wires it to the new-secret dialog. tRPC list query lands with the BE ticket. --- .../dashboard/[teamSlug]/secrets/error.tsx | 13 +++ src/app/dashboard/[teamSlug]/secrets/page.tsx | 16 +++ src/configs/layout.ts | 4 + src/configs/sidebar.ts | 8 ++ src/configs/urls.ts | 1 + .../dashboard/settings/secrets/constants.ts | 8 ++ .../settings/secrets/secrets-page-content.tsx | 106 ++++++++++++++++++ .../dashboard/settings/secrets/table.tsx | 80 +++++++++++++ .../dashboard/settings/secrets/types.ts | 10 ++ 9 files changed, 246 insertions(+) create mode 100644 src/app/dashboard/[teamSlug]/secrets/error.tsx create mode 100644 src/app/dashboard/[teamSlug]/secrets/page.tsx create mode 100644 src/features/dashboard/settings/secrets/constants.ts create mode 100644 src/features/dashboard/settings/secrets/secrets-page-content.tsx create mode 100644 src/features/dashboard/settings/secrets/table.tsx create mode 100644 src/features/dashboard/settings/secrets/types.ts diff --git a/src/app/dashboard/[teamSlug]/secrets/error.tsx b/src/app/dashboard/[teamSlug]/secrets/error.tsx new file mode 100644 index 000000000..6c4b5e338 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/secrets/error.tsx @@ -0,0 +1,13 @@ +'use client' + +import { DashboardRouteError } from '@/features/dashboard/shared/route-error' + +export default function SecretsPageError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return +} diff --git a/src/app/dashboard/[teamSlug]/secrets/page.tsx b/src/app/dashboard/[teamSlug]/secrets/page.tsx new file mode 100644 index 000000000..0bbe7a959 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/secrets/page.tsx @@ -0,0 +1,16 @@ +import { Page } from '@/features/dashboard/layouts/page' +import { SecretsPageContent } from '@/features/dashboard/settings/secrets/secrets-page-content' + +interface SecretsPageProps { + params: Promise<{ + teamSlug: string + }> +} + +export default async function SecretsPage(_props: SecretsPageProps) { + return ( + + + + ) +} diff --git a/src/configs/layout.ts b/src/configs/layout.ts index 0f29ab80c..ce6231aa9 100644 --- a/src/configs/layout.ts +++ b/src/configs/layout.ts @@ -91,6 +91,10 @@ const DASHBOARD_LAYOUT_CONFIGS: Record< }, // integrations + '/dashboard/*/secrets': () => ({ + title: 'Secrets', + type: 'default', + }), '/dashboard/*/webhooks': () => ({ title: 'Webhooks', type: 'default', diff --git a/src/configs/sidebar.ts b/src/configs/sidebar.ts index 7ca6f3ed0..ca76e3d86 100644 --- a/src/configs/sidebar.ts +++ b/src/configs/sidebar.ts @@ -9,6 +9,7 @@ import { SettingsIcon, TemplateIcon, UsageIcon, + VaultIcon, WebhookIcon, } from '@/ui/primitives/icons' import { INCLUDE_ARGUS, INCLUDE_BILLING } from './flags' @@ -43,6 +44,13 @@ export const SIDEBAR_MAIN_LINKS: SidebarNavItem[] = [ }, // Integrations + { + label: 'Secrets', + group: 'integration', + href: (args) => PROTECTED_URLS.SECRETS(args.teamSlug!), + icon: VaultIcon, + activeMatch: `/dashboard/*/secrets`, + }, ...(INCLUDE_ARGUS ? [ { diff --git a/src/configs/urls.ts b/src/configs/urls.ts index 9412b2a97..ff0e68ca6 100644 --- a/src/configs/urls.ts +++ b/src/configs/urls.ts @@ -39,6 +39,7 @@ export const PROTECTED_URLS = { `/dashboard/${teamSlug}/sandboxes/${sandboxId}/filesystem`, WEBHOOKS: (teamSlug: string) => `/dashboard/${teamSlug}/webhooks`, + SECRETS: (teamSlug: string) => `/dashboard/${teamSlug}/secrets`, TEMPLATES: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`, TEMPLATES_LIST: (teamSlug: string) => `/dashboard/${teamSlug}/templates/list`, diff --git a/src/features/dashboard/settings/secrets/constants.ts b/src/features/dashboard/settings/secrets/constants.ts new file mode 100644 index 000000000..bc009a0ec --- /dev/null +++ b/src/features/dashboard/settings/secrets/constants.ts @@ -0,0 +1,8 @@ +export const MAX_SECRET_HOSTS = 20 + +export const SECRETS_HELPER_COPY = + 'Sandboxes can use these secrets without the actual values being exposed to them' + +export const SECRETS_SEARCH_PLACEHOLDER_EMPTY = + 'Add a secret to start searching' +export const SECRETS_SEARCH_PLACEHOLDER = 'Search by label or host' diff --git a/src/features/dashboard/settings/secrets/secrets-page-content.tsx b/src/features/dashboard/settings/secrets/secrets-page-content.tsx new file mode 100644 index 000000000..4fbf11ed7 --- /dev/null +++ b/src/features/dashboard/settings/secrets/secrets-page-content.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useMemo, useState } from 'react' +import { cn } from '@/lib/utils' +import { pluralize } from '@/lib/utils/formatting' +import { CatchErrorBoundary } from '@/ui/error' +import { Button } from '@/ui/primitives/button' +import { AddIcon, SearchIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { + SECRETS_HELPER_COPY, + SECRETS_SEARCH_PLACEHOLDER, + SECRETS_SEARCH_PLACEHOLDER_EMPTY, +} from './constants' +import { SecretsTable } from './table' +import type { Secret } from './types' + +// tRPC list query lands with the BE ticket; until then the page renders empty. +// Hoisted so the reference is stable across renders. +const PLACEHOLDER_SECRETS: readonly Secret[] = [] + +interface SecretsPageContentProps { + className?: string +} + +export const SecretsPageContent = ({ className }: SecretsPageContentProps) => { + const [query, setQuery] = useState('') + + const secrets = PLACEHOLDER_SECRETS + const normalizedQuery = query.trim().toLowerCase() + const filteredSecrets = useMemo(() => { + if (!normalizedQuery) return secrets + return secrets.filter(({ label, allowList }) => { + const haystack = [ + label, + ...(allowList.mode === 'specific' ? allowList.hosts : []), + ] + return haystack.some((value) => + value.toLowerCase().includes(normalizedQuery) + ) + }) + }, [normalizedQuery]) + + const totalCount = secrets.length + const hasActiveSearch = normalizedQuery.length > 0 + const searchPlaceholder = + totalCount === 0 + ? SECRETS_SEARCH_PLACEHOLDER_EMPTY + : SECRETS_SEARCH_PLACEHOLDER + + const totalLabel = + totalCount === 0 + ? null + : hasActiveSearch + ? `Showing ${filteredSecrets.length} of ${totalCount} ${pluralize(totalCount, 'secret')}` + : `${totalCount} ${pluralize(totalCount, 'secret')} in total` + + return ( +
+
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + type="search" + value={query} + /> +
+ + {/* TODO(secrets-dialog): wire to NewSecretDialog in the next commit. */} + +
+ + +
+

+ {SECRETS_HELPER_COPY} +

+ {totalLabel ? ( +

{totalLabel}

+ ) : null} +
+ +
+ +
+
+
+ ) +} diff --git a/src/features/dashboard/settings/secrets/table.tsx b/src/features/dashboard/settings/secrets/table.tsx new file mode 100644 index 000000000..3e7bb78eb --- /dev/null +++ b/src/features/dashboard/settings/secrets/table.tsx @@ -0,0 +1,80 @@ +import { cn } from '@/lib/utils' +import { VaultIcon } from '@/ui/primitives/icons' +import { + Table, + TableBody, + TableEmptyState, + TableHead, + TableHeader, + TableLoadingState, + TableRow, +} from '@/ui/primitives/table' +import type { Secret } from './types' + +interface SecretsTableProps { + secrets: Secret[] + totalSecretCount: number + isLoading?: boolean + className?: string +} + +const headerCellClassName = + 'h-[17px] p-0 pb-2 align-top font-sans! text-[12px] leading-[17px] text-left font-normal text-fg-tertiary uppercase' + +export const SecretsTable = ({ + secrets, + totalSecretCount, + isLoading = false, + className, +}: SecretsTableProps) => { + const hasNoSecrets = totalSecretCount === 0 + const emptyMessage = hasNoSecrets + ? 'No secrets added yet' + : 'No secrets match your search' + + return ( + + + + + + + + + + + LABEL + ID + ALLOWED FOR + ADDED + + Actions + + + + 0 && [ + '[&_tr]:border-stroke', + '[&_tr:last-child]:border-b [&_tr:last-child]:border-stroke', + ] + )} + > + {isLoading ? ( + + ) : ( + + +

{emptyMessage}

+
+ )} +
+
+ ) +} diff --git a/src/features/dashboard/settings/secrets/types.ts b/src/features/dashboard/settings/secrets/types.ts new file mode 100644 index 000000000..30606b672 --- /dev/null +++ b/src/features/dashboard/settings/secrets/types.ts @@ -0,0 +1,10 @@ +// Backend contract for secrets isn't merged yet. This type is a forward-looking +// placeholder shaped after the Figma table columns; swap for the generated +// `components['schemas']['SecretDetail']` once the API ships. +export interface Secret { + id: string + label: string + description?: string + allowList: { mode: 'all' } | { mode: 'specific'; hosts: string[] } + createdAt: string +} From d9aea3567907c30f1317545ea283606767ca2a8c Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 4 Jun 2026 12:53:16 +0200 Subject: [PATCH 3/4] feat(secrets): add-new-secret dialog and reusable form Adds the create flow for secrets: - Zod schema (label, value, description, allowList { mode, hosts }). Hosts are wrapped in '{ value }' objects because RHF's useFieldArray treats arrays of primitives as 'never' \-- flattenHosts unwraps them before the payload heads to the BE. - SecretForm: single reusable form (RHF + FormProvider + zodResolver, mode: 'onChange'). Submit is gated on formState.isValid (+ optional requireDirty for the future edit flow). Renders label / secret value / description / allow-list sections plus the footer. - SecretValueField: masked input with eye / eye-off reveal toggle. - HostFieldArray: only mounts when allowList.mode === 'specific'; manages the host list with useFieldArray, min 1 / max MAX_SECRET_HOSTS. - NewSecretDialog: thin Dialog wrapper around SecretForm with the create title / Add label. Submit is a toast stub until the BE lands. - Wires the 'Add a secret' page button to NewSecretDialog. --- .../secrets/components/host-field-array.tsx | 92 ++++++++ .../secrets/components/secret-form.tsx | 219 ++++++++++++++++++ .../secrets/components/secret-value-field.tsx | 58 +++++ .../settings/secrets/new-secret-dialog.tsx | 60 +++++ .../dashboard/settings/secrets/schema.ts | 68 ++++++ .../settings/secrets/secrets-page-content.tsx | 23 +- 6 files changed, 509 insertions(+), 11 deletions(-) create mode 100644 src/features/dashboard/settings/secrets/components/host-field-array.tsx create mode 100644 src/features/dashboard/settings/secrets/components/secret-form.tsx create mode 100644 src/features/dashboard/settings/secrets/components/secret-value-field.tsx create mode 100644 src/features/dashboard/settings/secrets/new-secret-dialog.tsx create mode 100644 src/features/dashboard/settings/secrets/schema.ts diff --git a/src/features/dashboard/settings/secrets/components/host-field-array.tsx b/src/features/dashboard/settings/secrets/components/host-field-array.tsx new file mode 100644 index 000000000..0c37fd8a6 --- /dev/null +++ b/src/features/dashboard/settings/secrets/components/host-field-array.tsx @@ -0,0 +1,92 @@ +'use client' + +import { useFieldArray, useFormContext, useWatch } from 'react-hook-form' +import { Button } from '@/ui/primitives/button' +import { + FormControl, + FormField, + FormItem, + FormMessage, +} from '@/ui/primitives/form' +import { IconButton } from '@/ui/primitives/icon-button' +import { AddIcon, LinkIcon, RemoveIcon, TrashIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import { MAX_SECRET_HOSTS } from '../constants' +import type { SecretFormInput } from '../schema' + +export function HostFieldArray({ disabled }: { disabled?: boolean }) { + const { control } = useFormContext() + const allowListMode = useWatch({ control, name: 'allowList.mode' }) + + // Field array is only meaningful when the user picked "specific hosts". + // Returning null on "all" keeps RHF from registering a stray field array. + if (allowListMode !== 'specific') return null + + return +} + +function HostFieldArrayBody({ disabled }: { disabled?: boolean }) { + const { control } = useFormContext() + const { fields, append, remove } = useFieldArray({ + control, + name: 'allowList.hosts', + }) + + const atMax = fields.length >= MAX_SECRET_HOSTS + const onlyOne = fields.length <= 1 + + return ( +
+
    + {fields.map((field, index) => ( +
  • + ( + + + { + hostField.onChange('') + }} + autoComplete="off" + className="min-w-0" + disabled={disabled} + placeholder="example.com" + /> + + + + )} + /> + {index > 0 && ( + remove(index)} + type="button" + > + + + )} +
  • + ))} +
+ + +
+ ) +} diff --git a/src/features/dashboard/settings/secrets/components/secret-form.tsx b/src/features/dashboard/settings/secrets/components/secret-form.tsx new file mode 100644 index 000000000..749e85c17 --- /dev/null +++ b/src/features/dashboard/settings/secrets/components/secret-form.tsx @@ -0,0 +1,219 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import type { ReactNode } from 'react' +import { useForm, useFormContext, useWatch } from 'react-hook-form' +import { Button } from '@/ui/primitives/button' +import { DialogFooter } from '@/ui/primitives/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { Input } from '@/ui/primitives/input' +import { Label } from '@/ui/primitives/label' +import { Loader } from '@/ui/primitives/loader' +import { RadioGroup, RadioGroupItem } from '@/ui/primitives/radio-group' +import { + type SecretFormInput, + type SecretFormOutput, + SecretFormSchema, +} from '../schema' +import { HostFieldArray } from './host-field-array' +import { SecretValueField } from './secret-value-field' + +const DEFAULT_VALUES: SecretFormInput = { + label: '', + value: '', + description: '', + allowList: { mode: 'all', hosts: [] }, +} + +interface SecretFormProps { + defaultValues?: Partial + onSubmit: (values: SecretFormOutput) => void | Promise + submitLabel: string + submitIcon?: ReactNode + loadingLabel?: string + /** Edit flow only — keeps submit disabled until something actually changed. */ + requireDirty?: boolean +} + +export function SecretForm({ + defaultValues, + onSubmit, + submitLabel, + submitIcon, + loadingLabel = 'Saving…', + requireDirty = false, +}: SecretFormProps) { + const form = useForm({ + resolver: zodResolver(SecretFormSchema), + mode: 'onChange', + defaultValues: { ...DEFAULT_VALUES, ...defaultValues }, + }) + + const { formState } = form + const canSubmit = + formState.isValid && + !formState.isSubmitting && + (!requireDirty || formState.isDirty) + + return ( +
+ +
+ + + + +
+ + + {formState.isSubmitting ? ( +
+ + + {loadingLabel} + +
+ ) : ( + + )} +
+
+ + ) +} + +function LabelField({ disabled }: { disabled?: boolean }) { + const { control } = useFormContext() + return ( + ( + + label + + field.onChange('')} + placeholder="OpenAI API Key" + /> + + + + )} + /> + ) +} + +function DescriptionField({ disabled }: { disabled?: boolean }) { + const { control } = useFormContext() + return ( + ( + + description (optional) + + field.onChange('')} + placeholder="e.g. intented use, environment, etc." + value={field.value ?? ''} + /> + + + + )} + /> + ) +} + +function AllowListSection({ disabled }: { disabled?: boolean }) { + const { control, setValue } = useFormContext() + const mode = useWatch({ control, name: 'allowList.mode' }) + + const handleModeChange = (next: string) => { + if (next === 'all') { + setValue( + 'allowList', + { mode: 'all', hosts: [] }, + { shouldValidate: true, shouldDirty: true } + ) + } else if (next === 'specific') { + setValue( + 'allowList', + { mode: 'specific', hosts: [{ value: '' }] }, + { shouldValidate: true, shouldDirty: true } + ) + } + } + + return ( +
+
+ +

+ Define the websites and servers (hosts) that sandboxes may use this + secret with +

+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/src/features/dashboard/settings/secrets/components/secret-value-field.tsx b/src/features/dashboard/settings/secrets/components/secret-value-field.tsx new file mode 100644 index 000000000..0b1b1388e --- /dev/null +++ b/src/features/dashboard/settings/secrets/components/secret-value-field.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { useFormContext } from 'react-hook-form' +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/ui/primitives/form' +import { EyeIcon, EyeOffIcon } from '@/ui/primitives/icons' +import { Input } from '@/ui/primitives/input' +import type { SecretFormInput } from '../schema' + +export function SecretValueField({ disabled }: { disabled?: boolean }) { + const { control } = useFormContext() + const [revealed, setRevealed] = useState(false) + + return ( + ( + + secret value +
+ + + + +
+ +
+ )} + /> + ) +} diff --git a/src/features/dashboard/settings/secrets/new-secret-dialog.tsx b/src/features/dashboard/settings/secrets/new-secret-dialog.tsx new file mode 100644 index 000000000..b4cb7ae9d --- /dev/null +++ b/src/features/dashboard/settings/secrets/new-secret-dialog.tsx @@ -0,0 +1,60 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/ui/primitives/dialog' +import { AddIcon } from '@/ui/primitives/icons' +import { SecretForm } from './components/secret-form' +import { flattenHosts, type SecretFormOutput } from './schema' + +interface NewSecretDialogProps { + children: ReactNode +} + +export function NewSecretDialog({ children }: NewSecretDialogProps) { + const [open, setOpen] = useState(false) + + const handleSubmit = (values: SecretFormOutput) => { + // BE-ready payload shape: hosts come out of the form as `{ value }` objects + // (required by RHF's useFieldArray) and the BE expects plain strings. + const _payload = { + label: values.label, + value: values.value, + description: values.description, + allowList: + values.allowList.mode === 'all' + ? { mode: 'all' as const } + : { + mode: 'specific' as const, + hosts: flattenHosts(values.allowList.hosts), + }, + } + // TODO(secrets-be): swap for `trpc.secrets.create.mutateAsync(_payload)`. + toast(defaultSuccessToast('Secret added (UI only — backend pending)')) + setOpen(false) + } + + return ( + + {children} + + + Add new secret + + + } + submitLabel="Add" + /> + + + ) +} diff --git a/src/features/dashboard/settings/secrets/schema.ts b/src/features/dashboard/settings/secrets/schema.ts new file mode 100644 index 000000000..1deb75599 --- /dev/null +++ b/src/features/dashboard/settings/secrets/schema.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import { MAX_SECRET_HOSTS } from './constants' + +const HOST_REGEX = /^(?!-)[A-Za-z0-9-]{1,63}(? { + if (data.allowList.mode === 'specific' && data.allowList.hosts.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['allowList', 'hosts'], + message: 'Add at least one host', + }) + } +}) + +// Hand-written rather than `z.input`: `z.input` on a +// zod object isn't always eagerly expanded for RHF's deeply-recursive +// `FieldArrayPath` walker, and we want this type to be obviously a plain +// object shape. `type` (not `interface`) so it satisfies RHF's +// `FieldValues = Record` constraint. +export type SecretFormInput = { + label: string + value: string + description: string + allowList: { + mode: 'all' | 'specific' + hosts: { value: string }[] + } +} + +export type SecretFormOutput = SecretFormInput + +/** Unwrap the `{ value }`-wrapped hosts back into plain strings for the BE. */ +export function flattenHosts(hosts: { value: string }[]): string[] { + return hosts.map((h) => h.value) +} diff --git a/src/features/dashboard/settings/secrets/secrets-page-content.tsx b/src/features/dashboard/settings/secrets/secrets-page-content.tsx index 4fbf11ed7..c17363c82 100644 --- a/src/features/dashboard/settings/secrets/secrets-page-content.tsx +++ b/src/features/dashboard/settings/secrets/secrets-page-content.tsx @@ -12,12 +12,13 @@ import { SECRETS_SEARCH_PLACEHOLDER, SECRETS_SEARCH_PLACEHOLDER_EMPTY, } from './constants' +import { NewSecretDialog } from './new-secret-dialog' import { SecretsTable } from './table' import type { Secret } from './types' // tRPC list query lands with the BE ticket; until then the page renders empty. // Hoisted so the reference is stable across renders. -const PLACEHOLDER_SECRETS: readonly Secret[] = [] +const PLACEHOLDER_SECRETS: Secret[] = [] interface SecretsPageContentProps { className?: string @@ -68,20 +69,20 @@ export const SecretsPageContent = ({ className }: SecretsPageContentProps) => { className="h-9 border-stroke pl-9 font-sans" onChange={(event) => setQuery(event.target.value)} placeholder={searchPlaceholder} - type="search" value={query} /> - {/* TODO(secrets-dialog): wire to NewSecretDialog in the next commit. */} - + + + From fc9da6786f6dfefad27f0d108501a1f169367157 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Mon, 8 Jun 2026 16:39:03 +0200 Subject: [PATCH 4/4] Secrets with mock --- .../secrets/components/secret-form.tsx | 18 +- .../dashboard/settings/secrets/constants.ts | 2 +- .../settings/secrets/delete-secret-dialog.tsx | 82 ++++++++ .../settings/secrets/edit-secret-dialog.tsx | 85 +++++++++ .../dashboard/settings/secrets/index.ts | 7 + .../settings/secrets/new-secret-dialog.tsx | 24 ++- .../settings/secrets/secrets-page-content.tsx | 12 +- .../settings/secrets/secrets-table-row.tsx | 175 ++++++++++++++++++ .../dashboard/settings/secrets/store.ts | 71 +++++++ .../dashboard/settings/secrets/table.tsx | 66 ++++--- .../dashboard/settings/secrets/types.ts | 6 + 11 files changed, 493 insertions(+), 55 deletions(-) create mode 100644 src/features/dashboard/settings/secrets/delete-secret-dialog.tsx create mode 100644 src/features/dashboard/settings/secrets/edit-secret-dialog.tsx create mode 100644 src/features/dashboard/settings/secrets/index.ts create mode 100644 src/features/dashboard/settings/secrets/secrets-table-row.tsx create mode 100644 src/features/dashboard/settings/secrets/store.ts diff --git a/src/features/dashboard/settings/secrets/components/secret-form.tsx b/src/features/dashboard/settings/secrets/components/secret-form.tsx index 749e85c17..ebe9cd488 100644 --- a/src/features/dashboard/settings/secrets/components/secret-form.tsx +++ b/src/features/dashboard/settings/secrets/components/secret-form.tsx @@ -32,12 +32,18 @@ const DEFAULT_VALUES: SecretFormInput = { allowList: { mode: 'all', hosts: [] }, } +// Edit mode never roundtrips the plaintext (the BE owns the only copy), so we +// satisfy zod's value.min(1) with a placeholder and skip rendering the field. +// The edit dialog's onSubmit ignores `values.value`. +const EDIT_VALUE_PLACEHOLDER = '__edit_placeholder__' + interface SecretFormProps { defaultValues?: Partial onSubmit: (values: SecretFormOutput) => void | Promise submitLabel: string submitIcon?: ReactNode loadingLabel?: string + mode?: 'create' | 'edit' /** Edit flow only — keeps submit disabled until something actually changed. */ requireDirty?: boolean } @@ -48,12 +54,18 @@ export function SecretForm({ submitLabel, submitIcon, loadingLabel = 'Saving…', + mode = 'create', requireDirty = false, }: SecretFormProps) { + const isEdit = mode === 'edit' const form = useForm({ resolver: zodResolver(SecretFormSchema), mode: 'onChange', - defaultValues: { ...DEFAULT_VALUES, ...defaultValues }, + defaultValues: { + ...DEFAULT_VALUES, + ...defaultValues, + ...(isEdit && { value: EDIT_VALUE_PLACEHOLDER }), + }, }) const { formState } = form @@ -70,7 +82,7 @@ export function SecretForm({ >
- + {!isEdit && }
@@ -117,7 +129,7 @@ function LabelField({ disabled }: { disabled?: boolean }) { clearable disabled={disabled} onClear={() => field.onChange('')} - placeholder="OpenAI API Key" + placeholder="e.g. Service API Key" /> diff --git a/src/features/dashboard/settings/secrets/constants.ts b/src/features/dashboard/settings/secrets/constants.ts index bc009a0ec..86ba0fda1 100644 --- a/src/features/dashboard/settings/secrets/constants.ts +++ b/src/features/dashboard/settings/secrets/constants.ts @@ -5,4 +5,4 @@ export const SECRETS_HELPER_COPY = export const SECRETS_SEARCH_PLACEHOLDER_EMPTY = 'Add a secret to start searching' -export const SECRETS_SEARCH_PLACEHOLDER = 'Search by label or host' +export const SECRETS_SEARCH_PLACEHOLDER = 'Search by title, ID & allow list' diff --git a/src/features/dashboard/settings/secrets/delete-secret-dialog.tsx b/src/features/dashboard/settings/secrets/delete-secret-dialog.tsx new file mode 100644 index 000000000..125fc930a --- /dev/null +++ b/src/features/dashboard/settings/secrets/delete-secret-dialog.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast' +import { Button } from '@/ui/primitives/button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { RemoveIcon, TrashIcon } from '@/ui/primitives/icons' +import { useSecretsStore } from './store' +import type { Secret } from './types' + +interface DeleteSecretDialogProps { + secret: Secret + open: boolean + onOpenChange: (open: boolean) => void +} + +export const DeleteSecretDialog = ({ + secret, + open, + onOpenChange, +}: DeleteSecretDialogProps) => { + const { team } = useDashboard() + const { toast } = useToast() + const removeSecret = useSecretsStore((s) => s.removeSecret) + + const label = secret.label.trim() || 'Untitled' + + const handleDelete = () => { + removeSecret(team.slug, secret.id) + toast(defaultSuccessToast('Secret has been deleted.')) + onOpenChange(false) + } + + return ( + + +
+ + {`delete '${label}'?`} + + Sandboxes will no longer be able to use this secret.{' '} + + This may cause dependent flows to fail. + + + + +
+ + + + +
+
+
+
+ ) +} diff --git a/src/features/dashboard/settings/secrets/edit-secret-dialog.tsx b/src/features/dashboard/settings/secrets/edit-secret-dialog.tsx new file mode 100644 index 000000000..5f54782c6 --- /dev/null +++ b/src/features/dashboard/settings/secrets/edit-secret-dialog.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useDashboard } from '@/features/dashboard/context' +import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/ui/primitives/dialog' +import { CheckIcon } from '@/ui/primitives/icons' +import { SecretForm } from './components/secret-form' +import { + flattenHosts, + type SecretFormInput, + type SecretFormOutput, +} from './schema' +import { useSecretsStore } from './store' +import type { Secret } from './types' + +interface EditSecretDialogProps { + secret: Secret + open: boolean + onOpenChange: (open: boolean) => void +} + +export function EditSecretDialog({ + secret, + open, + onOpenChange, +}: EditSecretDialogProps) { + const { team } = useDashboard() + const updateSecret = useSecretsStore((s) => s.updateSecret) + + const defaultValues: Partial = { + label: secret.label, + description: secret.description ?? '', + allowList: + secret.allowList.mode === 'all' + ? { mode: 'all', hosts: [] } + : { + mode: 'specific', + hosts: secret.allowList.hosts.map((value) => ({ value })), + }, + } + + // TODO(secrets-be): swap for `trpc.secrets.update.mutateAsync(...)`. The + // plaintext value is intentionally never collected on edit (only the BE + // ever sees it on create). + const handleSubmit = (values: SecretFormOutput) => { + updateSecret(team.slug, secret.id, { + label: values.label, + description: values.description || undefined, + allowList: + values.allowList.mode === 'all' + ? { mode: 'all' } + : { + mode: 'specific', + hosts: flattenHosts(values.allowList.hosts), + }, + }) + toast(defaultSuccessToast('Secret updated')) + onOpenChange(false) + } + + return ( + + + + Edit secret + + + } + submitLabel="Save changes" + /> + + + ) +} diff --git a/src/features/dashboard/settings/secrets/index.ts b/src/features/dashboard/settings/secrets/index.ts new file mode 100644 index 000000000..8245592d5 --- /dev/null +++ b/src/features/dashboard/settings/secrets/index.ts @@ -0,0 +1,7 @@ +export { DeleteSecretDialog } from './delete-secret-dialog' +export { EditSecretDialog } from './edit-secret-dialog' +export { NewSecretDialog } from './new-secret-dialog' +export { SecretsPageContent } from './secrets-page-content' +export { SecretsTableRow } from './secrets-table-row' +export { useSecretsStore, useTeamSecrets } from './store' +export { SecretsTable } from './table' diff --git a/src/features/dashboard/settings/secrets/new-secret-dialog.tsx b/src/features/dashboard/settings/secrets/new-secret-dialog.tsx index b4cb7ae9d..308a59321 100644 --- a/src/features/dashboard/settings/secrets/new-secret-dialog.tsx +++ b/src/features/dashboard/settings/secrets/new-secret-dialog.tsx @@ -1,6 +1,7 @@ 'use client' import { type ReactNode, useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' import { Dialog, @@ -12,31 +13,34 @@ import { import { AddIcon } from '@/ui/primitives/icons' import { SecretForm } from './components/secret-form' import { flattenHosts, type SecretFormOutput } from './schema' +import { useSecretsStore } from './store' interface NewSecretDialogProps { children: ReactNode } export function NewSecretDialog({ children }: NewSecretDialogProps) { + const { team, user } = useDashboard() + const addSecret = useSecretsStore((s) => s.addSecret) const [open, setOpen] = useState(false) + // TODO(secrets-be): swap for `trpc.secrets.create.mutateAsync(...)` once the + // backend ships. The secret's plaintext `value` intentionally never enters + // the store — only the BE should ever hold it. const handleSubmit = (values: SecretFormOutput) => { - // BE-ready payload shape: hosts come out of the form as `{ value }` objects - // (required by RHF's useFieldArray) and the BE expects plain strings. - const _payload = { + addSecret(team.slug, { label: values.label, - value: values.value, - description: values.description, + description: values.description || undefined, allowList: values.allowList.mode === 'all' - ? { mode: 'all' as const } + ? { mode: 'all' } : { - mode: 'specific' as const, + mode: 'specific', hosts: flattenHosts(values.allowList.hosts), }, - } - // TODO(secrets-be): swap for `trpc.secrets.create.mutateAsync(_payload)`. - toast(defaultSuccessToast('Secret added (UI only — backend pending)')) + createdBy: { email: user.email, avatarUrl: user.avatarUrl }, + }) + toast(defaultSuccessToast('Secret added')) setOpen(false) } diff --git a/src/features/dashboard/settings/secrets/secrets-page-content.tsx b/src/features/dashboard/settings/secrets/secrets-page-content.tsx index c17363c82..31f7cb8b8 100644 --- a/src/features/dashboard/settings/secrets/secrets-page-content.tsx +++ b/src/features/dashboard/settings/secrets/secrets-page-content.tsx @@ -1,6 +1,7 @@ 'use client' import { useMemo, useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' import { cn } from '@/lib/utils' import { pluralize } from '@/lib/utils/formatting' import { CatchErrorBoundary } from '@/ui/error' @@ -13,21 +14,18 @@ import { SECRETS_SEARCH_PLACEHOLDER_EMPTY, } from './constants' import { NewSecretDialog } from './new-secret-dialog' +import { useTeamSecrets } from './store' import { SecretsTable } from './table' -import type { Secret } from './types' - -// tRPC list query lands with the BE ticket; until then the page renders empty. -// Hoisted so the reference is stable across renders. -const PLACEHOLDER_SECRETS: Secret[] = [] interface SecretsPageContentProps { className?: string } export const SecretsPageContent = ({ className }: SecretsPageContentProps) => { + const { team } = useDashboard() const [query, setQuery] = useState('') - const secrets = PLACEHOLDER_SECRETS + const secrets = useTeamSecrets(team.slug) const normalizedQuery = query.trim().toLowerCase() const filteredSecrets = useMemo(() => { if (!normalizedQuery) return secrets @@ -40,7 +38,7 @@ export const SecretsPageContent = ({ className }: SecretsPageContentProps) => { value.toLowerCase().includes(normalizedQuery) ) }) - }, [normalizedQuery]) + }, [normalizedQuery, secrets]) const totalCount = secrets.length const hasActiveSearch = normalizedQuery.length > 0 diff --git a/src/features/dashboard/settings/secrets/secrets-table-row.tsx b/src/features/dashboard/settings/secrets/secrets-table-row.tsx new file mode 100644 index 000000000..b8430bade --- /dev/null +++ b/src/features/dashboard/settings/secrets/secrets-table-row.tsx @@ -0,0 +1,175 @@ +'use client' + +import { usePostHog } from 'posthog-js/react' +import { useState } from 'react' +import { IdBadge, UserAvatar } from '@/features/dashboard/shared' +import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' +import { Badge } from '@/ui/primitives/badge' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/ui/primitives/dropdown-menu' +import { IconButton } from '@/ui/primitives/icon-button' +import { EditIcon, IndicatorDotsIcon, TrashIcon } from '@/ui/primitives/icons' +import { TableCell, TableRow } from '@/ui/primitives/table' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/ui/primitives/tooltip' +import { DeleteSecretDialog } from './delete-secret-dialog' +import { EditSecretDialog } from './edit-secret-dialog' +import type { Secret } from './types' + +const tableCellClassName = 'py-3 text-left [tr:first-child>&]:pt-1.5' +const actionIconClassName = 'size-4 text-fg-tertiary' + +interface SecretsTableRowProps { + secret: Secret +} + +export const SecretsTableRow = ({ secret }: SecretsTableRowProps) => { + const posthog = usePostHog() + const { toast } = useToast() + + const addedDate = formatDate(new Date(secret.createdAt), 'MMM d, yyyy') ?? '—' + const createdByEmail = secret.createdBy?.email?.trim() || 'Unknown user' + + const handleIdCopied = () => { + posthog.capture('copied secret id') + toast(defaultSuccessToast('ID copied to clipboard')) + } + + return ( + + +
+
+ + *** + +
+ + {secret.label} + +
+
+ + + + + + + + + + +
+ + {addedDate} + +
+ {secret.createdBy ? ( + + + + + + + {createdByEmail} + + ) : ( + + )} + +
+
+
+
+ ) +} + +function SecretRowActions({ secret }: { secret: Secret }) { + const [editOpen, setEditOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + + return ( + <> + + + + + + + + + setEditOpen(true)}> + Edit + + setDeleteOpen(true)}> + + Delete + + + + + + + + + ) +} + +function AllowedForCell({ secret }: { secret: Secret }) { + if (secret.allowList.mode === 'all') { + return All hosts + } + + const { hosts } = secret.allowList + const [first, ...rest] = hosts + const label = rest.length === 0 ? first : `${first} +${rest.length}` + + const badge = {label} + + if (rest.length === 0) return badge + + return ( + + {badge} + +
    + {hosts.map((host) => ( +
  • {host}
  • + ))} +
+
+
+ ) +} diff --git a/src/features/dashboard/settings/secrets/store.ts b/src/features/dashboard/settings/secrets/store.ts new file mode 100644 index 000000000..c6e5d0bb6 --- /dev/null +++ b/src/features/dashboard/settings/secrets/store.ts @@ -0,0 +1,71 @@ +'use client' + +import { nanoid } from 'nanoid' +import { create } from 'zustand' +import type { Secret } from './types' + +// In-memory mock until the secrets backend ships. Keyed by team slug so +// switching teams doesn't bleed entries across — mirrors how the keys +// feature scopes by `team.slug`. + +interface SecretsStoreState { + byTeam: Record +} + +interface SecretsStoreActions { + addSecret: ( + teamSlug: string, + input: Omit + ) => Secret + updateSecret: ( + teamSlug: string, + id: string, + patch: Pick + ) => void + removeSecret: (teamSlug: string, id: string) => void +} + +type Store = SecretsStoreState & SecretsStoreActions + +// Stable reference for empty-team lookups. Returning a fresh `[]` from the +// selector would trip Zustand's Object.is check on every render and loop. +const EMPTY_SECRETS: Secret[] = [] + +export const useSecretsStore = create()((set) => ({ + byTeam: {}, + addSecret: (teamSlug, input) => { + const created: Secret = { + ...input, + id: `sec_${nanoid(16)}`, + createdAt: new Date().toISOString(), + } + set((state) => ({ + byTeam: { + ...state.byTeam, + [teamSlug]: [created, ...(state.byTeam[teamSlug] ?? [])], + }, + })) + return created + }, + updateSecret: (teamSlug, id, patch) => { + set((state) => ({ + byTeam: { + ...state.byTeam, + [teamSlug]: (state.byTeam[teamSlug] ?? []).map((s) => + s.id === id ? { ...s, ...patch } : s + ), + }, + })) + }, + removeSecret: (teamSlug, id) => { + set((state) => ({ + byTeam: { + ...state.byTeam, + [teamSlug]: (state.byTeam[teamSlug] ?? []).filter((s) => s.id !== id), + }, + })) + }, +})) + +export const useTeamSecrets = (teamSlug: string): Secret[] => + useSecretsStore((s) => s.byTeam[teamSlug] ?? EMPTY_SECRETS) diff --git a/src/features/dashboard/settings/secrets/table.tsx b/src/features/dashboard/settings/secrets/table.tsx index 3e7bb78eb..51538e468 100644 --- a/src/features/dashboard/settings/secrets/table.tsx +++ b/src/features/dashboard/settings/secrets/table.tsx @@ -1,5 +1,8 @@ +'use client' + +import type { FC, ReactNode } from 'react' import { cn } from '@/lib/utils' -import { VaultIcon } from '@/ui/primitives/icons' +import { SearchIcon, VaultIcon } from '@/ui/primitives/icons' import { Table, TableBody, @@ -9,8 +12,15 @@ import { TableLoadingState, TableRow, } from '@/ui/primitives/table' +import { SecretsTableRow } from './secrets-table-row' import type { Secret } from './types' +const SecretsTableHead = ({ children }: { children: ReactNode }) => ( + + {children} + +) + interface SecretsTableProps { secrets: Secret[] totalSecretCount: number @@ -18,38 +28,28 @@ interface SecretsTableProps { className?: string } -const headerCellClassName = - 'h-[17px] p-0 pb-2 align-top font-sans! text-[12px] leading-[17px] text-left font-normal text-fg-tertiary uppercase' - -export const SecretsTable = ({ +export const SecretsTable: FC = ({ secrets, totalSecretCount, isLoading = false, className, -}: SecretsTableProps) => { +}) => { const hasNoSecrets = totalSecretCount === 0 - const emptyMessage = hasNoSecrets - ? 'No secrets added yet' - : 'No secrets match your search' return ( - +
- - - - - + + + + - - LABEL - ID - ALLOWED FOR - ADDED - - Actions - + + LABEL + ID + ALLOWED FOR + ADDED {isLoading ? ( - - ) : ( - - -

{emptyMessage}

+ + ) : secrets.length === 0 ? ( + + + {hasNoSecrets ? 'No secrets added yet' : 'No matching results'} + ) : ( + secrets.map((secret) => ( + + )) )}
diff --git a/src/features/dashboard/settings/secrets/types.ts b/src/features/dashboard/settings/secrets/types.ts index 30606b672..1a6af3512 100644 --- a/src/features/dashboard/settings/secrets/types.ts +++ b/src/features/dashboard/settings/secrets/types.ts @@ -1,10 +1,16 @@ // Backend contract for secrets isn't merged yet. This type is a forward-looking // placeholder shaped after the Figma table columns; swap for the generated // `components['schemas']['SecretDetail']` once the API ships. +export interface SecretAuthor { + email: string | null + avatarUrl: string | null +} + export interface Secret { id: string label: string description?: string allowList: { mode: 'all' } | { mode: 'specific'; hosts: string[] } createdAt: string + createdBy?: SecretAuthor }