Skip to content

Commit fe0b92b

Browse files
feat(secrets): show where a secret is referenced, beside its usage log (#6947)
* feat(secrets): show where a secret is referenced, beside its usage log "See usage" answered who has run something with a key. It could not answer the question a rotation actually starts from — where is this wired in — because a secret four blocks depend on but nothing has executed yet has no usage rows at all, so the panel read "This secret has not been used yet" for a live key. The usage view now carries two tabs. Logs is the existing trail, unchanged and still the default, since that is what the header action has always opened. References is new: the blocks that name the secret as {{KEY}}, grouped under their workflow, then the custom tools and MCP servers whose own bodies carry it. Detection is the workspace-fork remapper's. remapSubBlocks already walks nested tool-input params, resolves canonical basic/advanced pairs, and skips dormant and condition-hidden members, so calling it per block inherits every rule a fork already obeys. Only the aggregation is new: scanWorkflowReferences collapses its output to unique (kind, sourceId) pairs and discards the workflow — right for building a mapping table, wrong for locating a key. Nothing under ee/workspace-forking changed. - Candidates come from strpos(sub_blocks::text, name) > 0, deliberately not LIKE: `_` is a LIKE single-character wildcard and nearly every env key contains one, so SB_ACTION_ROUTER_SECRET would match text it does not occur in. The prefilter can over-match but never under-match; the scanner decides. The plan is an index scan on workflow by workspace, nested-looped into workflow_blocks, so cost tracks the workspace rather than the table. - Scope gates the read but does not narrow it. A {{KEY}} names a key, not a scope, so the same sites answer for a workspace secret and the personal one it shadows; narrowing here would report a personal secret as unreferenced the moment a workspace variable of the same name existed. - References reports one field per block, not a list. The remapper dedupes a block's references by (kind, sourceId), so a block naming the secret twice yields one entry — the type says so and a test pins it, because the row renders that field as its whole description. - Reads live state, not deployed: a draft workflow referencing the key must show. Blocks are capped and the cap is reported as `truncated` rather than silently trimming the list. - Authorization is the existing usage gate, renamed requireSecretTrailReadAccess and shared verbatim, so the two tabs can never disagree about who may look. UI is existing primitives only — ChipModalTabs for the strip, DetailSection per workflow over RESOURCE_LIST_STACK rows, IntegrationTile for the block glyph so a block reads here as it does on an integrations row, SettingsEmptyState for the gates. No new component, no new class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): close the reference-scan scope bypass and bound its output Review round 1. - use-cases.ts: `scope` was a caller-controlled assertion the reference scan never narrowed by, so `scope=personal` returned from the shared gate before any check and handed any workspace member the admin-gated reference map for any workspace secret. The usage trail can trust that scope because it filters the read by `secretOwnerUserId`; a name-based workspace-wide scan cannot. References now authorize on what the NAME resolves to — a workspace secret under that name is admin-gated outright, and absent one the caller must actually hold a personal secret of that name, which also stops a member enumerating arbitrary names. `scope` is dropped from the input, the contract, the hook and the query key rather than merely ignored: a parameter that does not exist cannot be asserted. The trail gate keeps its old name and a note saying why only a scope-narrowed read may reuse it. - scan.ts: the prefilter matched the bare name, so `API_KEY` also read every block holding `{{API_KEY_TEST}}` or the words "the API_KEY value" — and those false positives counted against the row cap, so on a workspace with enough of them genuine references sorted later were never read at all. It now matches the reference syntax (`{{name}}`, with the whitespace ENV_REF_PATTERN allows), so a candidate is a real occurrence and the cap means what it says. A name outside the env-key charset short-circuits, which is also what makes it safe to inline into the regex unescaped. Verified against a real workspace: the exact key still returns its 16 blocks, its prefix now returns 0 where it previously matched all 16, and a metacharacter name touches no query. - scan.ts: capping tool and server ROWS did not bound the output — one MCP server emits an entry per matching header plus one for its url, so 200 rows could expand past the contract's 400-entry bound and make the route reject its own response, turning a successful scan into a 500 and the tab into "Could not load references." Emission now stops at the bound and reports `truncated`. - secret-references-panel.tsx: the empty-state early return preceded the truncation banner, so a capped scan that filtered everything out claimed the secret was unreferenced. Both paths now share one note, and silence from a capped scan reads as absence of evidence rather than evidence of absence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): cover unicode whitespace, legacy keys, and the shadowed-personal tab Review round 2 — all three follow from round 1's own fixes. - scan.ts: the syntax prefilter anchored on `[[:space:]]`, but the two engines disagree about what whitespace is. `ENV_REF_PATTERN`'s `\s` accepts U+00A0, U+202F and U+3000; Postgres `[[:space:]]` matches only the ASCII set. So a value pasted with a non-breaking space inside the braces is a reference the executor resolves and the prefilter silently dropped — the one failure direction this feature must never take, since the answer it gives is "unused, safe to delete". Anchoring on `[^[:alnum:]_]` instead accepts every whitespace encoding while still rejecting a longer key on either side, and needs no code-point list that could drift. It can admit a non-reference like `{{-NAME-}}`; that costs one candidate row, and the scanner re-checks every candidate regardless. Erring loose here is deliberate. (Greptile's `{{\tAPI_KEY\t}}` example was already handled — tab is ASCII — but the unicode half of the finding was real.) - use-cases.ts: the gate read `keyAccess.knownKeys` as "a workspace secret exists under this name", but that set only covers names with an `env_workspace` credential row. A legacy value written before the ACL existed has no row and still wins at run time, so it fell through to the personal branch and handed a non-admin the reference map for exactly the oldest keys. It now reads the authoritative `workspace_environment.variables` map through a new `hasWorkspaceEnvValue`, which is documented against `knownKeys` so the two are not confused again. `getWorkspaceEnvKeyAdminAccess` keeps its existing contract — its `knownKeys` still answers the ACL question its other callers ask. - secret-references-panel.tsx: a personal secret shadowed by a same-named workspace variable could open the view (its owner may read their own Logs) but References always hit the workspace refusal and rendered a generic load error — a tab offered in a state where it cannot succeed. The refusal is correct; the tab now states the shadowing instead of asking for a map it will be denied, reusing the wording the detail page already shows. No request is made in that state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): re-check the reference gate's volatile input after the scan Review round 3. The name-resolution gate reads whether a workspace value exists, then scans. A workspace secret created between the two makes the map now in hand admin-gated, so a personal owner could receive it without workspace-secret administration. The window is small and the data is derivable — a workspace member can already open every workflow and read its `{{KEY}}` references — but the gate's stated contract is that references follow the same predicate as revealing the value, and a point-in-time check that can be overtaken does not honour that. An advisory lock or a snapshot transaction would serialize a read-only view against secret writes for it, which is the wrong trade. Instead the one volatile input is re-read after the scan and the request fails closed if it flipped. `requireSecretReferencesReadAccess` now reports which branch authorized: an `admin` grant holds however the name resolves and pays nothing, while a `personal` grant — the only one resting on absence — is re-checked. A non-admin loses nothing they were entitled to keep; the request is refused the way it would have been a moment later. Adds a `listSecretReferencesUseCase` suite covering both denial paths, the legacy value, the personal owner, the admin short-circuit, and the race itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): accept JSON-escaped whitespace in the reference prefilter Review round 4. The prefilter reads a `::text` rendering of a JSON column, and `jsonb::text` renders a real tab inside a string value as the literal pair `\` `t`. `t` is alphanumeric, so `[^[:alnum:]_]` could not consume it and the row was discarded before `ENV_REF_PATTERN` ever ran — the References tab omitting a live reference and reporting `truncated: false` while doing it. Round 3's fix was verified against a raw text value rather than the JSON rendering, which is exactly why it looked correct: `E'{{\tAPI_KEY\t}}'` matches, `jsonb_build_object('v', E'{{\tAPI_KEY\t}}')::text` does not. The gap between `{{` and the name now accepts three encodings at once — raw characters (covering every Unicode space, which Postgres `[[:space:]]` misses), JSON two-character escapes, and `\uXXXX` (how a vertical tab survives the same rendering). Verified against the real jsonb rendering: tab, newline, carriage return, vertical tab and form feed all recover, U+00A0 / U+3000 / space / plain keep matching, and `{{API_KEY_TEST}}`, `{{MY_API_KEY}}` and prose are still rejected — so the row cap keeps meaning what it says. Plain-text columns (`custom_tools.code`, `mcp_servers.url`) carry no JSON escaping, but tool code is JavaScript source and can contain the same escape sequences literally, so the one predicate is right for every column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(secrets): land the References link on the block, and name its field Feedback round. - Logs leads the tab strip. It was already the default tab; the order now says so. - The usage view drops its resource heading for a plain "Usage" title. The back chip already names the secret, so the tile and the subtitle underneath were saying it a second time. `CredentialDetailLayout` gains an optional `title` that renders the same element, class and column position the settings shell gives `SettingsPanel` — which is how the sibling Forks "Activity" view titles itself. Existing callers pass nothing and are unchanged. - A block row now lands on the block instead of the workflow's default framing. The editor had no URL params at all, so `?block=` is its first: read once on arrival, acted on, and stripped. It is a navigation signal rather than canvas state — the carve-out in sim-url-state.md is about pan, zoom, selection and drag, which are socket-synced or high-frequency; this is neither, and it rides in the link so a middle-click or reload keeps it where an in-memory handoff could not. The consuming effect mirrors the note-search reveal in the same file, including the three details that make that one work: read from `displayNodes` so a target arriving before its node mounts is retried on the mounting commit, route selection through `resolveSelectionConflicts`, and latch in a ref. It also claims `userFocusedWorkflowIdRef` the way a node click does, because `onInit` re-reads that inside its own rAF and would otherwise `fitView` over the camera — and that ref is reset by exactly the `workflowIdParam` change a deep link causes. The panel opens for free: `syncPanelWithSelection` already follows selection. `useSearchParams` needs a Suspense boundary and the editor's ancestry has none, so the read lives in a leaf under its own `fallback={null}` rather than wrapping the editor and adding a `loading.tsx` to its mount path. `next build` passes. - A tool-input reference showed `tools-tool-0-code`. Those `{subBlockId}-tool-{index}-{paramId}` keys are documented as an ephemeral, client-only projection of the canonical `tool-input` value and are not meant to be persisted, but older rows carry them — so the scanner reported whichever the record yielded last. They are dropped before scanning, which is right even where the two disagree: `tool.params` is what executes, so a mirror the canonical no longer matches describes a reference that no longer runs. - The row now shows the field's label from the block config rather than its storage id — "API Key", "Tools", "Code", "Bot Token" — falling back to the id when the block or field is unregistered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): make the reference prefilter exactly as tight as the authority Review round 6. The gap between `{{` and the name accepted any non-word character, so `{{-API_KEY-}}` and `{{"API_KEY"}}` matched in SQL while `ENV_REF_PATTERN` rejects them. The previous commit called that free — "costs a candidate row and nothing else" — which was wrong: a candidate row is a slot under BLOCK_SCAN_LIMIT, so enough near-misses sorted earlier exhaust the cap before a genuine reference is read, and the tab reports a live key as unused. That is the same failure the tightening in round 1 was meant to remove, reintroduced by the round 4 loosening that fixed JSON-escaped whitespace. The gap now enumerates exactly the whitespace `\s` accepts, in each encoding it can arrive in: `[[:space:]]` for raw ASCII, `\\[tnrf]` and `\\u000[bB]` for the JSON escapes, and an explicit class for the Unicode spaces Postgres emits verbatim but `[[:space:]]` does not match. That class is generated from a code-point table rather than written literally. Writing it by hand put a run of invisible characters in the source — a reviewer cannot check them, and a formatter or editor can silently mangle them. The table is the readable form and `toPgEscape` renders it. Verified against the real jsonb rendering, 17 cases: raw space, tab, newline, carriage return, vertical tab, form feed, U+00A0, U+202F, U+3000 and an embedded reference all match; `{{-API_KEY-}}`, `{{"API_KEY"}}`, `{{API_KEY_TEST}}`, `{{MY_API_KEY}}`, prose and an across-braces span all do not. Every candidate the SQL admits is now a real occurrence, so the cap counts references and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): cap the reference scan on results, not candidates Review round 7. The prefilter now matches reference syntax exactly, but `remapSubBlocks` filters further on semantics SQL cannot see: it drops dormant canonical members and condition-hidden fields. So a block whose only `{{KEY}}` sits in a hidden field is a genuine candidate that yields nothing, and with the cap counting candidates, enough of those sorted earlier displaced active references out of the answer. Unlike the previous two rounds this is not fixable by tightening the prefilter — no SQL predicate can evaluate canonical modes or field conditions. So the cap moves to what it should have counted all along: blocks REPORTED. Candidates are read a page at a time up to a ceiling far above the result limit, so filtered rows are absorbed as extra reads instead of taking result slots. Paging rather than one large read because the alternative is holding every candidate block's `sub_blocks` in memory at once; peak memory is now one page. `blockId` joins the ordering as a final tiebreak, since OFFSET paging over a non-unique sort can repeat or skip rows across pages — which here would double-report a block or silently lose one. This does not make the scan unconditionally complete, and the ceiling says so: bounded work and guaranteed completeness cannot both hold, so the only real choice is where the bound sits and whether it counts something the reader can see. It now counts results. Query plan re-checked with the OFFSET in place: still an index scan on workflow by workspace, nested-looped into workflow_blocks. Test added that pins the fix — 2,500 prose candidates sorted ahead of one real reference, which the previous cap dropped entirely. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): drop the paging that caused drift, and stop false truncation Review round 8. - Paging removed. It bought headroom and paid with drift: `OFFSET` is positional, so a block renamed, inserted or deleted between page queries shifts the result set, and the scan skips a live reference or reports one twice. That is a worse failure than the one paging was added to fix, and it was self-inflicted last round. Candidates are read in one statement again — one statement is one snapshot, so neither skew nor duplication is possible — with the ceiling lowered to 4,000 so a single read stays a sane amount of memory. Result-capping survives, which was the actual point: filtered rows are still absorbed as extra reads rather than taking result slots. - `truncated` no longer fires on an exact landing. The block path now uses the limit-plus-one read and strict `>` the resource paths already used, so a scan that ends precisely on a bound reports complete instead of warning about references that were all returned. - The deep-link target is released when its block does not exist. It was cleared only on a match, so a link to a since-deleted block left the id set with the param already stripped: the effect re-checked on every canvas update forever and shadowed a later link to the same block. Once any node has mounted the canvas is populated, so an id still absent is gone and the target is dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(secrets): gate the deep-link release on the workflow being ready Review round 9. Round 8 released a deep-link target once `displayNodes` was non-empty, reading that as "the canvas is populated, so a missing id is deleted". It is not: arriving from another workflow the store still holds that graph, so nodes are present while the linked workflow is still hydrating — and a valid `?block=` target was dropped before its own blocks ever mounted. The file already had the right predicate. `isWorkflowReady` pins `hydration.phase === 'ready'`, `hydration.workflowId === workflowIdParam` and `activeWorkflowId === workflowIdParam`, which is exactly "the graph now loaded is this workflow's". Absence is only conclusive under that, and a node count never was — it says something mounted, not whose. This is the second fix to this release condition in two rounds, both from guessing at a readiness signal instead of using the one the component already computes for the same question. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent b440f4e commit fe0b92b

21 files changed

Lines changed: 1719 additions & 18 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({ listReferences: vi.fn() }))
8+
9+
vi.mock('@/lib/secrets/application/use-cases', () => ({
10+
listSecretReferencesUseCase: {
11+
operation: { id: 'secrets.references' },
12+
execute: mocks.listReferences,
13+
},
14+
}))
15+
16+
import { GET } from '@/app/api/secrets/references/route'
17+
18+
const url = 'http://localhost/api/secrets/references?workspaceId=workspace-1&name=API_KEY'
19+
20+
describe('GET /api/secrets/references', () => {
21+
beforeEach(() => {
22+
vi.clearAllMocks()
23+
authMockFns.mockGetSession.mockResolvedValue({
24+
user: { id: 'admin-1' },
25+
session: { id: 'session-1' },
26+
})
27+
})
28+
29+
it('returns the workflows, blocks, and resources a secret is wired into', async () => {
30+
mocks.listReferences.mockResolvedValue({
31+
workflows: [
32+
{
33+
workflowId: 'workflow-1',
34+
workflowName: 'Nightly sync',
35+
blocks: [
36+
{ blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' },
37+
],
38+
},
39+
],
40+
resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }],
41+
truncated: false,
42+
})
43+
44+
const response = await GET(createMockRequest('GET', undefined, {}, url))
45+
46+
expect(response.status).toBe(200)
47+
expect(await response.json()).toEqual({
48+
workflows: [
49+
{
50+
workflowId: 'workflow-1',
51+
workflowName: 'Nightly sync',
52+
blocks: [
53+
{ blockId: 'block-1', blockName: 'Fetch orders', blockType: 'api', field: 'apiKey' },
54+
],
55+
},
56+
],
57+
resources: [{ id: 'tool-1', kind: 'custom-tool', name: 'Order lookup', field: 'code' }],
58+
truncated: false,
59+
})
60+
})
61+
62+
it('returns empty lists for a secret referenced nowhere', async () => {
63+
mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false })
64+
65+
const response = await GET(createMockRequest('GET', undefined, {}, url))
66+
67+
expect(response.status).toBe(200)
68+
expect(await response.json()).toEqual({ workflows: [], resources: [], truncated: false })
69+
})
70+
71+
it('rejects a request that names no secret', async () => {
72+
const response = await GET(
73+
createMockRequest(
74+
'GET',
75+
undefined,
76+
{},
77+
'http://localhost/api/secrets/references?workspaceId=workspace-1'
78+
)
79+
)
80+
81+
expect(response.status).toBe(400)
82+
expect(mocks.listReferences).not.toHaveBeenCalled()
83+
})
84+
85+
/**
86+
* The contract carries no `scope`. It used to, and because a reference scan is name-based and
87+
* never narrowed by scope, asserting `personal` skipped the admin gate outright — a member
88+
* could read the reference map for any workspace secret. A stray `scope` must therefore reach
89+
* neither the gate nor the scan.
90+
*/
91+
it('ignores a scope the caller tries to assert', async () => {
92+
mocks.listReferences.mockResolvedValue({ workflows: [], resources: [], truncated: false })
93+
94+
const response = await GET(createMockRequest('GET', undefined, {}, `${url}&scope=personal`))
95+
96+
expect(response.status).toBe(200)
97+
expect(mocks.listReferences).toHaveBeenCalledTimes(1)
98+
expect(mocks.listReferences.mock.calls[0]?.[0]?.input).toEqual({
99+
workspaceId: 'workspace-1',
100+
name: 'API_KEY',
101+
})
102+
})
103+
104+
/**
105+
* The use case gates the read behind the same permission that reveals the value. A refusal
106+
* has to reach the client as a refusal — surfacing it as an empty list would read as
107+
* "referenced nowhere" and invite deleting a live key.
108+
*/
109+
it('surfaces the use case refusal rather than an empty list', async () => {
110+
const { ForbiddenOperationError } = await import('@/lib/core/application/forbidden')
111+
mocks.listReferences.mockRejectedValue(
112+
new ForbiddenOperationError(
113+
'SECRET_ADMIN_ACCESS_REQUIRED',
114+
'Credential admin permission required to view this secret usage'
115+
)
116+
)
117+
118+
const response = await GET(createMockRequest('GET', undefined, {}, url))
119+
120+
expect(response.status).toBe(403)
121+
})
122+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { getSecretReferencesContract } from '@/lib/api/contracts/secrets'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { secretOperations } from '@/lib/secrets/application/operations'
9+
import { listSecretReferencesUseCase } from '@/lib/secrets/application/use-cases'
10+
11+
/** GET /api/secrets/references — where one secret is wired in, for the credential detail panel. */
12+
export const GET = defineInternalJsonRoute({
13+
contract: getSecretReferencesContract,
14+
auth: internalSessionAuth,
15+
operation: secretOperations.references,
16+
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
17+
errorPolicy: internalOrchestrationErrorPolicy,
18+
mapInput: ({ query }) => ({
19+
workspaceId: query.workspaceId,
20+
name: query.name,
21+
}),
22+
useCase: listSecretReferencesUseCase,
23+
/** The scan's shape is already the wire shape — nothing to project or serialize. */
24+
present: (scan) => scan,
25+
})

apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ interface CredentialDetailLayoutProps {
77
back: ReactNode
88
/** Optional controls grouped at the end of the action bar. */
99
actions?: ReactNode
10+
/**
11+
* Page title, for a view whose subject is the view itself rather than a resource — the same
12+
* slot `SettingsPanel` fills for a detail sub-view like the Forks "Activity" page. A surface
13+
* that leads with a resource uses {@link CredentialDetailHeading} instead; the two are
14+
* alternatives, not a pair.
15+
*/
16+
title?: ReactNode
1017
children: ReactNode
1118
}
1219

@@ -16,15 +23,24 @@ interface CredentialDetailLayoutProps {
1623
* supply the slots and body sections; all layout chrome lives here so callsites
1724
* stay free of bespoke styling.
1825
*/
19-
export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) {
26+
export function CredentialDetailLayout({
27+
back,
28+
actions,
29+
title,
30+
children,
31+
}: CredentialDetailLayoutProps) {
2032
return (
2133
<div className='flex h-full flex-col bg-[var(--bg)]'>
2234
<div className={cn(PAGE_HEADER_BAR, 'justify-between')}>
2335
{back}
2436
{actions ? <div className={HEADER_ACTION_CLUSTER}>{actions}</div> : null}
2537
</div>
2638
<div className='min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]'>
27-
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>{children}</div>
39+
<div className='mx-auto flex w-full max-w-[48rem] flex-col gap-7 pb-6'>
40+
{/* Same element, class and column position the settings shell gives its page title. */}
41+
{title ? <h1 className='text-[var(--text-body)] text-lg'>{title}</h1> : null}
42+
{children}
43+
</div>
2844
</div>
2945
</div>
3046
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { SecretReferencesPanel } from './secret-references-panel'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
'use client'
2+
3+
import { Wrench } from '@sim/emcn/icons'
4+
import { McpIcon } from '@/components/icons'
5+
import type { SecretReferenceResourcePayload } from '@/lib/api/contracts'
6+
import { DetailSection } from '@/app/workspace/[workspaceId]/components/credential-detail'
7+
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
8+
import {
9+
customToolIdParam,
10+
mcpServerIdParam,
11+
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
12+
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
13+
import {
14+
RESOURCE_LIST_STACK,
15+
SettingsResourceRow,
16+
} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
17+
import { focusBlockParam } from '@/app/workspace/[workspaceId]/w/[workflowId]/search-params'
18+
import { getBlock } from '@/blocks/registry'
19+
import { useSecretReferences } from '@/hooks/queries/credentials'
20+
21+
interface SecretReferencesPanelProps {
22+
workspaceId: string
23+
secretName: string
24+
/**
25+
* This personal secret is overridden by a workspace variable of the same name. References are
26+
* name-based, so every `{{name}}` in the workspace resolves to the workspace variable — whose
27+
* reference map is admin-gated. The owner may still read their own Logs, which is why the view
28+
* is offered at all, so this tab explains the shadowing instead of asking the API for a map it
29+
* will refuse.
30+
*/
31+
shadowed: boolean
32+
}
33+
34+
/**
35+
* Shown when a capped scan produced nothing to list. Distinct from "not referenced": the scan
36+
* stopped early, so silence here is absence of evidence, and saying otherwise would invite
37+
* deleting a key that four blocks past the cap still depend on.
38+
*/
39+
const TRUNCATED_NOTE = 'This secret is referenced in more places than can be listed here.'
40+
41+
/** A custom tool and an MCP server can each carry the key more than once, so `id` alone is not a key. */
42+
function resourceKey(resource: SecretReferenceResourcePayload): string {
43+
return `${resource.kind}:${resource.id}:${resource.field}`
44+
}
45+
46+
/**
47+
* The field's own label from the block's config — "Tools", "API Key" — rather than the storage
48+
* id the scanner reports. The id is how the value is keyed, not what the block calls it, and a
49+
* reader looking for the field on the canvas is looking for the label.
50+
*
51+
* Falls back to the raw id when the block or field is unregistered, which is honest: an id the
52+
* config cannot name is still better than naming nothing.
53+
*/
54+
function fieldLabel(blockType: string, field: string): string {
55+
return getBlock(blockType)?.subBlocks?.find((subBlock) => subBlock.id === field)?.title ?? field
56+
}
57+
58+
/**
59+
* The workflow, pointed at the block that carries the reference, so the canvas lands on it
60+
* rather than on its default framing. The target rides in the link itself so it survives a
61+
* middle-click or a reload, which an in-memory handoff could not.
62+
*/
63+
function blockHref(workspaceId: string, workflowId: string, blockId: string): string {
64+
return `/workspace/${workspaceId}/w/${workflowId}?${focusBlockParam.key}=${encodeURIComponent(blockId)}`
65+
}
66+
67+
/**
68+
* The settings page that owns the resource, deep-linked to its detail through the same param
69+
* that page reads — so a cascade row navigates like a block row instead of dead-ending.
70+
*/
71+
function resourceHref(workspaceId: string, resource: SecretReferenceResourcePayload): string {
72+
const settings = `/workspace/${workspaceId}/settings`
73+
return resource.kind === 'mcp-server'
74+
? `${settings}/mcp?${mcpServerIdParam.key}=${encodeURIComponent(resource.id)}`
75+
: `${settings}/custom-tools?${customToolIdParam.key}=${encodeURIComponent(resource.id)}`
76+
}
77+
78+
/**
79+
* Where one secret is wired in: the blocks that name it as `{{KEY}}`, grouped under their
80+
* workflow, then the custom tools and MCP servers whose own bodies carry it.
81+
*
82+
* The companion to the Logs tab, and the half of the question runs cannot answer — a secret
83+
* four blocks depend on but nothing has executed yet has an empty trail and a full list here.
84+
*/
85+
export function SecretReferencesPanel({
86+
workspaceId,
87+
secretName,
88+
shadowed,
89+
}: SecretReferencesPanelProps) {
90+
const { data, isPending, isError } = useSecretReferences(
91+
{ workspaceId, name: secretName },
92+
!shadowed
93+
)
94+
95+
if (shadowed) {
96+
return (
97+
<SettingsEmptyState variant='inline'>
98+
Overridden by a workspace variable, so every reference to this name resolves to that
99+
variable instead.
100+
</SettingsEmptyState>
101+
)
102+
}
103+
104+
if (isError) {
105+
return (
106+
<SettingsEmptyState variant='inline' tone='error'>
107+
Could not load references.
108+
</SettingsEmptyState>
109+
)
110+
}
111+
112+
if (isPending) {
113+
return <SettingsEmptyState variant='inline'>Loading…</SettingsEmptyState>
114+
}
115+
116+
if (data.workflows.length === 0 && data.resources.length === 0) {
117+
return (
118+
<SettingsEmptyState variant='inline'>
119+
{data.truncated ? TRUNCATED_NOTE : 'This secret is not referenced in any workflow.'}
120+
</SettingsEmptyState>
121+
)
122+
}
123+
124+
return (
125+
<div className='flex flex-col gap-7'>
126+
{data.workflows.map((workflow) => (
127+
<DetailSection key={workflow.workflowId} title={workflow.workflowName}>
128+
<div className={RESOURCE_LIST_STACK}>
129+
{workflow.blocks.map((block) => {
130+
const BlockIcon = getBlock(block.blockType)?.icon
131+
return (
132+
<SettingsResourceRow
133+
key={block.blockId}
134+
iconVariant='custom'
135+
/**
136+
* The brand-tinted block tile, so a block reads here exactly as it does on an
137+
* integrations row. A block icon is a brand mark, not a glyph: the tile owns
138+
* its fill and picks the contrasting icon colour, which is why it is never a
139+
* bare icon under `--text-icon`. An unregistered type has no tile, and the
140+
* row drops the whole slot for a nullish icon.
141+
*/
142+
icon={
143+
BlockIcon ? (
144+
<IntegrationTile blockType={block.blockType} icon={BlockIcon} />
145+
) : undefined
146+
}
147+
title={block.blockName}
148+
description={fieldLabel(block.blockType, block.field)}
149+
href={blockHref(workspaceId, workflow.workflowId, block.blockId)}
150+
clickLabel={`Open ${block.blockName} in ${workflow.workflowName}`}
151+
navigable
152+
/>
153+
)
154+
})}
155+
</div>
156+
</DetailSection>
157+
))}
158+
159+
{data.resources.length > 0 && (
160+
<DetailSection title='Custom tools and MCP servers'>
161+
<div className={RESOURCE_LIST_STACK}>
162+
{data.resources.map((resource) => (
163+
<SettingsResourceRow
164+
key={resourceKey(resource)}
165+
icon={
166+
resource.kind === 'mcp-server' ? (
167+
<McpIcon className='text-[var(--text-icon)]' />
168+
) : (
169+
<Wrench className='text-[var(--text-icon)]' />
170+
)
171+
}
172+
iconFilled
173+
title={resource.name}
174+
description={resource.field}
175+
href={resourceHref(workspaceId, resource)}
176+
clickLabel={`Open ${resource.name}`}
177+
navigable
178+
/>
179+
))}
180+
</div>
181+
</DetailSection>
182+
)}
183+
184+
{data.truncated && <p className='text-[var(--text-muted)] text-caption'>{TRUNCATED_NOTE}</p>}
185+
</div>
186+
)
187+
}

apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/search-params.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,19 @@ export const secretDetailViewUrlKeys = {
1515
history: 'push',
1616
clearOnDefault: true,
1717
} as const
18+
19+
/**
20+
* Active tab inside the usage view, so a shared `secret-view=usage` link can land on either
21+
* reading. Defaults to `logs`, which is what the header's "See usage" opened before References
22+
* existed — the action's name still promises the trail.
23+
*/
24+
export const secretUsageTabParam = {
25+
key: 'usage-tab',
26+
parser: parseAsStringLiteral(['references', 'logs'] as const).withDefault('logs'),
27+
} as const
28+
29+
/** Tab view-state: clean URLs, no back-stack churn. */
30+
export const secretUsageTabUrlKeys = {
31+
history: 'replace',
32+
clearOnDefault: true,
33+
} as const

0 commit comments

Comments
 (0)