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/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..ebe9cd488 --- /dev/null +++ b/src/features/dashboard/settings/secrets/components/secret-form.tsx @@ -0,0 +1,231 @@ +'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: [] }, +} + +// 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 +} + +export function SecretForm({ + defaultValues, + onSubmit, + 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, + ...(isEdit && { value: EDIT_VALUE_PLACEHOLDER }), + }, + }) + + const { formState } = form + const canSubmit = + formState.isValid && + !formState.isSubmitting && + (!requireDirty || formState.isDirty) + + return ( +
+ +
+ + {!isEdit && } + + +
+ + + {formState.isSubmitting ? ( +
+ + + {loadingLabel} + +
+ ) : ( + + )} +
+
+ + ) +} + +function LabelField({ disabled }: { disabled?: boolean }) { + const { control } = useFormContext() + return ( + ( + + label + + field.onChange('')} + placeholder="e.g. Service 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/constants.ts b/src/features/dashboard/settings/secrets/constants.ts new file mode 100644 index 000000000..86ba0fda1 --- /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 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 new file mode 100644 index 000000000..308a59321 --- /dev/null +++ b/src/features/dashboard/settings/secrets/new-secret-dialog.tsx @@ -0,0 +1,64 @@ +'use client' + +import { type ReactNode, useState } from 'react' +import { useDashboard } from '@/features/dashboard/context' +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' +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) => { + addSecret(team.slug, { + label: values.label, + description: values.description || undefined, + allowList: + values.allowList.mode === 'all' + ? { mode: 'all' } + : { + mode: 'specific', + hosts: flattenHosts(values.allowList.hosts), + }, + createdBy: { email: user.email, avatarUrl: user.avatarUrl }, + }) + toast(defaultSuccessToast('Secret added')) + 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 new file mode 100644 index 000000000..31f7cb8b8 --- /dev/null +++ b/src/features/dashboard/settings/secrets/secrets-page-content.tsx @@ -0,0 +1,105 @@ +'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' +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 { NewSecretDialog } from './new-secret-dialog' +import { useTeamSecrets } from './store' +import { SecretsTable } from './table' + +interface SecretsPageContentProps { + className?: string +} + +export const SecretsPageContent = ({ className }: SecretsPageContentProps) => { + const { team } = useDashboard() + const [query, setQuery] = useState('') + + const secrets = useTeamSecrets(team.slug) + 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, secrets]) + + 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} + value={query} + /> +
+ + + + +
+ + +
+

+ {SECRETS_HELPER_COPY} +

+ {totalLabel ? ( +

{totalLabel}

+ ) : null} +
+ +
+ +
+
+
+ ) +} 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 new file mode 100644 index 000000000..51538e468 --- /dev/null +++ b/src/features/dashboard/settings/secrets/table.tsx @@ -0,0 +1,78 @@ +'use client' + +import type { FC, ReactNode } from 'react' +import { cn } from '@/lib/utils' +import { SearchIcon, VaultIcon } from '@/ui/primitives/icons' +import { + Table, + TableBody, + TableEmptyState, + TableHead, + TableHeader, + 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 + isLoading?: boolean + className?: string +} + +export const SecretsTable: FC = ({ + secrets, + totalSecretCount, + isLoading = false, + className, +}) => { + const hasNoSecrets = totalSecretCount === 0 + + return ( + + + + + + + + + + LABEL + ID + ALLOWED FOR + ADDED + + + 0 && [ + '[&_tr]:border-stroke', + '[&_tr:last-child]:border-b [&_tr:last-child]:border-stroke', + ] + )} + > + {isLoading ? ( + + ) : 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 new file mode 100644 index 000000000..1a6af3512 --- /dev/null +++ b/src/features/dashboard/settings/secrets/types.ts @@ -0,0 +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 +} 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) => ( + + + + + + + +)