@cat-factory/app 0.238.0 → 0.241.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/app/components/documents/DocumentImportModal.vue +2 -0
  2. package/app/components/documents/DocumentSyncState.logic.spec.ts +33 -0
  3. package/app/components/documents/DocumentSyncState.logic.ts +38 -0
  4. package/app/components/documents/DocumentSyncState.vue +181 -0
  5. package/app/components/documents/TaskContextDocs.vue +21 -10
  6. package/app/components/layout/AccountAuditLog.logic.spec.ts +107 -0
  7. package/app/components/layout/AccountAuditLog.logic.ts +101 -0
  8. package/app/components/layout/AccountAuditLog.vue +148 -0
  9. package/app/components/layout/AccountTeamSettings.vue +39 -0
  10. package/app/components/panels/MergerResultView.vue +1 -0
  11. package/app/components/panels/ReportsPanel.vue +72 -4
  12. package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
  13. package/app/components/panels/StepToolServers.logic.ts +38 -0
  14. package/app/components/panels/StepToolServers.vue +28 -8
  15. package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
  16. package/app/composables/api/accounts.ts +12 -0
  17. package/app/composables/api/documents.ts +9 -0
  18. package/app/composables/useDocumentFreshness.ts +111 -0
  19. package/app/stores/accounts.audit.spec.ts +74 -0
  20. package/app/stores/accounts.ts +75 -0
  21. package/app/stores/board/moveRefusal.spec.ts +40 -0
  22. package/app/stores/board/moveRefusal.ts +34 -0
  23. package/app/stores/board/placement.ts +6 -1
  24. package/app/stores/documents.spec.ts +156 -0
  25. package/app/stores/documents.ts +14 -0
  26. package/app/types/documents.ts +4 -0
  27. package/i18n/locales/de.json +79 -3
  28. package/i18n/locales/en.json +79 -3
  29. package/i18n/locales/es.json +79 -3
  30. package/i18n/locales/fr.json +79 -3
  31. package/i18n/locales/he.json +79 -3
  32. package/i18n/locales/it.json +79 -3
  33. package/i18n/locales/ja.json +79 -3
  34. package/i18n/locales/pl.json +79 -3
  35. package/i18n/locales/tr.json +79 -3
  36. package/i18n/locales/uk.json +79 -3
  37. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { DocumentSourceKind } from '~/types/domain'
3
+ import DocumentSyncState from '~/components/documents/DocumentSyncState.vue'
3
4
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
4
5
 
5
6
  // Import pages from a connected document source and pick one to expand into
