diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts index 6fbfc3b54..fe2deb8c7 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/guardrail/models.ts @@ -1,6 +1,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as seedorigin$0 from "../seedorigin/models.js"; + /** * Effect is a rule's (or the final evaluation's) verdict. Ordered by * precedence: deny always beats ask, ask always beats allow, no matter @@ -97,4 +101,19 @@ export interface Rule { * references a rule by ID). */ "Source": string; + + /** + * BuiltIn marks a seeded example rule (BuiltIn() below) -- purely + * informational, same as mcpserver.MCPServer.BuiltIn/execenv. + * ExecEnv.BuiltIn: drives a "built-in" badge only, never gates + * Edit/Delete. + */ + "BuiltIn": boolean; + + /** + * Seed is this rule's seed provenance (docs/goals/0037) -- zero + * value means "not of seed origin," migration-free. See + * composition.Workflow.Seed's doc comment for the full reasoning. + */ + "Seed": seedorigin$0.Origin; } diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/configuresvc/configureservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/configuresvc/configureservice.ts index 90f106f11..1e68fd60c 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/configuresvc/configureservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/configuresvc/configureservice.ts @@ -236,6 +236,20 @@ export function DeleteMCPServer(id: string): $CancellablePromise { return $Call.ByID(3847603582, id); } +/** + * DeriveSecretLabels answers, statically from a node's own type and + * config -- never by resolving a real secret VALUE -- which vault + * entries its execution will resolve, as sorted, deduped LABELS (goal + * 0203 S2). This must compute the exact same answer a real run's own + * vault-reference resolution (vaultref.go) would reach: WorkflowVerdicts + * (the canvas nothing-hidden badge) calls this before anyone runs the + * workflow, so a step that will actually touch a secret can never show + * a clean badge the live gate then contradicts. + */ +export function DeriveSecretLabels(nodeTypeID: string, config: { [_ in string]?: string } | null): $CancellablePromise { + return $Call.ByID(3138098164, nodeTypeID, config); +} + export function ExecEnvs(): $CancellablePromise { return $Call.ByID(1392956673); } diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts index f11412724..9ef54b346 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/guardrailsvc/guardrailservice.ts @@ -40,7 +40,8 @@ export function CreateRule(rule: guardrail$0.Rule): $CancellablePromise { return $Call.ByID(1475597571, id); diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/index.ts index 0c5e3f448..d5af99b97 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/index.ts @@ -7,5 +7,8 @@ export { }; export type { + ListSecretAccessRequest, + ListSecretAccessResponse, + SecretAccessRecord, Status } from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/models.ts index d5bec1bfb..531086dfd 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/models.ts @@ -1,6 +1,46 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +/** + * ListSecretAccessRequest is the bound read API's request shape -- + * EntryID empty means "no filter" (the Secrets view's global Access + * history list); set means "this one entry's own history" (the detail + * dialog's own filtered view). Mirrors mcpauditsvc.ListMCPCallsRequest's + * own shape. + */ +export interface ListSecretAccessRequest { + "entryId": string; + "limit": number; + "offset": number; +} + +/** + * ListSecretAccessResponse carries one page plus the total matching-row + * count, same "showing X-Y of Z" reasoning ListMCPCallsResponse gives. + */ +export interface ListSecretAccessResponse { + "records": SecretAccessRecord[] | null; + "total": number; +} + +/** + * SecretAccessRecord is the frontend-facing JSON shape for one audit + * row -- mirrors secretaudit.Record with JSON tags added, same + * "adapter type stays free of a frontend-JSON concern" reasoning + * mcpauditsvc.MCPCallRecord's own doc comment gives. + */ +export interface SecretAccessRecord { + "id": number; + "timestamp": string; + "entryId": string; + "label": string; + "context": string; + "runId": string; + "workflowId": string; + "outcome": string; + "errorText": string; +} + /** * Status is VaultStatus's return shape -- the one read the frontend * needs to decide which of "set up," "unlock," or "browse" to show. diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/secretservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/secretservice.ts index 478c27452..73e7aecd7 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/secretservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/secretsvc/secretservice.ts @@ -27,6 +27,8 @@ import * as $models from "./models.js"; * but ONLY if the clipboard still holds exactly that value at that * point (the same "don't clobber something the user copied since" * check KeePassXC's own auto-clear makes), never unconditionally. + * Records one ContextUICopy audit line (goal 0203 S3), same not-gated- + * but-visible posture as RevealSecret. */ export function CopySecretToClipboard(id: string): $CancellablePromise { return $Call.ByID(2769882406, id); @@ -55,6 +57,19 @@ export function GeneratePassword(length: number, upper: boolean, lower: boolean, return $Call.ByID(2111800646, length, upper, lower, digits, symbols); } +/** + * ListSecretAccess is the bound read API the Secrets view's Access + * history list calls -- newest first, optionally filtered to one entry, + * limit/offset paged. Returns an empty page (never an error) when no + * audit store is wired yet -- structurally unreachable in the real app + * (main.go wires OpenAudit before any window/frontend exists), kept + * graceful rather than surfacing a confusing error on a race that can't + * actually happen. + */ +export function ListSecretAccess(req: $models.ListSecretAccessRequest): $CancellablePromise<$models.ListSecretAccessResponse> { + return $Call.ByID(2793339806, req); +} + /** * ListSecrets returns every entry's masked Summary (no password) -- * the browse surface's own list, sorted by title @@ -81,6 +96,12 @@ export function LockVault(): $CancellablePromise { * good way to surface a SECOND error about redaction failing while * already reporting a first one -- text passes through unredacted * rather than the whole error path failing outright. + * + * Deliberately unaudited (goal 0203 S3 contract): this reads every + * vault entry on a failure path purely to SCRUB output, never to expose + * a value to anyone -- recording it would bury real reads (a workflow + * that actually used a credential) under one audit line per error + * message formatted anywhere in the app. */ export function RedactKnownSecrets(text: string): $CancellablePromise { return $Call.ByID(2598084233, text); @@ -92,7 +113,9 @@ export function RedactKnownSecrets(text: string): $CancellablePromise { * browsing), matching SetHTTPRequestSecret's own write-only-elsewhere * posture but inverted: this vault's whole point is a human can read * their own password back, unlike the write-only integration-secret - * slots. + * slots. Records one ContextUIReveal audit line (goal 0203 S3) -- a + * human's own click, not gated (S2 contract), but visible in their own + * Access history. */ export function RevealSecret(id: string): $CancellablePromise { return $Call.ByID(1414222929, id); diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts index e1ab3831a..4f3136a34 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/settingssvc/settingsservice.ts @@ -68,7 +68,12 @@ export function AutoUpdateCheck(): $CancellablePromise { /** * CheckForUpdates asks the configured provider (GitHub Releases, - * alicoding/mill) whether a newer version exists. + * alicoding/mill) whether a newer version exists. The Wails-bound RPC + * surface (no context parameter is bindable); checkForUpdates below + * does the real work against a caller-supplied context, letting the + * background loop (settingsservice_updatenotice.go) propagate its own + * cancellable context instead of a fresh context.Background() call + * happening underneath it. */ export function CheckForUpdates(): $CancellablePromise<$models.UpdateCheckResult> { return $Call.ByID(3825907183); @@ -494,7 +499,11 @@ export function SetAttentionIdleThreshold(seconds: number): $CancellablePromise< } /** - * SetAutoUpdateCheck persists the opt-in; applies at boot. + * SetAutoUpdateCheck persists the opt-in and applies it live (goal + * 0207): turning it on starts the background loop immediately if it + * isn't already running; turning it off stops it. Both directions are + * idempotent -- flipping the same value twice is a no-op the second + * time, never a second loop or a panic on double-stop. */ export function SetAutoUpdateCheck(on: boolean): $CancellablePromise { return $Call.ByID(3808971894, on); diff --git a/frontend/e2e/secrets.spec.ts b/frontend/e2e/secrets.spec.ts index 872ce0cfa..a423091f3 100644 --- a/frontend/e2e/secrets.spec.ts +++ b/frontend/e2e/secrets.spec.ts @@ -99,6 +99,21 @@ test('secret manager: create vault, store/reveal/copy/edit/history/delete a pass await expect(page.getByTestId('secret-detail-copied').or(page.getByTestId('secret-detail-error'))).toBeVisible() }) + // --- Access history (goal 0203 S3): the reveal (and, when the real + // clipboard write succeeded, the copy) just performed each leave a + // visible row, in the user's own vocabulary, from the entry's own + // filtered view. Scoped to the currently-open detail dialog: the + // Secrets view header's own global Access history button carries + // the identical accessible name, still present (unmounted) behind + // this modal. --- + const bankDetailDialog = page.getByRole('dialog', { name: 'Bank of Testing', exact: true }) + await bankDetailDialog.getByRole('button', { name: 'Access history' }).click() + const entryAccessHistory = page.getByRole('dialog', { name: /Access history for/ }) + await expect(entryAccessHistory).toBeVisible() + await expect(entryAccessHistory.getByText('Shown to you')).toBeVisible() + await entryAccessHistory.getByLabel('Close').click() + await expect(bankDetailDialog).toBeVisible() + // --- Edit: change the password --- await page.getByRole('button', { name: 'Edit' }).click() const editPassword = page.getByTestId('secret-password-input') @@ -112,7 +127,9 @@ test('secret manager: create vault, store/reveal/copy/edit/history/delete a pass await expect(page.getByTestId('secret-detail-password')).toHaveValue('second-password-fake') // --- History: the pre-edit value is preserved --- - await page.getByRole('button', { name: 'History' }).click() + // exact: true -- "History" is otherwise a substring match against + // this same dialog's own "Access history" footer button. + await page.getByRole('button', { name: 'History', exact: true }).click() const historyRow = page.getByTestId('secret-history-row') await expect(historyRow).toHaveCount(1) await historyRow.getByLabel('Show password').click() @@ -120,6 +137,22 @@ test('secret manager: create vault, store/reveal/copy/edit/history/delete a pass await page.getByRole('dialog', { name: /History for/ }).getByLabel('Close').click() await page.getByRole('dialog', { name: 'Bank of Testing', exact: true }).getByLabel('Close').click() + // --- Global Access history (Secrets view header, goal 0203 S3): + // every read/reveal/copy this run performed against "Bank of + // Testing" shows up, newest first, each carrying the entry's own + // label -- unlike the per-entry filtered view above, which shows + // context only, this list needs the label to tell entries apart. --- + await page.getByTestId('secrets-access-history-open').click() + const globalAccessHistory = page.getByRole('dialog', { name: 'Access history', exact: true }) + await expect(globalAccessHistory).toBeVisible() + await expect(globalAccessHistory.getByText('Bank of Testing').first()).toBeVisible() + // Copied to the clipboard vs. Couldn't be read -- environment- + // dependent, same reasoning the earlier real-clipboard step's own + // comment gives (headless Linux CI has no pbcopy); either one + // proves the copy attempt left a row. + await expect(globalAccessHistory.getByText('Copied to the clipboard').or(globalAccessHistory.getByText("Couldn't be read"))).toBeVisible() + await globalAccessHistory.getByLabel('Close').click() + // --- Delete via the row's kebab menu, confirmed by name --- const bankRow = page.getByTestId('inventory-row').filter({ hasText: 'Bank of Testing' }) await bankRow.getByTestId('inventory-row-menu').click() diff --git a/frontend/src/composition/breakpoints.ts b/frontend/src/composition/breakpoints.ts index 772a1d7be..e627d42fa 100644 --- a/frontend/src/composition/breakpoints.ts +++ b/frontend/src/composition/breakpoints.ts @@ -97,6 +97,7 @@ export function useBreakpoints(workflowId: string | undefined, onChanged?: () => GuardrailService.CreateRule({ ID: '', Label: 'Breakpoint', Effect: GuardrailEffect.EffectAsk, Source: DEBUG_SOURCE, WorkflowID: workflowId, NodeID: nodeId, NodeTypeID: '', RequestID: '', Condition: '', + BuiltIn: false, Seed: { SeedRevision: 0, Modified: false }, }).then(done).catch(done) } }, diff --git a/frontend/src/locales/en/secrets.json b/frontend/src/locales/en/secrets.json index ca84f99a1..50ecffe4b 100644 --- a/frontend/src/locales/en/secrets.json +++ b/frontend/src/locales/en/secrets.json @@ -55,5 +55,26 @@ "urlLabel": "Website", "notesLabel": "Notes", "tagsLabel": "Tags" + }, + "accessHistory": { + "button": "Access history", + "heading": "Access history", + "headingForEntry": "Access history for \"{{label}}\"", + "emptyHeading": "No reads yet", + "emptyDescription": "Every time this vault is read, it shows up here.", + "showingRange": "Showing {{start}}-{{end}} of {{total}}", + "previousPageAriaLabel": "Previous page", + "nextPageAriaLabel": "Next page", + "outcomeRead": "Read", + "outcomeError": "Couldn't be read", + "readFailed": "Couldn't be read", + "readByWorkflow": "Read by workflow \"{{label}}\"", + "readMcpServerSpawn": "Read to start an MCP server", + "readExecEnv": "Read to run a command", + "readHttpHeader": "Read for an API call", + "readConfigureToolsPreview": "Read to list an MCP server's tools", + "readUiReveal": "Shown to you", + "readUiCopy": "Copied to the clipboard", + "readGeneric": "Read" } } diff --git a/frontend/src/shared/GuardrailRuleDialog.tsx b/frontend/src/shared/GuardrailRuleDialog.tsx index ac4da8ece..c91f85d22 100644 --- a/frontend/src/shared/GuardrailRuleDialog.tsx +++ b/frontend/src/shared/GuardrailRuleDialog.tsx @@ -53,6 +53,7 @@ export function GuardrailRuleDialog({ rule, onClose, onSaved }: { await GuardrailService.CreateRule({ ID: '', Label: label, Effect: effect as GuardrailEffect, Condition: condition, Source: '', NodeTypeID: '', RequestID: '', WorkflowID: '', NodeID: '', + BuiltIn: false, Seed: { SeedRevision: 0, Modified: false }, ...scopeFields, }) } diff --git a/frontend/src/shared/bindings.ts b/frontend/src/shared/bindings.ts index f055cb049..15a6a79c5 100644 --- a/frontend/src/shared/bindings.ts +++ b/frontend/src/shared/bindings.ts @@ -86,7 +86,12 @@ export type { PairingCodeInfo, } from '../../bindings/github.com/alicoding/mill/internal/services/remoteauthsvc' export { SecretService } from '../../bindings/github.com/alicoding/mill/internal/services/secretsvc' -export type { Status as VaultStatus } from '../../bindings/github.com/alicoding/mill/internal/services/secretsvc/models' +export type { + ListSecretAccessRequest, + ListSecretAccessResponse, + SecretAccessRecord, + Status as VaultStatus, +} from '../../bindings/github.com/alicoding/mill/internal/services/secretsvc/models' export type { Entry as SecretEntry, Summary as SecretSummary } from '../../bindings/github.com/alicoding/mill/internal/domain/secret/models' export { SettingsService } from '../../bindings/github.com/alicoding/mill/internal/services/settingssvc' export type { diff --git a/frontend/src/views/ReviewAlwaysRuleDialog.tsx b/frontend/src/views/ReviewAlwaysRuleDialog.tsx index 868a70a58..0652bdde7 100644 --- a/frontend/src/views/ReviewAlwaysRuleDialog.tsx +++ b/frontend/src/views/ReviewAlwaysRuleDialog.tsx @@ -69,6 +69,7 @@ export function ReviewAlwaysRuleDialog({ run, effect, onClose, onResolved }: { await GuardrailService.CreateRule({ ID: '', Label: ruleName, Effect: (effect === 'allow' ? GuardrailEffect.EffectAllow : GuardrailEffect.EffectDeny), Condition: '', Source: '', NodeTypeID: '', RequestID: '', WorkflowID: '', NodeID: '', + BuiltIn: false, Seed: { SeedRevision: 0, Modified: false }, ...scopeFields, }) } catch (err) { diff --git a/frontend/src/views/SecretsAccessHistoryDialog.tsx b/frontend/src/views/SecretsAccessHistoryDialog.tsx new file mode 100644 index 000000000..ef7b76046 --- /dev/null +++ b/frontend/src/views/SecretsAccessHistoryDialog.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ActionList, Dialog, IconButton, Label, type LabelProps, Stack, Text } from '@primer/react' +import { Blankslate } from '@primer/react/experimental' +import { ChevronLeftIcon, ChevronRightIcon, HistoryIcon } from '@primer/octicons-react' +import { CompositionService, SecretService } from '../shared/bindings' +import type { SecretAccessRecord } from '../shared/bindings' +import { formatUpdated } from '../shared/inventorySort' +import listStyles from '../shared/ListCard.module.css' + +// Goal 0203 S3: "who read this credential, and when" -- read-only, one +// component for both entry points the design contract names: the +// Secrets view's header opens it with no entryId (every read across +// the vault, newest first); the detail dialog's own "Access history" +// footer button opens it pre-filtered to that one entry. Mirrors +// ActivityMCPCalls' paging shape (goal 0159), the converged pattern for +// a server-paged audit log in this app. +const PAGE_SIZE = 25 + +const OUTCOME_VARIANT: Record = { + read: 'success', + error: 'danger', +} + +// contextCopyKey maps one record's context (+ whether a workflow name +// resolved) to its locale key -- see secrets.json's accessHistory.* for +// the actual sentences. A workflow-attributed read always wins over the +// context's own generic phrase, matching the design contract's own +// "Read by workflow " example. +function contextCopyKey(context: string, hasWorkflow: boolean): string { + if (hasWorkflow && (context === 'mcp-server-spawn' || context === 'exec-env' || context === 'http-header')) { + return 'accessHistory.readByWorkflow' + } + switch (context) { + case 'mcp-server-spawn': return 'accessHistory.readMcpServerSpawn' + case 'exec-env': return 'accessHistory.readExecEnv' + case 'http-header': return 'accessHistory.readHttpHeader' + case 'configure-tools-preview': return 'accessHistory.readConfigureToolsPreview' + case 'ui-reveal': return 'accessHistory.readUiReveal' + case 'ui-copy': return 'accessHistory.readUiCopy' + default: return 'accessHistory.readGeneric' + } +} + +export function SecretsAccessHistoryDialog({ entryId, entryLabel, onClose }: { + entryId?: string + entryLabel?: string + onClose: () => void +}) { + const { t } = useTranslation('secrets') + const [records, setRecords] = useState(null) + const [total, setTotal] = useState(0) + const [error, setError] = useState('') + const [offset, setOffset] = useState(0) + const [workflowLabels, setWorkflowLabels] = useState>({}) + + useEffect(() => { + CompositionService.Workflows() + .then((workflows) => { + const byID: Record = {} + for (const w of workflows ?? []) byID[w.ID] = w.Label + setWorkflowLabels(byID) + }) + .catch(() => undefined) // a label failing to resolve degrades to the generic phrase, never an error + }, []) + + const refresh = useCallback(() => { + SecretService.ListSecretAccess({ entryId: entryId ?? '', limit: PAGE_SIZE, offset }) + .then((resp) => { + setRecords(resp.records ?? []) + setTotal(resp.total) + }) + .catch((err) => setError(String(err))) + }, [entryId, offset]) + + useEffect(() => { refresh() }, [refresh]) + + if (records === null && !error) return null + + const rangeStart = total === 0 ? 0 : offset + 1 + const rangeEnd = Math.min(offset + (records?.length ?? 0), total) + const heading = entryLabel ? t('accessHistory.headingForEntry', { label: entryLabel }) : t('accessHistory.heading') + + return ( + + {error && {error}} + {records && records.length === 0 && ( + + + {t('accessHistory.emptyHeading')} + {t('accessHistory.emptyDescription')} + + )} + {records && records.length > 0 && ( + <> + + {records.map((r) => { + const workflowLabel = r.workflowId ? workflowLabels[r.workflowId] : undefined + const isError = r.outcome === 'error' + const description = isError + ? t('accessHistory.readFailed') + : t(contextCopyKey(r.context, Boolean(workflowLabel)), { label: workflowLabel }) + return ( + + {entryId ? description : r.label} + + {entryId ? formatUpdated(r.timestamp) : [description, formatUpdated(r.timestamp)].join(' · ')} + + + + + + ) + })} + + + + {t('accessHistory.showingRange', { start: rangeStart, end: rangeEnd, total })} + + + setOffset(Math.max(0, offset - PAGE_SIZE))} + data-testid="secrets-access-history-prev-page" + /> + = total} + onClick={() => setOffset(offset + PAGE_SIZE)} + data-testid="secrets-access-history-next-page" + /> + + + + )} + + ) +} diff --git a/frontend/src/views/SecretsDetailDialog.tsx b/frontend/src/views/SecretsDetailDialog.tsx index ffa5ff563..822caa24a 100644 --- a/frontend/src/views/SecretsDetailDialog.tsx +++ b/frontend/src/views/SecretsDetailDialog.tsx @@ -12,11 +12,12 @@ import styles from './SecretsView.module.css' // clipboard through SecretService.CopySecretToClipboard, which // auto-clears after 10s server-side (secretservice_autolock.go) -- no // clipboard timer logic lives in this component. -export function SecretsDetailDialog({ id, onClose, onEdit, onHistory, onDelete }: { +export function SecretsDetailDialog({ id, onClose, onEdit, onHistory, onAccessHistory, onDelete }: { id: string onClose: () => void onEdit: () => void onHistory: () => void + onAccessHistory: () => void onDelete: () => void }) { const { t } = useTranslation('secrets') @@ -41,6 +42,7 @@ export function SecretsDetailDialog({ id, onClose, onEdit, onHistory, onDelete } title={entry?.Title ?? ''} onClose={onClose} footerButtons={[ + { content: t('accessHistory.button'), onClick: onAccessHistory }, { content: t('historyButton'), onClick: onHistory }, { content: t('deleteButton'), buttonType: 'danger', onClick: onDelete }, { content: t('editButton'), buttonType: 'primary', onClick: onEdit }, diff --git a/frontend/src/views/SecretsView.tsx b/frontend/src/views/SecretsView.tsx index eb8fce25d..8ed927049 100644 --- a/frontend/src/views/SecretsView.tsx +++ b/frontend/src/views/SecretsView.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { Events } from '@wailsio/runtime' import { Blankslate } from '@primer/react/experimental' import { Button, Checkbox, FormControl, Heading, IconButton, Stack, Text } from '@primer/react' -import { KeyIcon, LockIcon, PlusIcon } from '@primer/octicons-react' +import { HistoryIcon, KeyIcon, LockIcon, PlusIcon } from '@primer/octicons-react' import { SecretService } from '../shared/bindings' import type { SecretSummary, VaultStatus } from '../shared/bindings' import { InventoryList, type InventoryItem } from '../shared/InventoryList' @@ -14,6 +14,7 @@ import PageContainer from '../shared/PageContainer' import { SecretsEntryDialog } from './SecretsEntryDialog' import { SecretsDetailDialog } from './SecretsDetailDialog' import { SecretsHistoryDialog } from './SecretsHistoryDialog' +import { SecretsAccessHistoryDialog } from './SecretsAccessHistoryDialog' import styles from './SecretsView.module.css' // The secret manager's human-facing surface (goal 0185 S2): browse, @@ -33,6 +34,8 @@ export default function SecretsView() { const [editingID, setEditingID] = useState(null) const [detailID, setDetailID] = useState(null) const [historyID, setHistoryID] = useState(null) + const [accessHistoryID, setAccessHistoryID] = useState(null) + const [showAccessHistory, setShowAccessHistory] = useState(false) const refresh = () => { SecretService.VaultStatus().then((s) => { @@ -163,6 +166,13 @@ export default function SecretsView() { {t('subtitle')} + setShowAccessHistory(true)} + data-testid="secrets-access-history-open" + />