mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-21 13:00:04 +08:00
feat(secrets): add optional descriptions to workspace secrets (#6796)
* feat(secrets): add optional descriptions to workspace secrets Workspace secrets already have a backing credential row with a description column, but nothing surfaced it. Teammates had no way to record what a secret is for. - Add a Description field to the secret detail page, matching the integrations credential page, gated on workspace-secret admin - Fold the value and description editors into one Save/Discard pair and one unsaved-changes guard; two guards cannot coexist, since each seeds its own same-URL history entry - Match descriptions in the secrets settings search - Expose description on GET/PUT /api/v2/secrets and in the CLI Descriptions are workspace-only: env_personal credential rows are per-workspace mirrors of one user-global secret, so one saved there would exist in a single workspace, and a personal secret has no teammates to inform. The API rejects a description on personal scope rather than silently dropping it, and omitting it on PUT leaves any existing description untouched so a value rotation cannot erase it. * fix(secrets): address review findings on secret descriptions - Patch the credential detail cache optimistically on update. `onMutate` cancelled the detail query but only patched the lists, so a detail-backed editor stayed dirty after a successful save until the refetch landed — long enough for Discard to restore the pre-save value over the committed one, and for Back to open the unsaved-changes guard. - Memoize `useSecretValue`'s returned callbacks and object, per the hook convention, so the composed form's save/discard stop churning per render. - Reject a description on a personal secret in the domain layer rather than only at the v2 boundary. The internal credential update path accepted one for any type, writing data every reader hides. - Normalize an empty description to null so the API and UI agree. - Correct the secrets documentation, which described a Display Name field the detail view does not have and omitted the scope rule. - Drop the CLI's copy of the 500-character bound; it can't import the contract, so a copy only drifts from the message the API already returns. - Collapse a redundant save guard and align the description write gate with the render gate. Leaves the integrations credential page byte-identical to staging. * fix(secrets): keep the API docs example and CLI column order stable Backward-compatibility fixes for anyone who never sets a description. - Move the blank-to-null normalization out of the contract and into the route. A Zod `.transform()` on any property drops the whole request schema's OpenAPI examples, which had silently removed the Set Secret request example from the published docs. - Append the CLI `description` column instead of inserting it before `updated`. `--output text` is positional, so inserting would shift every field an existing script cuts. - Reject a description on a personal secret with a message that says so, rather than dropping the field and falling through to the generic "no updatable fields" error.
This commit is contained in:
@@ -1787,6 +1787,7 @@ sim secrets set <name> [options]
|
||||
| --- | --- | --- |
|
||||
| `--scope <scope>` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. |
|
||||
| `--value <value>` | No | Secret value; visible to shell history when supplied directly. |
|
||||
| `--description <description>` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
|
||||
@@ -80,5 +80,6 @@ sim secrets set <name> [options]
|
||||
| --- | --- | --- |
|
||||
| `--scope <scope>` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. |
|
||||
| `--value <value>` | No | Secret value; visible to shell history when supplied directly. |
|
||||
| `--description <description>` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. |
|
||||
|
||||
</CommandTable>
|
||||
|
||||
@@ -95,14 +95,15 @@ Click **Details** on any secret row to open its detail view.
|
||||
|
||||
<Image
|
||||
src="/static/secrets/secret-details.png"
|
||||
alt="Secret details view showing Display Name, Description, and Members sections"
|
||||
alt="Secret details view showing Key, Value, Description, and Members sections"
|
||||
width={700}
|
||||
height={400}
|
||||
/>
|
||||
|
||||
From here you can:
|
||||
|
||||
- Edit the **Display Name** and **Description**
|
||||
- View the **Key** and edit the **Value**
|
||||
- Edit the **Description** — an optional note telling teammates what the secret is for. Workspace secrets only; a personal secret is not shared, so it has none
|
||||
- Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role
|
||||
|
||||
Click **Save** to apply changes, or **Back** to return to the list.
|
||||
|
||||
@@ -5052,6 +5052,17 @@
|
||||
"enum": ["workspace", "personal"],
|
||||
"description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace."
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["admin", "member"],
|
||||
@@ -5070,7 +5081,7 @@
|
||||
"description": "ISO 8601 timestamp when the secret was last updated."
|
||||
}
|
||||
},
|
||||
"required": ["name", "scope", "role", "createdAt", "updatedAt"],
|
||||
"required": ["name", "scope", "description", "role", "createdAt", "updatedAt"],
|
||||
"additionalProperties": false,
|
||||
"title": "Secret metadata",
|
||||
"description": "Public secret metadata without the stored secret value."
|
||||
@@ -5107,6 +5118,7 @@
|
||||
{
|
||||
"name": "STRIPE_API_KEY",
|
||||
"scope": "workspace",
|
||||
"description": "Production billing key — rotate quarterly.",
|
||||
"role": "admin",
|
||||
"createdAt": "2026-06-01T09:14:00.000Z",
|
||||
"updatedAt": "2026-06-20T14:02:11.000Z"
|
||||
@@ -5133,6 +5145,7 @@
|
||||
"data": {
|
||||
"name": "STRIPE_API_KEY",
|
||||
"scope": "workspace",
|
||||
"description": "Production billing key — rotate quarterly.",
|
||||
"role": "admin",
|
||||
"createdAt": "2026-06-01T09:14:00.000Z",
|
||||
"updatedAt": "2026-06-20T14:02:11.000Z"
|
||||
@@ -5160,6 +5173,18 @@
|
||||
"maxLength": 65536,
|
||||
"description": "Write-only secret value. It is never returned.",
|
||||
"writeOnly": true
|
||||
},
|
||||
"description": {
|
||||
"description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"maxLength": 500
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": ["workspaceId", "scope", "value"],
|
||||
|
||||
@@ -135,6 +135,69 @@ describe('/api/v2/secrets/[name]', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards a workspace description to the set operation', async () => {
|
||||
const response = await PUT(
|
||||
request('PUT', {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
scope: 'workspace',
|
||||
value: 'secret-value',
|
||||
description: ' Prod billing key ',
|
||||
}),
|
||||
context
|
||||
)
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(mocks.set).toHaveBeenCalledWith({
|
||||
principal: PRINCIPAL,
|
||||
input: {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
name: SECRET_NAME,
|
||||
scope: 'workspace',
|
||||
value: 'secret-value',
|
||||
description: 'Prod billing key',
|
||||
},
|
||||
request: expect.anything(),
|
||||
})
|
||||
})
|
||||
|
||||
it('omits description entirely when unset so a rotation cannot erase it', async () => {
|
||||
await PUT(
|
||||
request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', value: 'rotated' }),
|
||||
context
|
||||
)
|
||||
|
||||
expect(mocks.set.mock.calls[0][0].input).not.toHaveProperty('description')
|
||||
})
|
||||
|
||||
it('normalizes an empty description to null so it matches the UI clear path', async () => {
|
||||
await PUT(
|
||||
request('PUT', {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
scope: 'workspace',
|
||||
value: 'secret-value',
|
||||
description: ' ',
|
||||
}),
|
||||
context
|
||||
)
|
||||
|
||||
expect(mocks.set.mock.calls[0][0].input.description).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a description on a personal secret rather than dropping it', async () => {
|
||||
const response = await PUT(
|
||||
request('PUT', {
|
||||
workspaceId: WORKSPACE_ID,
|
||||
scope: 'personal',
|
||||
value: 'secret-value',
|
||||
description: 'has no shared audience',
|
||||
}),
|
||||
context
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(mocks.set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 200 when replacing an existing secret', async () => {
|
||||
mocks.set.mockResolvedValueOnce({ secret, userId: 'user-1', created: false })
|
||||
|
||||
|
||||
@@ -19,7 +19,16 @@ export const PUT = defineV2JsonRoute({
|
||||
auth: v2ApiKeyAuth,
|
||||
rateLimit: v2RateLimits.publicApi,
|
||||
errorPolicy: v2OrchestrationErrorPolicy,
|
||||
mapInput: ({ params, body }) => ({ ...body, name: params.name }),
|
||||
/**
|
||||
* Normalizes a blank description to an explicit clear here rather than in the
|
||||
* contract: a Zod `.transform()` on any property drops the whole request
|
||||
* schema's OpenAPI examples, silently removing them from the published docs.
|
||||
*/
|
||||
mapInput: ({ params, body }) => ({
|
||||
...body,
|
||||
name: params.name,
|
||||
...(body.description === '' ? { description: null } : {}),
|
||||
}),
|
||||
useCase: setSecretUseCase,
|
||||
statusForResult: ({ created }) => (created ? 201 : 200),
|
||||
present: ({ secret, userId }) => ({ data: toV2Secret(secret, userId) }),
|
||||
|
||||
@@ -113,6 +113,7 @@ describe('GET /api/v2/secrets', () => {
|
||||
{
|
||||
name: 'STRIPE_API_KEY',
|
||||
scope: 'workspace',
|
||||
description: null,
|
||||
role: 'admin',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
@@ -142,6 +143,38 @@ describe('GET /api/v2/secrets', () => {
|
||||
* `mapInput` — because the contract-level sweep only checks a hand-maintained
|
||||
* map of param names and stays green when a route drops the stamp entirely.
|
||||
*/
|
||||
it('reports a workspace secret description and never a personal one', async () => {
|
||||
mocks.list.mockResolvedValue({
|
||||
secrets: [
|
||||
{ ...secret, description: 'Prod billing key' },
|
||||
{
|
||||
...secret,
|
||||
id: 'secret-2',
|
||||
type: 'env_personal' as const,
|
||||
displayName: 'MY_TEST_KEY',
|
||||
envKey: 'MY_TEST_KEY',
|
||||
envOwnerUserId: 'user-1',
|
||||
description: 'leaked from a workspace mirror',
|
||||
},
|
||||
],
|
||||
userId: 'user-1',
|
||||
nextCursorKeys: null,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
})
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
|
||||
headers: { 'x-api-key': 'key' },
|
||||
})
|
||||
)
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data[0].description).toBe('Prod billing key')
|
||||
expect(body.data[1].description).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a cursor minted under a different filter', async () => {
|
||||
mocks.list.mockResolvedValue({
|
||||
secrets: [secret],
|
||||
|
||||
@@ -13,6 +13,7 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S
|
||||
return {
|
||||
name: row.envKey,
|
||||
scope: row.type === 'env_workspace' ? 'workspace' : 'personal',
|
||||
description: row.type === 'env_workspace' ? row.description : null,
|
||||
role: row.role,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
|
||||
+42
-7
@@ -9,22 +9,48 @@ import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
|
||||
|
||||
const logger = createLogger('CredentialDetailForm')
|
||||
|
||||
/**
|
||||
* A second editable section rendered on the same detail page (e.g. a secret's
|
||||
* value), whose lifecycle is folded into the form's.
|
||||
*/
|
||||
export interface CredentialDetailFormSection {
|
||||
isDirty: boolean
|
||||
isSaving: boolean
|
||||
/**
|
||||
* Resolves true when the caller may proceed — including when there was nothing
|
||||
* to write. False only when a write was attempted and failed, which stops the
|
||||
* metadata save from committing alone.
|
||||
*/
|
||||
save: () => Promise<boolean>
|
||||
discard: () => void
|
||||
}
|
||||
|
||||
interface UseCredentialDetailFormParams {
|
||||
credential: WorkspaceCredential | null
|
||||
isAdmin: boolean
|
||||
/** Where the back link / discard navigates to. */
|
||||
backHref: string
|
||||
/**
|
||||
* An additional editable section on the page, folded into one dirty state, one
|
||||
* save, and one unsaved-changes guard. Two independent guards on a page cannot
|
||||
* coexist: each seeds its own same-URL history entry while dirty, so Back would
|
||||
* pop only one of them and leave the other stranded.
|
||||
*/
|
||||
section?: CredentialDetailFormSection
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared editable-metadata controller for a credential detail page: Display Name
|
||||
* and Description drafts seeded from the credential, dirty tracking, an
|
||||
* admin-only save, and the shared unsaved-changes guard.
|
||||
* admin-only save, and the shared unsaved-changes guard. An optional
|
||||
* {@link CredentialDetailFormSection} folds a second editor on the same page
|
||||
* into that one save and one guard.
|
||||
*/
|
||||
export function useCredentialDetailForm({
|
||||
credential,
|
||||
isAdmin,
|
||||
backHref,
|
||||
section,
|
||||
}: UseCredentialDetailFormParams) {
|
||||
const updateCredential = useUpdateWorkspaceCredential()
|
||||
|
||||
@@ -50,12 +76,18 @@ export function useCredentialDetailForm({
|
||||
const isDescriptionDirty = credential
|
||||
? descriptionDraft !== (credential.description || '')
|
||||
: false
|
||||
const isDirty = isDisplayNameDirty || isDescriptionDirty
|
||||
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty
|
||||
const isSectionDirty = section?.isDirty ?? false
|
||||
const isDirty = isMetadataDirty || isSectionDirty
|
||||
const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
|
||||
|
||||
const guard = useUnsavedChangesGuard({ isDirty, backHref })
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!credential || !isAdmin || !isDirty || updateCredential.isPending) return
|
||||
if (!credential || isSaving) return
|
||||
if (isSectionDirty && !(await section?.save())) return
|
||||
if (!isAdmin || !isMetadataDirty) return
|
||||
|
||||
try {
|
||||
await updateCredential.mutateAsync({
|
||||
credentialId: credential.id,
|
||||
@@ -73,18 +105,21 @@ export function useCredentialDetailForm({
|
||||
}, [
|
||||
credential,
|
||||
isAdmin,
|
||||
isDirty,
|
||||
isMetadataDirty,
|
||||
isSectionDirty,
|
||||
isSaving,
|
||||
section,
|
||||
isDisplayNameDirty,
|
||||
isDescriptionDirty,
|
||||
displayNameDraft,
|
||||
descriptionDraft,
|
||||
updateCredential.mutateAsync,
|
||||
updateCredential.isPending,
|
||||
])
|
||||
|
||||
const discard = useCallback(() => {
|
||||
if (credential) seedDrafts(credential)
|
||||
}, [credential, seedDrafts])
|
||||
section?.discard()
|
||||
}, [credential, section, seedDrafts])
|
||||
|
||||
return {
|
||||
displayNameDraft,
|
||||
@@ -94,7 +129,7 @@ export function useCredentialDetailForm({
|
||||
isDirty,
|
||||
save,
|
||||
discard,
|
||||
isSaving: updateCredential.isPending,
|
||||
isSaving,
|
||||
handleBackClick: guard.handleBackClick,
|
||||
showUnsavedAlert: guard.showUnsavedAlert,
|
||||
setShowUnsavedAlert: guard.setShowUnsavedAlert,
|
||||
|
||||
+11
-2
@@ -419,12 +419,21 @@ export function SecretsManager() {
|
||||
return mapped.filter(({ envVar }) => envVar.key.toLowerCase().includes(term))
|
||||
}, [envVars, searchTerm])
|
||||
|
||||
/**
|
||||
* The row has no description column, so a description-only match is legible
|
||||
* only on the secret's detail page. Personal secrets carry no shared
|
||||
* description and stay key-only.
|
||||
*/
|
||||
const filteredWorkspaceEntries = useMemo(() => {
|
||||
const entries = Object.entries(workspaceVars)
|
||||
if (!searchTerm.trim()) return entries
|
||||
const term = searchTerm.toLowerCase()
|
||||
return entries.filter(([key]) => key.toLowerCase().includes(term))
|
||||
}, [workspaceVars, searchTerm])
|
||||
return entries.filter(
|
||||
([key]) =>
|
||||
key.toLowerCase().includes(term) ||
|
||||
Boolean(workspaceEnvKeyToCredential.get(key)?.description?.toLowerCase().includes(term))
|
||||
)
|
||||
}, [workspaceVars, searchTerm, workspaceEnvKeyToCredential])
|
||||
|
||||
const filteredNewWorkspaceRows = useMemo(() => {
|
||||
const mapped = newWorkspaceRows.map((row, index) => ({ row, originalIndex: index }))
|
||||
|
||||
+38
-14
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
@@ -69,8 +69,8 @@ export function useSecretValue({ workspaceId, credential }: UseSecretValueParams
|
||||
const isDirty = draft !== currentValue
|
||||
const isSaving = savePersonal.isPending || upsertWorkspace.isPending
|
||||
|
||||
const save = async () => {
|
||||
if (!credential || !canEdit || isConflicted || !isDirty || isSaving) return
|
||||
const save = useCallback(async (): Promise<boolean> => {
|
||||
if (!credential || !canEdit || isConflicted || !isDirty || isSaving) return true
|
||||
try {
|
||||
if (isPersonal) {
|
||||
const { data: latest } = await refetchPersonal()
|
||||
@@ -79,7 +79,7 @@ export function useSecretValue({ workspaceId, credential }: UseSecretValueParams
|
||||
description: 'Could not load your latest secrets. Please try again in a moment.',
|
||||
})
|
||||
logger.warn('Aborted personal secret save: latest environment unavailable')
|
||||
return
|
||||
return false
|
||||
}
|
||||
const merged: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(latest).map(([key, entry]) => [key, entry.value])
|
||||
@@ -89,24 +89,48 @@ export function useSecretValue({ workspaceId, credential }: UseSecretValueParams
|
||||
} else {
|
||||
await upsertWorkspace.mutateAsync({ workspaceId, variables: { [envKey]: draft } })
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
toast.error("Couldn't save value", {
|
||||
description: getErrorMessage(error, 'Please try again in a moment.'),
|
||||
})
|
||||
logger.error('Failed to save secret value', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const discard = () => setDraft(currentValue)
|
||||
|
||||
return {
|
||||
value: draft,
|
||||
setValue: setDraft,
|
||||
}, [
|
||||
credential,
|
||||
canEdit,
|
||||
isConflicted,
|
||||
isDirty,
|
||||
save,
|
||||
discard,
|
||||
isSaving,
|
||||
}
|
||||
isPersonal,
|
||||
envKey,
|
||||
draft,
|
||||
workspaceId,
|
||||
refetchPersonal,
|
||||
savePersonal.mutateAsync,
|
||||
upsertWorkspace.mutateAsync,
|
||||
])
|
||||
|
||||
const discard = useCallback(() => setDraft(currentValue), [currentValue])
|
||||
|
||||
/**
|
||||
* Memoized so the object itself is stable, not just its callbacks: consumers
|
||||
* pass the whole value as one unit into {@link useCredentialDetailForm}'s
|
||||
* `section`, where a fresh object each render would churn the combined
|
||||
* save/discard identities regardless of the callbacks inside it.
|
||||
*/
|
||||
return useMemo(
|
||||
() => ({
|
||||
value: draft,
|
||||
setValue: setDraft,
|
||||
canEdit,
|
||||
isConflicted,
|
||||
isDirty,
|
||||
save,
|
||||
discard,
|
||||
isSaving,
|
||||
}),
|
||||
[draft, canEdit, isConflicted, isDirty, save, discard, isSaving]
|
||||
)
|
||||
}
|
||||
|
||||
+46
-18
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Chip, ChipCopyInput, ChipLink, Send } from '@sim/emcn'
|
||||
import { ArrowLeft, Key } from '@sim/emcn/icons'
|
||||
import { Chip, ChipCopyInput, ChipLink, ChipTextarea } from '@sim/emcn'
|
||||
import { ArrowLeft, Key, Send } from '@sim/emcn/icons'
|
||||
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
|
||||
import { ResourceTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
CredentialMembersSection,
|
||||
DetailSection,
|
||||
UnsavedChangesModal,
|
||||
useUnsavedChangesGuard,
|
||||
useCredentialDetailForm,
|
||||
} from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
|
||||
import { useSecretValue } from '@/app/workspace/[workspaceId]/settings/components/secrets/hooks/use-secret-value'
|
||||
@@ -34,10 +34,24 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||
|
||||
const valueField = useSecretValue({ workspaceId, credential })
|
||||
const guard = useUnsavedChangesGuard({ isDirty: valueField.isDirty, backHref: secretsHref })
|
||||
|
||||
/**
|
||||
* Description is workspace-only because `env_personal` credentials are
|
||||
* per-workspace mirrors of one user-global secret, so one saved here would
|
||||
* exist in this workspace alone — and a personal secret has no teammates to
|
||||
* inform. Gates the write and the render alike, so the two cannot disagree.
|
||||
*/
|
||||
const isWorkspaceSecretAdmin = isAdmin && !isPersonal
|
||||
|
||||
const form = useCredentialDetailForm({
|
||||
credential,
|
||||
isAdmin: isWorkspaceSecretAdmin,
|
||||
backHref: secretsHref,
|
||||
section: valueField,
|
||||
})
|
||||
|
||||
const back = (
|
||||
<ChipLink href={secretsHref} onClick={guard.handleBackClick} leftIcon={ArrowLeft}>
|
||||
<ChipLink href={secretsHref} onClick={form.handleBackClick} leftIcon={ArrowLeft}>
|
||||
Secrets
|
||||
</ChipLink>
|
||||
)
|
||||
@@ -45,21 +59,19 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
|
||||
const canEditValue = valueField.canEdit && !valueField.isConflicted
|
||||
|
||||
const actions =
|
||||
credential && ((isAdmin && !isPersonal) || canEditValue) ? (
|
||||
credential && (isWorkspaceSecretAdmin || canEditValue) ? (
|
||||
<>
|
||||
{isAdmin && !isPersonal && (
|
||||
{isWorkspaceSecretAdmin && (
|
||||
<Chip leftIcon={Send} onClick={() => setIsShareModalOpen(true)}>
|
||||
Share
|
||||
</Chip>
|
||||
)}
|
||||
{canEditValue && (
|
||||
<SaveDiscardChips
|
||||
dirty={valueField.isDirty}
|
||||
saving={valueField.isSaving}
|
||||
onSave={valueField.save}
|
||||
onDiscard={valueField.discard}
|
||||
/>
|
||||
)}
|
||||
<SaveDiscardChips
|
||||
dirty={form.isDirty}
|
||||
saving={form.isSaving}
|
||||
onSave={form.save}
|
||||
onDiscard={form.discard}
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -109,6 +121,22 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
{!isPersonal && (
|
||||
<DetailSection title='Description'>
|
||||
<ChipTextarea
|
||||
id='secret-description'
|
||||
rows={4}
|
||||
value={form.descriptionDraft}
|
||||
onChange={(event) => form.setDescriptionDraft(event.target.value)}
|
||||
placeholder='Add a description...'
|
||||
maxLength={500}
|
||||
autoComplete='off'
|
||||
data-lpignore='true'
|
||||
viewOnly={!isWorkspaceSecretAdmin}
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{!isPersonal && <CredentialMembersSection credentialId={credential.id} isAdmin={isAdmin} />}
|
||||
</CredentialDetailLayout>
|
||||
|
||||
@@ -121,9 +149,9 @@ export function SecretDetail({ workspaceId, credentialId }: SecretDetailProps) {
|
||||
)}
|
||||
|
||||
<UnsavedChangesModal
|
||||
open={guard.showUnsavedAlert}
|
||||
onOpenChange={guard.setShowUnsavedAlert}
|
||||
onDiscard={guard.confirmDiscard}
|
||||
open={form.showUnsavedAlert}
|
||||
onOpenChange={form.setShowUnsavedAlert}
|
||||
onDiscard={form.confirmDiscard}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { queryClient } = vi.hoisted(() => ({
|
||||
queryClient: {
|
||||
cancelQueries: vi.fn().mockResolvedValue(undefined),
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
getQueryData: vi.fn(),
|
||||
getQueriesData: vi.fn(() => []),
|
||||
setQueryData: vi.fn(),
|
||||
setQueriesData: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: {},
|
||||
useQuery: vi.fn(),
|
||||
useQueryClient: vi.fn(() => queryClient),
|
||||
useMutation: vi.fn((options) => options),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api/client/request', () => ({ requestJson: vi.fn() }))
|
||||
|
||||
import { useUpdateWorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
|
||||
const CREDENTIAL_ID = 'cred-1'
|
||||
|
||||
const existing = {
|
||||
id: CREDENTIAL_ID,
|
||||
workspaceId: 'workspace-1',
|
||||
type: 'env_workspace' as const,
|
||||
displayName: 'STRIPE_API_KEY',
|
||||
description: 'old description',
|
||||
providerId: null,
|
||||
accountId: null,
|
||||
envKey: 'STRIPE_API_KEY',
|
||||
envOwnerUserId: null,
|
||||
createdBy: 'user-1',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-02T00:00:00.000Z',
|
||||
role: 'admin' as const,
|
||||
}
|
||||
|
||||
/** Replays the detail-cache updater the mutation hands to `setQueryData`. */
|
||||
function detailAfterMutate(cached: typeof existing | null) {
|
||||
const detailCall = queryClient.setQueryData.mock.calls.find(
|
||||
([key]) => Array.isArray(key) && key.includes('detail')
|
||||
)
|
||||
const updater = detailCall?.[1] as (old: unknown) => unknown
|
||||
return updater(cached)
|
||||
}
|
||||
|
||||
describe('useUpdateWorkspaceCredential optimistic detail cache', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient.getQueryData.mockReturnValue(existing)
|
||||
queryClient.getQueriesData.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('patches the detail cache so a detail-backed editor stops being dirty after save', async () => {
|
||||
const mutation = useUpdateWorkspaceCredential() as any
|
||||
await mutation.onMutate({ credentialId: CREDENTIAL_ID, description: 'new description' })
|
||||
|
||||
expect(detailAfterMutate(existing)).toMatchObject({ description: 'new description' })
|
||||
})
|
||||
|
||||
it('clears the detail description when the edit passes null', async () => {
|
||||
const mutation = useUpdateWorkspaceCredential() as any
|
||||
await mutation.onMutate({ credentialId: CREDENTIAL_ID, description: null })
|
||||
|
||||
expect(detailAfterMutate(existing)).toMatchObject({ description: null })
|
||||
})
|
||||
|
||||
it('leaves untouched fields alone when only displayName changes', async () => {
|
||||
const mutation = useUpdateWorkspaceCredential() as any
|
||||
await mutation.onMutate({ credentialId: CREDENTIAL_ID, displayName: 'RENAMED' })
|
||||
|
||||
expect(detailAfterMutate(existing)).toMatchObject({
|
||||
displayName: 'RENAMED',
|
||||
description: 'old description',
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls the detail cache back when the update fails', async () => {
|
||||
const mutation = useUpdateWorkspaceCredential() as any
|
||||
const context = await mutation.onMutate({
|
||||
credentialId: CREDENTIAL_ID,
|
||||
description: 'new description',
|
||||
})
|
||||
queryClient.setQueryData.mockClear()
|
||||
|
||||
mutation.onError(new Error('boom'), { credentialId: CREDENTIAL_ID }, context)
|
||||
|
||||
expect(queryClient.setQueryData).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(['detail']),
|
||||
existing
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -156,35 +156,53 @@ export function useUpdateWorkspaceCredential() {
|
||||
const previousLists = queryClient.getQueriesData<WorkspaceCredential[]>({
|
||||
queryKey: workspaceCredentialKeys.lists(),
|
||||
})
|
||||
const previousDetail = queryClient.getQueryData<WorkspaceCredential | null>(
|
||||
workspaceCredentialKeys.detail(variables.credentialId)
|
||||
)
|
||||
|
||||
/** Applies the in-flight edit to one cached credential. */
|
||||
const withEdit = (cred: WorkspaceCredential): WorkspaceCredential => ({
|
||||
...cred,
|
||||
...(variables.displayName !== undefined ? { displayName: variables.displayName } : {}),
|
||||
...(variables.description !== undefined
|
||||
? { description: variables.description ?? null }
|
||||
: {}),
|
||||
})
|
||||
|
||||
/*
|
||||
* The detail cache is patched alongside the lists, not just cancelled: a
|
||||
* detail-backed editor compares its drafts against this entry to decide
|
||||
* whether it is dirty, so leaving it stale keeps the surface dirty after a
|
||||
* successful save until the `onSettled` refetch lands — long enough for
|
||||
* Discard to restore the pre-save value over the committed one.
|
||||
*/
|
||||
queryClient.setQueryData<WorkspaceCredential | null>(
|
||||
workspaceCredentialKeys.detail(variables.credentialId),
|
||||
(old) => (old ? withEdit(old) : old)
|
||||
)
|
||||
|
||||
queryClient.setQueriesData<WorkspaceCredential[]>(
|
||||
{ queryKey: workspaceCredentialKeys.lists() },
|
||||
(old) => {
|
||||
if (!old) return old
|
||||
return old.map((cred) =>
|
||||
cred.id === variables.credentialId
|
||||
? {
|
||||
...cred,
|
||||
...(variables.displayName !== undefined
|
||||
? { displayName: variables.displayName }
|
||||
: {}),
|
||||
...(variables.description !== undefined
|
||||
? { description: variables.description ?? null }
|
||||
: {}),
|
||||
}
|
||||
: cred
|
||||
)
|
||||
return old.map((cred) => (cred.id === variables.credentialId ? withEdit(cred) : cred))
|
||||
}
|
||||
)
|
||||
|
||||
return { previousLists }
|
||||
return { previousLists, previousDetail }
|
||||
},
|
||||
onError: (_err, _variables, context) => {
|
||||
onError: (_err, variables, context) => {
|
||||
if (context?.previousLists) {
|
||||
for (const [queryKey, data] of context.previousLists) {
|
||||
queryClient.setQueryData(queryKey, data)
|
||||
}
|
||||
}
|
||||
if (context?.previousDetail !== undefined) {
|
||||
queryClient.setQueryData(
|
||||
workspaceCredentialKeys.detail(variables.credentialId),
|
||||
context.previousDetail
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
|
||||
@@ -232,6 +232,7 @@ const CREDENTIAL_CONNECTION_EXAMPLE = {
|
||||
const SECRET_EXAMPLE = {
|
||||
name: 'STRIPE_API_KEY',
|
||||
scope: 'workspace',
|
||||
description: 'Production billing key — rotate quarterly.',
|
||||
role: 'admin',
|
||||
createdAt: '2026-06-01T09:14:00.000Z',
|
||||
updatedAt: '2026-06-20T14:02:11.000Z',
|
||||
|
||||
@@ -33,6 +33,12 @@ export const v2SecretSchema = z
|
||||
.object({
|
||||
name: v2SecretNameSchema,
|
||||
scope: v2SecretScopeSchema,
|
||||
description: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience.'
|
||||
),
|
||||
role: workspaceCredentialRoleSchema.describe('Caller role for the secret.'),
|
||||
createdAt: v2TimestampSchema.describe('ISO 8601 timestamp when the secret was created.'),
|
||||
updatedAt: v2TimestampSchema.describe('ISO 8601 timestamp when the secret was last updated.'),
|
||||
@@ -88,8 +94,25 @@ export const v2SetSecretBodySchema = z
|
||||
.max(65_536, 'value is too long')
|
||||
.describe('Write-only secret value. It is never returned.')
|
||||
.meta({ writeOnly: true }),
|
||||
description: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(500, 'description must be at most 500 characters')
|
||||
.nullish()
|
||||
.describe(
|
||||
'What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.'
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scope === 'personal' && data.description !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['description'],
|
||||
message: 'description is only supported for a workspace secret',
|
||||
})
|
||||
}
|
||||
})
|
||||
export type V2SetSecretBody = z.input<typeof v2SetSecretBodySchema>
|
||||
|
||||
export const v2DeleteSecretQuerySchema = z
|
||||
|
||||
@@ -441,6 +441,41 @@ describe('performUpdateCredential — service-account secret rotation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('performUpdateCredential — description scope', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetDbChainMock()
|
||||
mockIsClientCredentialAccountProviderId.mockReturnValue(false)
|
||||
mockGetClientCredentialAccountDescriptor.mockReturnValue(undefined)
|
||||
})
|
||||
|
||||
it('applies a description to a workspace secret', async () => {
|
||||
mockCredential({ type: 'env_workspace', envKey: 'STRIPE_API_KEY', providerId: null })
|
||||
|
||||
const result = await performUpdateCredential({
|
||||
credentialId: 'cred-1',
|
||||
userId: 'user-1',
|
||||
description: 'Prod billing key',
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(updatePayload().description).toBe('Prod billing key')
|
||||
})
|
||||
|
||||
it('rejects a description on a personal secret instead of writing dead data', async () => {
|
||||
mockCredential({ type: 'env_personal', envKey: 'MY_TEST_KEY', providerId: null })
|
||||
|
||||
const result = await performUpdateCredential({
|
||||
credentialId: 'cred-1',
|
||||
userId: 'user-1',
|
||||
description: 'invisible dead data',
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({ success: false, errorCode: 'validation' })
|
||||
expect(result.success ? '' : result.error).toMatch(/cannot have a description/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createServiceAccountCredential', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -193,6 +193,20 @@ export async function updateCredentialRecord(
|
||||
params: UpdateCredentialRecordParams
|
||||
): Promise<PerformCredentialResult> {
|
||||
try {
|
||||
// A description is teammate-facing, so it is meaningless on `env_personal`:
|
||||
// those rows are per-workspace mirrors of one user-global secret, and every
|
||||
// reader already hides or nulls the field for them. Rejected here rather than
|
||||
// at one adapter, so no surface can write data every reader hides — and said
|
||||
// plainly, since dropping the field would fall through to the generic
|
||||
// "no updatable fields" error and explain nothing.
|
||||
if (params.description !== undefined && params.credential.type === 'env_personal') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'A personal secret cannot have a description; it is not shared with teammates.',
|
||||
errorCode: 'validation',
|
||||
}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {}
|
||||
if (params.description !== undefined) {
|
||||
updates.description = params.description ?? null
|
||||
|
||||
@@ -32,8 +32,14 @@ export async function setWorkspaceSecret(params: {
|
||||
name: string
|
||||
value: string
|
||||
userId: string
|
||||
/**
|
||||
* Teammate-facing note on the credential row. `undefined` leaves any existing
|
||||
* description untouched so rotating a value can't silently erase it; `null`
|
||||
* clears it.
|
||||
*/
|
||||
description?: string | null
|
||||
}): Promise<SecretMutationResult> {
|
||||
const { workspaceId, name, value, userId } = params
|
||||
const { workspaceId, name, value, userId, description } = params
|
||||
const { encrypted } = await encryptSecret(value)
|
||||
const updatedAt = new Date()
|
||||
|
||||
@@ -76,7 +82,7 @@ export async function setWorkspaceSecret(params: {
|
||||
})
|
||||
await tx
|
||||
.update(credential)
|
||||
.set({ updatedAt })
|
||||
.set(description === undefined ? { updatedAt } : { updatedAt, description })
|
||||
.where(
|
||||
and(
|
||||
eq(credential.workspaceId, workspaceId),
|
||||
|
||||
@@ -163,6 +163,38 @@ describe('secret application use cases', () => {
|
||||
expect(JSON.stringify(mocks.audit.mock.calls)).not.toContain('secret-value')
|
||||
})
|
||||
|
||||
it('forwards a workspace description to the manager', async () => {
|
||||
await setSecretUseCase.execute({
|
||||
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
|
||||
input: {
|
||||
workspaceId: workspace.workspaceId,
|
||||
name: secret.envKey,
|
||||
scope: 'workspace',
|
||||
value: 'secret-value',
|
||||
description: 'Prod billing key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mocks.setWorkspace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: 'Prod billing key' })
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a description on a personal secret in the use case, not just the contract', async () => {
|
||||
await expect(
|
||||
setSecretUseCase.execute({
|
||||
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
|
||||
input: {
|
||||
workspaceId: workspace.workspaceId,
|
||||
name: secret.envKey,
|
||||
scope: 'personal',
|
||||
value: 'secret-value',
|
||||
description: 'has no shared audience',
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(/only supported for a workspace secret/)
|
||||
})
|
||||
|
||||
it('still fails a workspace write whose metadata never materialized', async () => {
|
||||
mocks.listCredentials.mockResolvedValue({ data: [], nextCursorKeys: null })
|
||||
|
||||
|
||||
@@ -250,6 +250,12 @@ export interface SetSecretInput {
|
||||
name: string
|
||||
scope: SecretScope
|
||||
value: string
|
||||
/**
|
||||
* Workspace scope only, and rejected at the contract for personal scope: an
|
||||
* `env_personal` row is a per-workspace mirror of one user-global secret, so a
|
||||
* description written here would exist in this workspace alone.
|
||||
*/
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
|
||||
@@ -259,6 +265,12 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
|
||||
authorizationOptions,
|
||||
async execute({ principal, input, context }) {
|
||||
const userId = principalUserId(principal)
|
||||
if (input.scope === 'personal' && input.description !== undefined) {
|
||||
throw new OrchestrationError(
|
||||
'validation',
|
||||
'description is only supported for a workspace secret'
|
||||
)
|
||||
}
|
||||
if (input.scope === 'workspace') {
|
||||
await requireWorkspaceSecretMutationAccess({
|
||||
workspaceId: context.workspaceId,
|
||||
@@ -273,6 +285,7 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
|
||||
name: input.name,
|
||||
value: input.value,
|
||||
userId,
|
||||
description: input.description,
|
||||
})
|
||||
const secret = await getWorkspaceSecretMetadata({
|
||||
workspaceId: context.workspaceId,
|
||||
@@ -297,7 +310,11 @@ export const setSecretUseCase = defineAuthorizedWorkspaceUseCase({
|
||||
resourceId: `${input.scope}:${input.name}`,
|
||||
resourceName: input.name,
|
||||
description: `Set ${input.scope} secret "${input.name}"`,
|
||||
metadata: { scope: input.scope, name: input.name },
|
||||
metadata: {
|
||||
scope: input.scope,
|
||||
name: input.name,
|
||||
...(input.description !== undefined ? { descriptionUpdated: true } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
@@ -15,12 +15,14 @@ const SECRET_RESULT: CommandSpec = {
|
||||
{ header: 'scope' },
|
||||
{ header: 'role' },
|
||||
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
|
||||
{ header: 'description' },
|
||||
],
|
||||
}
|
||||
|
||||
interface SetSecretOptions {
|
||||
scope: (typeof SECRET_SCOPES)[number]
|
||||
value?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
function validateSecretValue(value: string): string {
|
||||
@@ -31,7 +33,26 @@ function validateSecretValue(value: string): string {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* A description belongs to the workspace secret teammates share; a personal
|
||||
* secret has none, and the API rejects one. Failing here names the flag rather
|
||||
* than surfacing a validation error against the request body, and does so before
|
||||
* the interactive value prompt. The length bound is left to the API, whose
|
||||
* message already names the field — a copy here would silently drift from it.
|
||||
*/
|
||||
function validateDescriptionScope(
|
||||
description: string | undefined,
|
||||
scope: SetSecretOptions['scope']
|
||||
): string | undefined {
|
||||
if (description === undefined) return undefined
|
||||
if (scope === 'personal') {
|
||||
throw new SimApiError('--description is only supported for a workspace secret.', 0)
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
async function setSecret(name: string, options: SetSecretOptions, command: Command): Promise<void> {
|
||||
const description = validateDescriptionScope(options.description, options.scope)
|
||||
const value = validateSecretValue(options.value ?? (await promptSecret()))
|
||||
const { client, profile } = clientFrom(command)
|
||||
const operation = V2_OPERATIONS.setSecret
|
||||
@@ -41,6 +62,7 @@ async function setSecret(name: string, options: SetSecretOptions, command: Comma
|
||||
workspaceId: client.requireWorkspace(),
|
||||
scope: options.scope,
|
||||
value,
|
||||
description,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -62,6 +84,10 @@ export function attachSecretCommands(program: Command): void {
|
||||
.makeOptionMandatory()
|
||||
)
|
||||
.option('--value <value>', 'Secret value; visible to shell history when supplied directly')
|
||||
.option(
|
||||
'--description <description>',
|
||||
'What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged'
|
||||
)
|
||||
.action((name: string, options: SetSecretOptions, command: Command) =>
|
||||
setSecret(name, options, command)
|
||||
)
|
||||
|
||||
@@ -456,11 +456,14 @@ export const CLI_CONTRACT: CliContract = {
|
||||
],
|
||||
},
|
||||
listSecrets: {
|
||||
// `description` trails the existing columns: `--output text` is positional,
|
||||
// so inserting ahead of `updated` would shift every field a script already cuts.
|
||||
columns: [
|
||||
{ header: 'name' },
|
||||
{ header: 'scope' },
|
||||
{ header: 'role' },
|
||||
{ header: 'updated', path: 'updatedAt', format: 'timestamp' },
|
||||
{ header: 'description' },
|
||||
],
|
||||
},
|
||||
getWorkspace: {
|
||||
|
||||
@@ -3794,6 +3794,7 @@ export type ListSecretsQuery = {
|
||||
type ListSecretsResponseRef0 = {
|
||||
name: string
|
||||
scope: 'workspace' | 'personal'
|
||||
description: string | null
|
||||
role: 'admin' | 'member'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -4790,11 +4791,13 @@ export type SetSecretBody = {
|
||||
workspaceId: string
|
||||
scope: 'workspace' | 'personal'
|
||||
value: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
type SetSecretResponseRef0 = {
|
||||
name: string
|
||||
scope: 'workspace' | 'personal'
|
||||
description: string | null
|
||||
role: 'admin' | 'member'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -8535,6 +8538,11 @@ export const V2_OPERATIONS = {
|
||||
required: true,
|
||||
describe: 'Write-only secret value. It is never returned.',
|
||||
},
|
||||
description: {
|
||||
kind: 'string',
|
||||
describe:
|
||||
'What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tableExportDownload: {
|
||||
|
||||
Reference in New Issue
Block a user