@@ -147,6 +148,7 @@ function preview(externalId: string) {
147
148
  {{ doc.title }}
148
149
  </a>
149
150
  <p class="mt-0.5 line-clamp-2 text-xs text-slate-500">{{ doc.excerpt }}</p>
151
+ <DocumentSyncState :doc="doc" class="mt-1" />
150
152
  </div>
151
153
  <UButton
152
154
  color="primary"
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { documentFreshnessChangeSchema, documentFreshnessGapSchema } from '@cat-factory/contracts'
3
+ import { missingI18nKeys } from '../../../test/i18nKeys'
4
+ import { CHANGE_KEYS, GAP_KEYS } from './DocumentSyncState.logic'
5
+
6
+ /**
7
+ * The half of these tables' correctness that no guard can see.
8
+ *
9
+ * `satisfies Record<TheEnum, string>` already proves every MEMBER has an entry, and CI's locale
10
+ * parity plus `i18n:check` cover keys written literally as a translate call. A key held in a lookup
11
+ * table is invisible to both, so deleting it from the catalog passes every check and renders its own
12
+ * dotted path to the user (see `test/i18nKeys.ts`).
13
+ *
14
+ * The member lists are DERIVED from the picklists the component's types come from rather than
15
+ * re-listed here: a re-listed copy would pass while the code under test had drifted, which is the
16
+ * one failure mode a table test exists to catch.
17
+ */
18
+ describe('DocumentSyncState freshness tables', () => {
19
+ it('names a key the base catalog holds for every gap', () => {
20
+ expect(missingI18nKeys(Object.values(GAP_KEYS))).toEqual([])
21
+ })
22
+
23
+ it('names a key the base catalog holds for every change outcome', () => {
24
+ expect(missingI18nKeys(Object.values(CHANGE_KEYS))).toEqual([])
25
+ })
26
+
27
+ it('covers exactly the contracts vocabularies, with no entry for a member that is gone', () => {
28
+ expect(Object.keys(GAP_KEYS).sort()).toEqual([...documentFreshnessGapSchema.options].sort())
29
+ expect(Object.keys(CHANGE_KEYS).sort()).toEqual(
30
+ [...documentFreshnessChangeSchema.options].sort(),
31
+ )
32
+ })
33
+ })
@@ -0,0 +1,38 @@
1
+ import type { DocumentFreshnessChange, DocumentFreshnessGap } from '~/types/domain'
2
+
3
+ /**
4
+ * The i18n keys `DocumentSyncState` renders a freshness verdict through, in their own module so a
5
+ * spec can prove they still resolve.
6
+ *
7
+ * Both are exhaustive `Record`s over a contracts vocabulary, which buys half the guarantee: adding a
8
+ * member fails the build here rather than rendering an empty line. The other half is what the
9
+ * `satisfies` cannot see: that each VALUE still names a key the catalog holds. Typed message keys
10
+ * and `i18n:check` only find keys written literally as a translate call, so a key held in a lookup
11
+ * table is invisible to both, and deleting it reads as a clean removal that renders its own key path
12
+ * at runtime. `DocumentSyncState.logic.spec.ts` closes that (see `test/i18nKeys.ts`).
13
+ *
14
+ * ONE FULL SENTENCE per member rather than a shared "not confirmed: {reason}" frame, because each
15
+ * asks for a different fix and a clause spliced into a sentence is what breaks first in a language
16
+ * whose word order is not English's.
17
+ */
18
+ export const GAP_KEYS = {
19
+ not_connected: 'documents.freshness.gap.not_connected',
20
+ credentials_unreadable: 'documents.freshness.gap.credentials_unreadable',
21
+ unversioned: 'documents.freshness.gap.unversioned',
22
+ source_unreachable: 'documents.freshness.gap.source_unreachable',
23
+ } as const satisfies Record<DocumentFreshnessGap, string>
24
+
25
+ /**
26
+ * What a CONFIRMED check found, which is a different question from why one failed.
27
+ *
28
+ * Three outcomes rather than a "did it change" boolean, because the middle one is the common case
29
+ * for a whole-file source and is exactly what a boolean gets wrong: a Figma file's revision moves on
30
+ * any edit anywhere in it, so a check legitimately finds a newer revision with not one byte of THIS
31
+ * document's content different. Rendering that as "pulled the newer version" would tell a person
32
+ * their own edit had landed when it may be in a frame this document does not cover.
33
+ */
34
+ export const CHANGE_KEYS = {
35
+ unchanged: 'documents.freshness.change.unchanged',
36
+ reimported: 'documents.freshness.change.reimported',
37
+ revision_only: 'documents.freshness.change.revision_only',
38
+ } as const satisfies Record<DocumentFreshnessChange, string>
@@ -0,0 +1,181 @@
1
+ <script setup lang="ts">
2
+ import { isConnectableSource } from '@cat-factory/contracts'
3
+ import type { SourceDocument } from '~/types/domain'
4
+ import { CHANGE_KEYS, GAP_KEYS } from '~/components/documents/DocumentSyncState.logic'
5
+
6
+ // When a stored document was last written, and a way to ask its source whether that is still the
7
+ // current revision.
8
+ //
9
+ // A source-backed document is a PROJECTION of a page someone else keeps editing, and until the
10
+ // dispatch-time refresh landed nothing ever looked at the source again. Runs now re-confirm on
11
+ // every dispatch, but the person deciding whether to START one still had no way to see it: the
12
+ // board showed a title and an excerpt frozen at import, so "is the frame I just edited the one the
13
+ // agents will read" was unanswerable without opening Figma and comparing by eye.
14
+ //
15
+ // TWO facts, deliberately rendered as two, because they answer different questions and one is not
16
+ // the other's proxy:
17
+ //
18
+ // - `syncedAt` is when the BODY was last written. It is on every row, costs nothing, and moves
19
+ // only when a fetch actually changed something.
20
+ // - the freshness verdict is what the source said when someone last ASKED. It exists only after
21
+ // a click, because confirming costs a round trip per document and listing a board's imported
22
+ // pages must not spend one each.
23
+ //
24
+ // So an absent verdict means "nobody has asked", never "unknown", and a row that was refreshed and
25
+ // found UNCHANGED still shows its old `syncedAt` beside a confirmation: the body genuinely was not
26
+ // rewritten, and moving the stamp would claim a write that never happened.
27
+ //
28
+ // BOTH are rendered WITH THEIR TIME, and that is not decoration. Each is a claim about a moment in
29
+ // the history of a page someone else is still editing, and a moment stated without its time is read
30
+ // as "now": a confirmation reached an hour ago would otherwise keep showing a green check under
31
+ // copy that says the copy is current, which is the precise false confidence this whole surface
32
+ // exists to remove. Hence the `long` datetime format (`short` is date-only, so two writes on the
33
+ // same day are indistinguishable) on the one side and the verdict's own `checkedAt` on the other.
34
+ const props = defineProps<{ doc: SourceDocument }>()
35
+
36
+ const { t, d } = useI18n()
37
+ const documents = useDocumentsStore()
38
+ const toast = useToast()
39
+
40
+ /**
41
+ * The source to ask, or null for an origin with nobody to ask. An `upload` was handed to the
42
+ * platform through the API and has no page behind it, so it gets the stamp and no action; the
43
+ * backend refuses the call for the same reason, and narrowing here keeps the SPA from making it.
44
+ */
45
+ const askable = computed(() => (isConnectableSource(props.doc.source) ? props.doc.source : null))
46
+
47
+ const verdict = computed(() => documents.freshnessFor(props.doc.source, props.doc.externalId))
48
+ const busy = computed(() => documents.isRefreshing(props.doc.source, props.doc.externalId))
49
+
50
+ interface Stated {
51
+ tone: 'ok' | 'warn' | 'muted'
52
+ icon: string
53
+ text: string
54
+ /** The revision token, shown only when there IS one to paste back into the source. */
55
+ revision: string
56
+ }
57
+
58
+ /** What the last check concluded, or null when nobody has asked yet. */
59
+ const stated = computed<Stated | null>(() => {
60
+ const value = verdict.value?.verdict
61
+ if (!value) return null
62
+ switch (value.status) {
63
+ case 'confirmed':
64
+ // None of the three is a degradation, but they are the whole answer to "did my edit land",
65
+ // and each answers it differently.
66
+ return {
67
+ tone: 'ok',
68
+ icon: 'i-lucide-check',
69
+ text: t(CHANGE_KEYS[value.change]),
70
+ revision: value.version,
71
+ }
72
+ case 'unconfirmed':
73
+ return {
74
+ tone: 'warn',
75
+ icon: 'i-lucide-triangle-alert',
76
+ text: t(GAP_KEYS[value.reason]),
77
+ revision: '',
78
+ }
79
+ case 'not-applicable':
80
+ // Reachable for a connectable source this deployment wired no provider for. Stating it beats
81
+ // rendering nothing after a click, which reads as an action that silently failed.
82
+ return {
83
+ tone: 'muted',
84
+ icon: 'i-lucide-minus',
85
+ text: t('documents.freshness.notApplicable'),
86
+ revision: '',
87
+ }
88
+ default:
89
+ return unstatable(value)
90
+ }
91
+ })
92
+
93
+ /** Compile-time totality over the verdict union: adding a status fails the build here. */
94
+ function unstatable(_value: never): null {
95
+ return null
96
+ }
97
+
98
+ /**
99
+ * The verdict's own moment, always rendered beside it.
100
+ *
101
+ * A verdict does not expire and is deliberately not made to: expiring it would invent an "unknown"
102
+ * where the rule is that only "nobody has asked" exists. What makes that safe is stating WHEN, so a
103
+ * check reached an hour ago reads as an hour-old check instead of as the current state of a page
104
+ * that has had an hour to move.
105
+ */
106
+ const checkedAt = computed(() => {
107
+ const at = verdict.value?.checkedAt
108
+ if (at === undefined) return ''
109
+ return t('documents.freshness.checkedAt', { when: d(new Date(at), 'long') })
110
+ })
111
+
112
+ /**
113
+ * The verdict's detail line: which revision was confirmed, and when it was confirmed.
114
+ *
115
+ * In the hover title rather than the row, which is an 11px line already carrying a stamp, a
116
+ * sentence and a button. That placement is only safe because the visible sentences state what the
117
+ * check FOUND and never when it ran ("Matches the source", not "current as of just now"): a claim
118
+ * about the present tense would still be a claim about the present tense with the timestamp hidden
119
+ * one layer down.
120
+ */
121
+ const detail = computed(() =>
122
+ [
123
+ stated.value?.revision
124
+ ? t('documents.freshness.revision', { version: stated.value.revision })
125
+ : '',
126
+ checkedAt.value,
127
+ ]
128
+ .filter(Boolean)
129
+ .join(' · '),
130
+ )
131
+
132
+ const TONE_CLASS: Record<Stated['tone'], string> = {
133
+ ok: 'text-emerald-400',
134
+ warn: 'text-amber-400',
135
+ muted: 'text-slate-500',
136
+ }
137
+
138
+ async function refresh() {
139
+ const source = askable.value
140
+ if (!source || busy.value) return
141
+ try {
142
+ await documents.refresh(source, props.doc.externalId)
143
+ } catch (e) {
144
+ toast.add({
145
+ title: t('documents.freshness.refreshFailed'),
146
+ description: e instanceof Error ? e.message : String(e),
147
+ icon: 'i-lucide-triangle-alert',
148
+ color: 'error',
149
+ })
150
+ }
151
+ }
152
+ </script>
153
+
154
+ <template>
155
+ <div class="flex items-center gap-1.5 text-[11px] text-slate-500">
156
+ <span class="truncate">
157
+ {{ t('documents.freshness.updated', { when: d(new Date(props.doc.syncedAt), 'long') }) }}
158
+ </span>
159
+ <span
160
+ v-if="stated"
161
+ class="flex min-w-0 items-center gap-1"
162
+ :class="TONE_CLASS[stated.tone]"
163
+ :title="detail || undefined"
164
+ >
165
+ <UIcon :name="stated.icon" class="h-3 w-3 shrink-0" />
166
+ <span class="truncate">{{ stated.text }}</span>
167
+ </span>
168
+ <UButton
169
+ v-if="askable"
170
+ color="neutral"
171
+ variant="ghost"
172
+ size="xs"
173
+ icon="i-lucide-refresh-cw"
174
+ :loading="busy"
175
+ :aria-label="t('documents.freshness.refresh')"
176
+ :title="t('documents.freshness.refresh')"
177
+ class="ml-auto shrink-0"
178
+ @click="refresh"
179
+ />
180
+ </div>
181
+ </template>
@@ -4,6 +4,7 @@ import type { Block } from '~/types/domain'
4
4
  import { connectableSources } from '~/utils/sourcePicker'
5
5
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
6
6
  import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
7
+ import DocumentSyncState from '~/components/documents/DocumentSyncState.vue'
7
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
8
9
 
9
10
  // Documents (from any source) attached to a task OR an initiative as agent
@@ -140,19 +141,29 @@ async function attach(item: PendingContext) {
140
141
  />
141
142
 
142
143
  <div v-if="linked.length" class="space-y-1">
143
- <DocumentOriginLink
144
+ <!--
145
+ The sync state sits BESIDE the origin link, never inside it: the refresh action is a
146
+ button, and a button nested in an anchor both breaks the markup and navigates away on the
147
+ click it was meant to handle.
148
+ -->
149
+ <div
144
150
  v-for="doc in linked"
145
151
  :key="`${doc.source}:${doc.externalId}`"
146
- :url="doc.url"
147
- class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
148
- hover-class="hover:bg-slate-800/60"
152
+ class="rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5"
149
153
  >
150
- <UIcon
151
- :name="documents.descriptorForOrigin(doc.source)?.icon ?? 'i-lucide-file-text'"
152
- class="h-3.5 w-3.5 shrink-0 text-indigo-400"
153
- />
154
- <span class="truncate">{{ doc.title }}</span>
155
- </DocumentOriginLink>
154
+ <DocumentOriginLink
155
+ :url="doc.url"
156
+ class="flex items-center gap-1.5 text-xs text-slate-300"
157
+ hover-class="hover:text-white"
158
+ >
159
+ <UIcon
160
+ :name="documents.descriptorForOrigin(doc.source)?.icon ?? 'i-lucide-file-text'"
161
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
162
+ />
163
+ <span class="truncate">{{ doc.title }}</span>
164
+ </DocumentOriginLink>
165
+ <DocumentSyncState :doc="doc" class="mt-1" />
166
+ </div>
156
167
  </div>
157
168
  <p v-else class="text-[11px] text-slate-500">
158
169
  {{ emptyHint }}
@@ -0,0 +1,107 @@
1
+ import { AUDIT_ACTION_DETAIL_KEYS, auditActionSchema } from '@cat-factory/contracts'
2
+ import type { AuditEventWire } from '@cat-factory/contracts'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { ACTION_KEYS, actorLabel, describeEvent } from './AccountAuditLog.logic'
5
+
6
+ /**
7
+ * The audit viewer's sentence composition. What is worth pinning is the set of rows a happy-path
8
+ * render never produces, because those are the rows an audit log is kept FOR: an action this build
9
+ * has retired, a `details` blob that would not parse, a person who is no longer here.
10
+ */
11
+
12
+ /** A fake `t` that renders `key(param=value, …)`, so an unresolved slot is visible as itself. */
13
+ function fakeT(key: string, params?: Record<string, string>): string {
14
+ const rendered = Object.entries(params ?? {})
15
+ .map(([k, v]) => `${k}=${v}`)
16
+ .sort()
17
+ .join(',')
18
+ return rendered ? `${key}(${rendered})` : key
19
+ }
20
+
21
+ function event(overrides: Partial<AuditEventWire> = {}): AuditEventWire {
22
+ return {
23
+ id: 'aud_1',
24
+ at: 1_700_000_000_000,
25
+ workspaceId: null,
26
+ actor: { kind: 'user', userId: 'usr_actor' },
27
+ action: 'account.member_roles_changed',
28
+ targetType: 'user',
29
+ targetId: 'usr_target',
30
+ details: { previousRoles: 'developer', roles: 'admin' },
31
+ actorName: 'Ada',
32
+ targetName: 'Grace',
33
+ ...overrides,
34
+ } as AuditEventWire
35
+ }
36
+
37
+ describe('audit sentence composition', () => {
38
+ it('gives every action in the wire vocabulary its own copy', () => {
39
+ // Derived from the picklist the backend writes against, not a list retyped here: an action
40
+ // added on the backend fails THIS assertion rather than rendering its raw code at an operator.
41
+ expect(Object.keys(ACTION_KEYS).sort()).toEqual([...auditActionSchema.options].sort())
42
+ })
43
+
44
+ it('interpolates the row’s own values', () => {
45
+ expect(describeEvent(event(), fakeT)).toBe(
46
+ 'layout.auditLog.actions.accountMemberRolesChanged(previousRoles=developer,roles=admin,target=Grace)',
47
+ )
48
+ })
49
+
50
+ it('defaults every slot the ACTION declares when the details blob was unreadable', () => {
51
+ // The regression: `decodeAuditDetails` deliberately returns `{}` for a blob that will not
52
+ // parse, keeping a row that states less over losing the row entirely. Defaulting by iterating
53
+ // the row cannot reach that case — there are no keys to iterate — so the sentence rendered the
54
+ // literal `{previousRoles}` at an operator. The seed comes from the contract, so the two
55
+ // declared slots are known even when the row carries neither.
56
+ expect(describeEvent(event({ details: {} }), fakeT)).toBe(
57
+ 'layout.auditLog.actions.accountMemberRolesChanged(previousRoles=layout.auditLog.values.none,' +
58
+ 'roles=layout.auditLog.values.none,target=Grace)',
59
+ )
60
+ })
61
+
62
+ it('defaults a slot the row carries as null or empty, without dropping it', () => {
63
+ expect(describeEvent(event({ details: { previousRoles: null, roles: '' } }), fakeT)).toBe(
64
+ 'layout.auditLog.actions.accountMemberRolesChanged(previousRoles=layout.auditLog.values.none,' +
65
+ 'roles=layout.auditLog.values.none,target=Grace)',
66
+ )
67
+ })
68
+
69
+ it('leaves no declared slot of any action unfilled on an empty details blob', () => {
70
+ // The structural form of the case above, across the whole vocabulary rather than one action:
71
+ // whatever an action declares it carries, a row carrying none of it still renders every slot.
72
+ for (const action of auditActionSchema.options) {
73
+ const sentence = describeEvent(event({ action, details: {} }), fakeT)
74
+ for (const slot of AUDIT_ACTION_DETAIL_KEYS[action]) {
75
+ expect(sentence).toContain(`${slot}=layout.auditLog.values.none`)
76
+ }
77
+ }
78
+ })
79
+
80
+ it('names a RETIRED action as itself rather than dropping or guessing it', () => {
81
+ // Nothing here can know what a retired member meant, and a missing row is the one failure an
82
+ // audit log must not have.
83
+ expect(describeEvent(event({ action: { retired: 'account.something_gone' } }), fakeT)).toBe(
84
+ 'layout.auditLog.retiredAction(action=account.something_gone)',
85
+ )
86
+ })
87
+
88
+ it('falls back to the raw id for a person who is no longer here', () => {
89
+ // Which is precisely the kind of thing the log is kept to record, so it renders rather than
90
+ // becoming a placeholder.
91
+ expect(actorLabel(event({ actorName: null }), fakeT)).toBe('usr_actor')
92
+ expect(describeEvent(event({ targetName: null }), fakeT)).toContain('target=usr_target')
93
+ })
94
+
95
+ it('shows an API key as the key, never the person who minted it', () => {
96
+ // A leaked key has to stay distinguishable from the human it was minted by.
97
+ expect(actorLabel(event({ actor: { kind: 'apiKey', apiKeyId: 'key_9' } }), fakeT)).toBe(
98
+ 'layout.auditLog.actors.apiKey(id=key_9)',
99
+ )
100
+ })
101
+
102
+ it('says the SYSTEM acted, which is not the same as a user we failed to resolve', () => {
103
+ expect(actorLabel(event({ actor: { kind: 'system' } }), fakeT)).toBe(
104
+ 'layout.auditLog.actors.system',
105
+ )
106
+ })
107
+ })
@@ -0,0 +1,101 @@
1
+ import { AUDIT_ACTION_DETAIL_KEYS, isRetiredAuditValue } from '@cat-factory/contracts'
2
+ import type { AuditAction, AuditEventWire } from '@cat-factory/contracts'
3
+
4
+ // How one audit row becomes one sentence. Kept out of the SFC on the same seam as
5
+ // `StepToolServers.logic.ts`, because the two rules worth asserting here are both about rows a
6
+ // happy-path render never produces: an action this build has retired, and a row whose `details`
7
+ // blob would not parse.
8
+ //
9
+ // The design this serves: the backend records machine-readable FIELDS and never prose, since a
10
+ // row is persisted and English written today could not be re-rendered for a reader in another
11
+ // locale years later. So every sentence is a translated key plus the row's own values.
12
+
13
+ /**
14
+ * Action → message key. EXHAUSTIVE over the contract union on purpose: the alternative is a lookup
15
+ * returning `undefined` for an action somebody added on the backend, which renders a raw
16
+ * `account.member_roles_changed` at an operator instead of failing the build.
17
+ */
18
+ export const ACTION_KEYS: Record<AuditAction, string> = {
19
+ 'account.member_added': 'layout.auditLog.actions.accountMemberAdded',
20
+ 'account.member_roles_changed': 'layout.auditLog.actions.accountMemberRolesChanged',
21
+ 'account.budget_changed': 'layout.auditLog.actions.accountBudgetChanged',
22
+ 'account.settings_changed': 'layout.auditLog.actions.accountSettingsChanged',
23
+ 'account.invitation_created': 'layout.auditLog.actions.accountInvitationCreated',
24
+ 'account.invitation_revoked': 'layout.auditLog.actions.accountInvitationRevoked',
25
+ 'account.invitation_accepted': 'layout.auditLog.actions.accountInvitationAccepted',
26
+ 'account.member_sessions_revoked': 'layout.auditLog.actions.accountMemberSessionsRevoked',
27
+ 'workspace.member_added': 'layout.auditLog.actions.workspaceMemberAdded',
28
+ 'workspace.member_role_changed': 'layout.auditLog.actions.workspaceMemberRoleChanged',
29
+ 'workspace.member_removed': 'layout.auditLog.actions.workspaceMemberRemoved',
30
+ 'workspace.access_mode_changed': 'layout.auditLog.actions.workspaceAccessModeChanged',
31
+ }
32
+
33
+ /** Translate a key with interpolation params. The component owns the i18n instance; this is pure. */
34
+ export type Translate = (key: string, params?: Record<string, string>) => string
35
+
36
+ /**
37
+ * The row's detail fields as interpolation params.
38
+ *
39
+ * Seeded from what the ACTION declares it carries (`AUDIT_ACTION_DETAIL_KEYS`, the same contract
40
+ * the backend writers are held to), then overlaid with what the row actually holds. Both halves
41
+ * are needed, and the seed is the one easy to leave out: iterating the row alone defaults
42
+ * nothing, because a row whose `details` blob was unreadable has NO keys to iterate — and that is
43
+ * precisely the row the defaulting exists for. `decodeAuditDetails` returns an empty set there by
44
+ * design, keeping a row that states less over losing the row entirely, so without the seed the
45
+ * sentence renders the literal `{previousRoles}` at an operator.
46
+ *
47
+ * Derived from the contract rather than re-listed, so an action whose fields change cannot leave
48
+ * this behind: the `Record` it reads is exhaustive over the same picklist `ACTION_KEYS` is.
49
+ */
50
+ export function detailParams(
51
+ action: AuditAction,
52
+ details: AuditEventWire['details'],
53
+ none: string,
54
+ ): Record<string, string> {
55
+ const params: Record<string, string> = {}
56
+ for (const key of AUDIT_ACTION_DETAIL_KEYS[action]) params[key] = none
57
+ for (const [key, value] of Object.entries(details)) {
58
+ params[key] = value === null || value === '' ? none : String(value)
59
+ }
60
+ return params
61
+ }
62
+
63
+ /** Who the action was performed ON: the resolved name, else the raw id. */
64
+ export function targetLabel(event: AuditEventWire): string {
65
+ return event.targetName ?? event.targetId
66
+ }
67
+
68
+ /**
69
+ * Who performed it.
70
+ *
71
+ * The three principal kinds render differently on purpose. A user shows their name (or their id,
72
+ * when the person is gone — which is precisely the kind of thing the log is kept to record); an
73
+ * API key shows the key, never the person who minted it, so a leaked key is distinguishable from
74
+ * them; and `system` says the engine acted, which is a different fact from a user we failed to
75
+ * resolve and must never look the same.
76
+ */
77
+ export function actorLabel(event: AuditEventWire, t: Translate): string {
78
+ if (event.actor.kind === 'system') return t('layout.auditLog.actors.system')
79
+ if (event.actor.kind === 'apiKey') {
80
+ return t('layout.auditLog.actors.apiKey', { id: event.actor.apiKeyId })
81
+ }
82
+ return event.actorName ?? event.actor.userId
83
+ }
84
+
85
+ /**
86
+ * The sentence for one row.
87
+ *
88
+ * A RETIRED action (one this build no longer declares, in a row written before it was retired) is
89
+ * named as itself rather than dropped or guessed onto a current member. Nothing here can know what
90
+ * it meant, and a missing row is the one failure an audit log must not have — so it renders as
91
+ * "unrecognised action: <value>" and keeps its actor, target and timestamp.
92
+ */
93
+ export function describeEvent(event: AuditEventWire, t: Translate): string {
94
+ if (isRetiredAuditValue(event.action)) {
95
+ return t('layout.auditLog.retiredAction', { action: event.action.retired })
96
+ }
97
+ return t(ACTION_KEYS[event.action], {
98
+ target: targetLabel(event),
99
+ ...detailParams(event.action, event.details, t('layout.auditLog.values.none')),
100
+ })
101
+ }