@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.
- package/app/components/documents/DocumentImportModal.vue +2 -0
- package/app/components/documents/DocumentSyncState.logic.spec.ts +33 -0
- package/app/components/documents/DocumentSyncState.logic.ts +38 -0
- package/app/components/documents/DocumentSyncState.vue +181 -0
- package/app/components/documents/TaskContextDocs.vue +21 -10
- package/app/components/layout/AccountAuditLog.logic.spec.ts +107 -0
- package/app/components/layout/AccountAuditLog.logic.ts +101 -0
- package/app/components/layout/AccountAuditLog.vue +148 -0
- package/app/components/layout/AccountTeamSettings.vue +39 -0
- package/app/components/panels/MergerResultView.vue +1 -0
- package/app/components/panels/ReportsPanel.vue +72 -4
- package/app/components/panels/StepToolServers.logic.spec.ts +41 -1
- package/app/components/panels/StepToolServers.logic.ts +38 -0
- package/app/components/panels/StepToolServers.vue +28 -8
- package/app/components/riskPolicy/RiskPolicyPicker.logic.ts +7 -1
- package/app/composables/api/accounts.ts +12 -0
- package/app/composables/api/documents.ts +9 -0
- package/app/composables/useDocumentFreshness.ts +111 -0
- package/app/stores/accounts.audit.spec.ts +74 -0
- package/app/stores/accounts.ts +75 -0
- package/app/stores/board/moveRefusal.spec.ts +40 -0
- package/app/stores/board/moveRefusal.ts +34 -0
- package/app/stores/board/placement.ts +6 -1
- package/app/stores/documents.spec.ts +156 -0
- package/app/stores/documents.ts +14 -0
- package/app/types/documents.ts +4 -0
- package/i18n/locales/de.json +79 -3
- package/i18n/locales/en.json +79 -3
- package/i18n/locales/es.json +79 -3
- package/i18n/locales/fr.json +79 -3
- package/i18n/locales/he.json +79 -3
- package/i18n/locales/it.json +79 -3
- package/i18n/locales/ja.json +79 -3
- package/i18n/locales/pl.json +79 -3
- package/i18n/locales/tr.json +79 -3
- package/i18n/locales/uk.json +79 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
151
|
-
:
|
|
152
|
-
class="
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
}
|