Skip to content
Merged
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
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/cli/reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/cli/secrets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
5 changes: 3 additions & 2 deletions apps/docs/content/docs/en/platform/credentials.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 26 additions & 1 deletion apps/docs/openapi-v2-resources.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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."
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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"],
Expand Down
63 changes: 63 additions & 0 deletions apps/sim/app/api/v2/secrets/[name]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
11 changes: 10 additions & 1 deletion apps/sim/app/api/v2/secrets/[name]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }),
Expand Down
33 changes: 33 additions & 0 deletions apps/sim/app/api/v2/secrets/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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],
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/v2/secrets/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -94,7 +129,7 @@ export function useCredentialDetailForm({
isDirty,
save,
discard,
isSaving: updateCredential.isPending,
isSaving,
handleBackClick: guard.handleBackClick,
showUnsavedAlert: guard.showUnsavedAlert,
setShowUnsavedAlert: guard.setShowUnsavedAlert,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
Loading
Loading