Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/app/dashboard/[teamSlug]/secrets/error.tsx
Original file line number Diff line number Diff line change
@@ -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 <DashboardRouteError error={error} reset={reset} />
}
16 changes: 16 additions & 0 deletions src/app/dashboard/[teamSlug]/secrets/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Page>
<SecretsPageContent />
</Page>
)
}
4 changes: 4 additions & 0 deletions src/configs/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
}),
'/dashboard/*/sandboxes/*/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 41 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const sandboxId = parts[4]!

Check warning on line 42 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand Down Expand Up @@ -70,8 +70,8 @@
}),
'/dashboard/*/templates/*/builds/*': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 73 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const buildId = parts.pop()!

Check warning on line 74 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.
const buildIdSliced = `${buildId.slice(0, 6)}...${buildId.slice(-6)}`

return {
Expand All @@ -91,6 +91,10 @@
},

// integrations
'/dashboard/*/secrets': () => ({
title: 'Secrets',
type: 'default',
}),
'/dashboard/*/webhooks': () => ({
title: 'Webhooks',
type: 'default',
Expand Down Expand Up @@ -128,7 +132,7 @@
}),
'/dashboard/*/billing/plan': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 135 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand All @@ -142,7 +146,7 @@
},
'/dashboard/*/billing/plan/select': (pathname) => {
const parts = pathname.split('/')
const teamSlug = parts[2]!

Check warning on line 149 in src/configs/layout.ts

View workflow job for this annotation

GitHub Actions / Lint

lint/style/noNonNullAssertion

Forbidden non-null assertion.

return {
title: [
Expand Down
8 changes: 8 additions & 0 deletions src/configs/sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SettingsIcon,
TemplateIcon,
UsageIcon,
VaultIcon,
WebhookIcon,
} from '@/ui/primitives/icons'
import { INCLUDE_ARGUS, INCLUDE_BILLING } from './flags'
Expand Down Expand Up @@ -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
? [
{
Expand Down
1 change: 1 addition & 0 deletions src/configs/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SecretFormInput>()
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 <HostFieldArrayBody disabled={disabled} />
}

function HostFieldArrayBody({ disabled }: { disabled?: boolean }) {
const { control } = useFormContext<SecretFormInput>()
const { fields, append, remove } = useFieldArray({
control,
name: 'allowList.hosts',
})

const atMax = fields.length >= MAX_SECRET_HOSTS
const onlyOne = fields.length <= 1

return (
<div className="flex flex-col gap-2">
<ul className="flex flex-col gap-2">
{fields.map((field, index) => (
<li className="flex items-start gap-1.5" key={field.id}>
<FormField
control={control}
name={`allowList.hosts.${index}.value`}
render={({ field: hostField }) => (
<FormItem className="min-w-0 flex-1">
<FormControl>
<Input
{...hostField}
clearable
onClear={() => {
hostField.onChange('')
}}
autoComplete="off"
className="min-w-0"
disabled={disabled}
placeholder="example.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{index > 0 && (
<IconButton
variant="secondary"
aria-label={`Remove host ${index + 1}`}
disabled={disabled || onlyOne}
onClick={() => remove(index)}
type="button"
>
<TrashIcon aria-hidden className="size-4" />
</IconButton>
)}
</li>
))}
</ul>

<Button
variant="secondary"
className="w-fit"
disabled={disabled || atMax}
onClick={() => append({ value: '' })}
type="button"
>
<AddIcon aria-hidden className="size-4" />
Add more
</Button>
</div>
)
}
231 changes: 231 additions & 0 deletions src/features/dashboard/settings/secrets/components/secret-form.tsx
Original file line number Diff line number Diff line change
@@ -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<SecretFormInput>
onSubmit: (values: SecretFormOutput) => void | Promise<void>
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<SecretFormInput>({
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 (
<Form {...form}>
<form
className="flex flex-1 flex-col justify-between gap-6 min-w-0 min-h-0"
onSubmit={form.handleSubmit(onSubmit)}
>
<div className="flex flex-1 flex-col gap-4 min-w-0 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<LabelField disabled={formState.isSubmitting} />
{!isEdit && <SecretValueField disabled={formState.isSubmitting} />}
<DescriptionField disabled={formState.isSubmitting} />
<AllowListSection disabled={formState.isSubmitting} />
</div>

<DialogFooter>
{formState.isSubmitting ? (
<div className="flex items-center justify-center py-2 gap-2 w-full">
<Loader size="sm" variant="slash" />
<span className="prose-body text-fg-secondary">
{loadingLabel}
</span>
</div>
) : (
<Button
className="w-full"
disabled={!canSubmit}
type="submit"
variant="primary"
>
{submitIcon}
{submitLabel}
</Button>
)}
</DialogFooter>
</form>
</Form>
)
}

function LabelField({ disabled }: { disabled?: boolean }) {
const { control } = useFormContext<SecretFormInput>()
return (
<FormField
control={control}
name="label"
render={({ field }) => (
<FormItem className="min-w-0">
<FormLabel>label</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
className="min-w-0"
clearable
disabled={disabled}
onClear={() => field.onChange('')}
placeholder="e.g. Service API Key"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
}

function DescriptionField({ disabled }: { disabled?: boolean }) {
const { control } = useFormContext<SecretFormInput>()
return (
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem className="min-w-0">
<FormLabel>description (optional)</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
className="min-w-0"
clearable
disabled={disabled}
onClear={() => field.onChange('')}
placeholder="e.g. intented use, environment, etc."
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
}

function AllowListSection({ disabled }: { disabled?: boolean }) {
const { control, setValue } = useFormContext<SecretFormInput>()
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 (
<section className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<Label>allow list</Label>
<p className="text-fg-tertiary prose-body">
Define the websites and servers (hosts) that sandboxes may use this
secret with
</p>
</div>

<div className="flex flex-col gap-3">
<RadioGroup
className="flex flex-col gap-2 px-0.5"
disabled={disabled}
onValueChange={handleModeChange}
value={mode}
>
<div className="flex items-center gap-2">
<RadioGroupItem id="allow-list-all" value="all" />
<Label
className="cursor-pointer select-none"
htmlFor="allow-list-all"
>
all hosts
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="allow-list-specific" value="specific" />
<Label
className="cursor-pointer select-none"
htmlFor="allow-list-specific"
>
specific hosts
</Label>
</div>
</RadioGroup>

<HostFieldArray disabled={disabled} />
</div>
</section>
)
}
Loading
Loading