@cat-factory/app 0.239.0 → 0.241.1
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/README.md +11 -1
- 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/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/stores/notifications.ts +40 -7
- package/app/stores/workspace/commands.ts +1 -1
- package/app/stores/workspace/hydrate.ts +12 -7
- package/app/stores/workspace.spec.ts +91 -4
- package/app/stores/workspace.ts +18 -13
- package/app/types/documents.ts +4 -0
- package/i18n/locales/de.json +73 -3
- package/i18n/locales/en.json +73 -3
- package/i18n/locales/es.json +73 -3
- package/i18n/locales/fr.json +73 -3
- package/i18n/locales/he.json +73 -3
- package/i18n/locales/it.json +73 -3
- package/i18n/locales/ja.json +73 -3
- package/i18n/locales/pl.json +73 -3
- package/i18n/locales/tr.json +73 -3
- package/i18n/locales/uk.json +73 -3
- package/package.json +2 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
4
|
+
import type { AuditEventWire } from '@cat-factory/contracts'
|
|
5
|
+
import { actorLabel, describeEvent } from './AccountAuditLog.logic'
|
|
6
|
+
|
|
7
|
+
// The account audit log: who did what, when, for the privileged actions an account admin is
|
|
8
|
+
// answerable for. Read-only by construction — the store exposes no mutation and the backend has
|
|
9
|
+
// no update or delete surface besides the retention sweep.
|
|
10
|
+
//
|
|
11
|
+
// The whole design rests on one rule: the backend records machine-readable FIELDS and never
|
|
12
|
+
// prose, because a row is persisted and English written today could never be re-rendered for a
|
|
13
|
+
// reader in another locale years later. So every sentence here is composed from a translated key
|
|
14
|
+
// plus the row's `details`. The composition itself lives in `AccountAuditLog.logic.ts`, where
|
|
15
|
+
// the two cases a happy-path render never reaches (a retired action, an unreadable `details`
|
|
16
|
+
// blob) can be asserted without mounting anything.
|
|
17
|
+
const props = defineProps<{ accountId: string }>()
|
|
18
|
+
|
|
19
|
+
const accounts = useAccountsStore()
|
|
20
|
+
const toast = useToast()
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
|
|
23
|
+
const events = computed(() => accounts.auditEvents)
|
|
24
|
+
const hasMore = computed(() => accounts.auditCursor !== null)
|
|
25
|
+
const loading = computed(() => accounts.auditLoading)
|
|
26
|
+
/**
|
|
27
|
+
* The load FAILED, as distinct from an empty log. An audit viewer that renders a store outage as
|
|
28
|
+
* "nothing has happened" tells an admin the exact opposite of the truth, so the two states have
|
|
29
|
+
* separate renderings and this one never silently resolves to an empty list.
|
|
30
|
+
*/
|
|
31
|
+
const loadError = ref<string | null>(null)
|
|
32
|
+
|
|
33
|
+
async function load() {
|
|
34
|
+
loadError.value = null
|
|
35
|
+
try {
|
|
36
|
+
await accounts.loadAuditEvents(props.accountId)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
loadError.value = apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadMore() {
|
|
43
|
+
try {
|
|
44
|
+
await accounts.loadMoreAuditEvents(props.accountId)
|
|
45
|
+
} catch (e) {
|
|
46
|
+
toast.add({
|
|
47
|
+
title: t('layout.auditLog.errors.loadMore'),
|
|
48
|
+
description: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)),
|
|
49
|
+
icon: 'i-lucide-triangle-alert',
|
|
50
|
+
color: 'error',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(() => void load())
|
|
56
|
+
watch(
|
|
57
|
+
() => props.accountId,
|
|
58
|
+
(id) => {
|
|
59
|
+
if (id) void load()
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
/**
|
|
63
|
+
* Something elsewhere in the app wrote a row this feed does not have yet (today: an admin forcing
|
|
64
|
+
* a member's sessions to end, whose only lasting trace IS that row).
|
|
65
|
+
*
|
|
66
|
+
* The reload lands here rather than in the writer for two reasons that are one reason: this
|
|
67
|
+
* component is the only place that knows the feed is being shown, and it is the only place that
|
|
68
|
+
* renders a failed read as a failed read. A writer that awaited the refresh itself would report
|
|
69
|
+
* its own success or failure by whether an unrelated GET succeeded.
|
|
70
|
+
*/
|
|
71
|
+
watch(
|
|
72
|
+
() => accounts.auditStale,
|
|
73
|
+
(stale) => {
|
|
74
|
+
if (stale) void load()
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The composition helpers, bound to this component's i18n instance. They take `t` as a parameter
|
|
80
|
+
* so the logic module stays pure and testable without an i18n runtime; binding here keeps the
|
|
81
|
+
* template free of the plumbing.
|
|
82
|
+
*/
|
|
83
|
+
const describe = (event: AuditEventWire) => describeEvent(event, t)
|
|
84
|
+
const actor = (event: AuditEventWire) => actorLabel(event, t)
|
|
85
|
+
|
|
86
|
+
/** Absolute local time: an audit reader is answering "when exactly", never "how long ago". */
|
|
87
|
+
function timestamp(at: number): string {
|
|
88
|
+
return new Date(at).toLocaleString()
|
|
89
|
+
}
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<template>
|
|
93
|
+
<section class="rounded-md border border-slate-800 bg-slate-800/40 p-4">
|
|
94
|
+
<div class="mb-3 flex items-start justify-between gap-3">
|
|
95
|
+
<div>
|
|
96
|
+
<h3 class="font-semibold text-white">{{ t('layout.auditLog.title') }}</h3>
|
|
97
|
+
<p class="mt-1 text-slate-400">{{ t('layout.auditLog.description') }}</p>
|
|
98
|
+
</div>
|
|
99
|
+
<UButton
|
|
100
|
+
size="xs"
|
|
101
|
+
color="neutral"
|
|
102
|
+
variant="ghost"
|
|
103
|
+
icon="i-lucide-refresh-cw"
|
|
104
|
+
:loading="loading"
|
|
105
|
+
:aria-label="t('layout.auditLog.refresh')"
|
|
106
|
+
data-testid="audit-log-refresh"
|
|
107
|
+
@click="load()"
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- A failed load is NOT an empty log; it says so and offers the retry. -->
|
|
112
|
+
<p v-if="loadError" class="text-red-400" data-testid="audit-log-error">
|
|
113
|
+
{{ t('layout.auditLog.errors.load') }}
|
|
114
|
+
<span class="text-slate-400">{{ loadError }}</span>
|
|
115
|
+
</p>
|
|
116
|
+
|
|
117
|
+
<p v-else-if="events.length === 0 && !loading" class="text-slate-400">
|
|
118
|
+
{{ t('layout.auditLog.empty') }}
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
<ol v-else class="space-y-2" data-testid="audit-log-list">
|
|
122
|
+
<li
|
|
123
|
+
v-for="event in events"
|
|
124
|
+
:key="event.id"
|
|
125
|
+
class="rounded border border-slate-800 bg-slate-900/40 px-3 py-2"
|
|
126
|
+
>
|
|
127
|
+
<div class="flex flex-wrap items-baseline gap-x-2">
|
|
128
|
+
<span class="font-medium text-white">{{ actor(event) }}</span>
|
|
129
|
+
<span class="text-slate-300">{{ describe(event) }}</span>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="mt-1 text-xs text-slate-500">{{ timestamp(event.at) }}</div>
|
|
132
|
+
</li>
|
|
133
|
+
</ol>
|
|
134
|
+
|
|
135
|
+
<UButton
|
|
136
|
+
v-if="hasMore && !loadError"
|
|
137
|
+
class="mt-3"
|
|
138
|
+
size="xs"
|
|
139
|
+
color="neutral"
|
|
140
|
+
variant="soft"
|
|
141
|
+
:loading="loading"
|
|
142
|
+
data-testid="audit-log-load-more"
|
|
143
|
+
@click="loadMore()"
|
|
144
|
+
>
|
|
145
|
+
{{ t('layout.auditLog.loadMore') }}
|
|
146
|
+
</UButton>
|
|
147
|
+
</section>
|
|
148
|
+
</template>
|
|
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|
|
3
3
|
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
4
4
|
import type { AccountRole } from '~/types/domain'
|
|
5
5
|
import type { InvitationStatus } from '@cat-factory/contracts'
|
|
6
|
+
import AccountAuditLog from '~/components/layout/AccountAuditLog.vue'
|
|
6
7
|
import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
|
|
7
8
|
import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
|
|
8
9
|
import AccountPlatformAlertSettings from '~/components/layout/AccountPlatformAlertSettings.vue'
|
|
@@ -16,6 +17,7 @@ import SecretInput from '~/components/common/SecretInput.vue'
|
|
|
16
17
|
const props = defineProps<{ accountId: string }>()
|
|
17
18
|
|
|
18
19
|
const accounts = useAccountsStore()
|
|
20
|
+
const uiMode = useUiModeStore()
|
|
19
21
|
const auth = useAuthStore()
|
|
20
22
|
const toast = useToast()
|
|
21
23
|
const { t, te } = useI18n()
|
|
@@ -66,6 +68,20 @@ async function updateMemberRoles(userId: string, roles: AccountRole[]) {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
|
|
71
|
+
/**
|
|
72
|
+
* End every session a member holds. Their roles are untouched, so nothing in the roster changes —
|
|
73
|
+
* which is why the confirmation names the person: there is no visible after-state to check.
|
|
74
|
+
*/
|
|
75
|
+
async function revokeSessions(userId: string, label: string) {
|
|
76
|
+
if (!(await confirmAction('revoke', label))) return
|
|
77
|
+
try {
|
|
78
|
+
await accounts.revokeMemberSessions(props.accountId, userId)
|
|
79
|
+
toast.add({ title: t('layout.accountTeam.members.sessionsRevoked'), icon: 'i-lucide-check' })
|
|
80
|
+
} catch (e) {
|
|
81
|
+
notifyError(t('layout.accountTeam.errors.revokeSessions'), e)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
function notifyError(title: string, e: unknown) {
|
|
70
86
|
toast.add({
|
|
71
87
|
title,
|
|
@@ -232,6 +248,20 @@ async function disconnectEmail() {
|
|
|
232
248
|
<span v-else class="text-xs uppercase tracking-wide text-slate-400">
|
|
233
249
|
{{ m.roles.join(', ') }}
|
|
234
250
|
</span>
|
|
251
|
+
<!-- Offboarding: end every session this member holds, leaving their membership and
|
|
252
|
+
roles alone. Confirmed, because it is not undoable from here (the person simply
|
|
253
|
+
signs in again) and because it is the sort of thing a mis-click should not do. -->
|
|
254
|
+
<UButton
|
|
255
|
+
v-if="isAdmin"
|
|
256
|
+
size="xs"
|
|
257
|
+
color="neutral"
|
|
258
|
+
variant="ghost"
|
|
259
|
+
icon="i-lucide-log-out"
|
|
260
|
+
:title="t('layout.accountTeam.members.revokeSessions')"
|
|
261
|
+
:aria-label="t('layout.accountTeam.members.revokeSessions')"
|
|
262
|
+
data-testid="revoke-member-sessions"
|
|
263
|
+
@click="revokeSessions(m.userId, m.name || m.email || m.userId)"
|
|
264
|
+
/>
|
|
235
265
|
</li>
|
|
236
266
|
<li v-if="accounts.members.length === 0" class="text-slate-500">
|
|
237
267
|
{{ t('layout.accountTeam.members.empty') }}
|
|
@@ -355,5 +385,14 @@ async function disconnectEmail() {
|
|
|
355
385
|
<section v-if="isAdmin">
|
|
356
386
|
<AccountRunCredentialSettings :account-id="accountId" />
|
|
357
387
|
</section>
|
|
388
|
+
|
|
389
|
+
<!-- The account audit log (admin-only, ADVANCED tier). Advanced rather than basic because
|
|
390
|
+
reading who changed what is a governance task, not part of the everyday delivery loop —
|
|
391
|
+
and unlike an override field there is no default it hides, so hiding it withholds nothing
|
|
392
|
+
a basic-tier user would otherwise be acting on. The backend gates it too; this only
|
|
393
|
+
decides whether the surface exists. -->
|
|
394
|
+
<section v-if="isAdmin && uiMode.isAdvanced">
|
|
395
|
+
<AccountAuditLog :account-id="accountId" />
|
|
396
|
+
</section>
|
|
358
397
|
</div>
|
|
359
398
|
</template>
|
|
@@ -64,6 +64,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
|
|
|
64
64
|
within_thresholds: 'panels.mergerResult.reason.within_thresholds',
|
|
65
65
|
exceeded_thresholds: 'panels.mergerResult.reason.exceeded_thresholds',
|
|
66
66
|
auto_merge_disabled: 'panels.mergerResult.reason.auto_merge_disabled',
|
|
67
|
+
no_policy_configured: 'panels.mergerResult.reason.no_policy_configured',
|
|
67
68
|
no_rationale: 'panels.mergerResult.reason.no_rationale',
|
|
68
69
|
no_assessment: 'panels.mergerResult.reason.no_assessment',
|
|
69
70
|
merge_failed: 'panels.mergerResult.reason.merge_failed',
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
KNOWN_REASONS,
|
|
4
|
+
REASON_KEY,
|
|
5
|
+
REMEDY_KEY,
|
|
6
|
+
reasonText,
|
|
7
|
+
remedyText,
|
|
8
|
+
} from './StepToolServers.logic'
|
|
3
9
|
import type { ToolServerUnavailableReason } from '~/types/toolServers'
|
|
4
10
|
|
|
5
11
|
/**
|
|
@@ -49,6 +55,40 @@ describe('tool-server unavailability reasons', () => {
|
|
|
49
55
|
})
|
|
50
56
|
})
|
|
51
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The remedy is the half an operator acts on. A diagnosis with no next step is where this surface
|
|
60
|
+
* started: the reason was already stated to the AGENT in its prompt, and stating it to a person
|
|
61
|
+
* changes nothing unless it also names what to change.
|
|
62
|
+
*/
|
|
63
|
+
describe('tool-server unavailability remedies', () => {
|
|
64
|
+
it('gives every reason in the wire vocabulary a remedy of its own', () => {
|
|
65
|
+
expect(Object.keys(REMEDY_KEY).sort()).toEqual([...KNOWN_REASONS].sort())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('never points two reasons at one remedy', () => {
|
|
69
|
+
// The vocabulary exists BECAUSE each member needs a different fix, so two members sharing a
|
|
70
|
+
// remedy line means either the copy is wrong or the split was.
|
|
71
|
+
const keys = Object.values(REMEDY_KEY)
|
|
72
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('never reuses a reason line as a remedy', () => {
|
|
76
|
+
// The two are rendered together. A remedy pointing at the reason's own key would render the
|
|
77
|
+
// diagnosis twice and read as advice.
|
|
78
|
+
const reasons = new Set(Object.values(REASON_KEY))
|
|
79
|
+
for (const key of Object.values(REMEDY_KEY)) expect(reasons.has(key)).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('offers no remedy for a retired reason, rather than guessing one', () => {
|
|
83
|
+
// The build knows the code was recorded and not what it meant. Any remedy here would name a
|
|
84
|
+
// surface picked from a member the operator may never have hit, and the reason line already
|
|
85
|
+
// states the raw code, which is the whole of what is known.
|
|
86
|
+
for (const reason of ['legacy_reason', 'constructor', '__proto__']) {
|
|
87
|
+
expect(remedyText(reason as ToolServerUnavailableReason, (key) => key)).toBeNull()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
52
92
|
/** Every `t` call `reasonText` made, so the assertion is about the key it CHOSE, not the copy. */
|
|
53
93
|
function render(reason: string): { key: string; params?: Record<string, unknown> }[] {
|
|
54
94
|
const seen: { key: string; params?: Record<string, unknown> }[] = []
|
|
@@ -25,6 +25,29 @@ export const REASON_KEY: Record<ToolServerUnavailableReason, string> = {
|
|
|
25
25
|
over_budget: 'panels.stepDetail.toolServers.reason.overBudget',
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The i18n key per reason for the REMEDY line: what an operator has to change to get the server
|
|
30
|
+
* wired next run. Exhaustive on the same vocabulary and for the same reason as {@link REASON_KEY}.
|
|
31
|
+
*
|
|
32
|
+
* Split from the reason rather than folded into one sentence because the two answer different
|
|
33
|
+
* questions and only one of them is stable: the reason states what the dispatch decided and is a
|
|
34
|
+
* fact about that run forever, while the remedy names a surface this deployment happens to offer
|
|
35
|
+
* (the Infrastructure window, a declaration in deployment code). The split is also the vocabulary's
|
|
36
|
+
* own justification, since every member exists precisely because it needs a DIFFERENT fix
|
|
37
|
+
* (`docs`' table in `backend/docs/mcp-tool-servers.md` is the same mapping for a reader who never
|
|
38
|
+
* opens the SPA); a reason with no remedy leaves the operator holding an accurate diagnosis and no
|
|
39
|
+
* next step, which is the state this surface was built to end.
|
|
40
|
+
*/
|
|
41
|
+
export const REMEDY_KEY: Record<ToolServerUnavailableReason, string> = {
|
|
42
|
+
harness_unsupported: 'panels.stepDetail.toolServers.remedy.harnessUnsupported',
|
|
43
|
+
transport_unsupported: 'panels.stepDetail.toolServers.remedy.transportUnsupported',
|
|
44
|
+
missing_secret: 'panels.stepDetail.toolServers.remedy.missingSecret',
|
|
45
|
+
reserved_secret: 'panels.stepDetail.toolServers.remedy.reservedSecret',
|
|
46
|
+
oauth_not_connected: 'panels.stepDetail.toolServers.remedy.oauthNotConnected',
|
|
47
|
+
oauth_token_failed: 'panels.stepDetail.toolServers.remedy.oauthTokenFailed',
|
|
48
|
+
over_budget: 'panels.stepDetail.toolServers.remedy.overBudget',
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
/** The reason vocabulary as the SCHEMA states it: what a parity assertion grades {@link REASON_KEY} against. */
|
|
29
52
|
export const KNOWN_REASONS = toolServerUnavailableReasonSchema.options
|
|
30
53
|
|
|
@@ -56,3 +79,18 @@ export function reasonText(
|
|
|
56
79
|
? t(REASON_KEY[reason])
|
|
57
80
|
: t('panels.stepDetail.toolServers.reason.unknown', { reason })
|
|
58
81
|
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render the remedy for one reason, or `null` when there is none to give.
|
|
85
|
+
*
|
|
86
|
+
* `null` is the honest answer for a RETIRED member and the reason it is not folded into
|
|
87
|
+
* {@link reasonText}: this build knows the code was recorded and does not know what it meant, so it
|
|
88
|
+
* cannot name a surface to change without guessing which current member the operator should act
|
|
89
|
+
* on. The reason line already names the raw code, which is the whole of what is known.
|
|
90
|
+
*/
|
|
91
|
+
export function remedyText(
|
|
92
|
+
reason: ToolServerUnavailableReason,
|
|
93
|
+
t: (key: string, params?: Record<string, unknown>) => string,
|
|
94
|
+
): string | null {
|
|
95
|
+
return isKnownReason(reason) ? t(REMEDY_KEY[reason]) : null
|
|
96
|
+
}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// the Infrastructure window is where declarations are read; rendering it here would put an empty
|
|
16
16
|
// section on every step of every run to say nothing happened. The distinction absent-vs-empty is
|
|
17
17
|
// still carried on the wire and answered by the debug API, which is where a reader asking it looks.
|
|
18
|
-
import type { StepToolServers
|
|
19
|
-
import { reasonText } from '~/components/panels/StepToolServers.logic'
|
|
18
|
+
import type { StepToolServers } from '~/types/toolServers'
|
|
19
|
+
import { reasonText, remedyText } from '~/components/panels/StepToolServers.logic'
|
|
20
20
|
import { agentKindMeta } from '~/utils/catalog'
|
|
21
21
|
|
|
22
22
|
const props = defineProps<{
|
|
@@ -44,9 +44,19 @@ const dispatchedAs = computed(() =>
|
|
|
44
44
|
: null,
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Each dropped server with both halves of its answer resolved: WHY it was dropped, and what to
|
|
49
|
+
* change so the next run gets it. Bound here rather than called from the template so the pure
|
|
50
|
+
* mappings (`StepToolServers.logic.ts`) stay assertable without mounting a component, and so a
|
|
51
|
+
* retired member's absent remedy is decided once instead of on every re-render.
|
|
52
|
+
*/
|
|
53
|
+
const drops = computed(() =>
|
|
54
|
+
props.toolServers.unavailable.map((server) => ({
|
|
55
|
+
...server,
|
|
56
|
+
reasonText: reasonText(server.reason, (key, params) => t(key, params ?? {})),
|
|
57
|
+
remedy: remedyText(server.reason, (key, params) => t(key, params ?? {})),
|
|
58
|
+
})),
|
|
59
|
+
)
|
|
50
60
|
</script>
|
|
51
61
|
|
|
52
62
|
<template>
|
|
@@ -87,9 +97,9 @@ const describeReason = (reason: ToolServerUnavailableReason) =>
|
|
|
87
97
|
</li>
|
|
88
98
|
</ul>
|
|
89
99
|
|
|
90
|
-
<ul v-if="
|
|
100
|
+
<ul v-if="drops.length" class="mt-2 space-y-1.5">
|
|
91
101
|
<li
|
|
92
|
-
v-for="server in
|
|
102
|
+
v-for="server in drops"
|
|
93
103
|
:key="server.id"
|
|
94
104
|
data-testid="step-tool-server-unavailable"
|
|
95
105
|
class="flex items-start gap-1.5 text-[12px] text-slate-300"
|
|
@@ -97,7 +107,17 @@ const describeReason = (reason: ToolServerUnavailableReason) =>
|
|
|
97
107
|
<UIcon name="i-lucide-plug-zap" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400/80" />
|
|
98
108
|
<span>
|
|
99
109
|
<span class="font-medium text-slate-200">{{ server.label || server.id }}</span>
|
|
100
|
-
<span class="text-slate-400"> {{
|
|
110
|
+
<span class="text-slate-400"> {{ server.reasonText }}</span>
|
|
111
|
+
<!--
|
|
112
|
+
Absent for a reason this build no longer recognises: it knows the code was recorded and
|
|
113
|
+
not what it meant, so there is no surface it can honestly send an operator to.
|
|
114
|
+
-->
|
|
115
|
+
<span
|
|
116
|
+
v-if="server.remedy"
|
|
117
|
+
data-testid="step-tool-server-remedy"
|
|
118
|
+
class="mt-0.5 block text-slate-500"
|
|
119
|
+
>{{ server.remedy }}</span
|
|
120
|
+
>
|
|
101
121
|
</span>
|
|
102
122
|
</li>
|
|
103
123
|
</ul>
|
|
@@ -78,7 +78,13 @@ export function refusedRiskPolicySelections(input: {
|
|
|
78
78
|
if (!from) return refusals
|
|
79
79
|
const judge = (id: string, to: RiskPolicy | null) => {
|
|
80
80
|
if (!to) return
|
|
81
|
-
|
|
81
|
+
// The same actor on both sides: a picker only ever offers the library of the ONE workspace the
|
|
82
|
+
// task is homed in, so both policies are in force there and the editor is the same person to
|
|
83
|
+
// each. The two-sided shape exists for the cross-home move, which the picker cannot express.
|
|
84
|
+
const refusal = refuseRiskPolicySelection({
|
|
85
|
+
from: { policy: from, actor: input.actor },
|
|
86
|
+
to: { policy: to, actor: input.actor },
|
|
87
|
+
})
|
|
82
88
|
if (refusal) refusals.set(id, refusal)
|
|
83
89
|
}
|
|
84
90
|
judge('', input.defaultPolicy)
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
getAccountSettingsContract,
|
|
8
8
|
getEmailConnectionContract,
|
|
9
9
|
listAccountMembersContract,
|
|
10
|
+
listAuditEventsContract,
|
|
10
11
|
listAccountsContract,
|
|
11
12
|
listInvitationsContract,
|
|
12
13
|
revokeInvitationContract,
|
|
14
|
+
revokeMemberSessionsContract,
|
|
13
15
|
setMemberRolesContract,
|
|
14
16
|
testEmailContract,
|
|
15
17
|
updateAccountContract,
|
|
@@ -42,6 +44,16 @@ export function accountsApi({ send }: ApiContext) {
|
|
|
42
44
|
setMemberRoles: (accountId: string, userId: string, roles: AccountRole[]) =>
|
|
43
45
|
send(setMemberRolesContract, { pathParams: { accountId, userId }, body: { roles } }),
|
|
44
46
|
|
|
47
|
+
// End every session a member holds, without touching their membership or roles: the
|
|
48
|
+
// offboarding lever for a departure or a lost device. Admin-only (enforced server-side).
|
|
49
|
+
revokeMemberSessions: (accountId: string, userId: string) =>
|
|
50
|
+
send(revokeMemberSessionsContract, { pathParams: { accountId, userId } }),
|
|
51
|
+
|
|
52
|
+
// One page of the account's audit log, newest first. `cursor` is opaque and comes straight
|
|
53
|
+
// back from a previous page; the server clamps `limit`.
|
|
54
|
+
listAuditEvents: (accountId: string, query: { cursor?: string; limit?: number } = {}) =>
|
|
55
|
+
send(listAuditEventsContract, { pathParams: { accountId }, queryParams: query }),
|
|
56
|
+
|
|
45
57
|
// Invitations: invite teammates by email into an org account.
|
|
46
58
|
listInvitations: (accountId: string) =>
|
|
47
59
|
send(listInvitationsContract, { pathParams: { accountId } }),
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
listDocumentsContract,
|
|
10
10
|
listDocumentSourcesContract,
|
|
11
11
|
planDocumentContract,
|
|
12
|
+
refreshDocumentContract,
|
|
12
13
|
resolveDocumentRefContract,
|
|
13
14
|
searchDocumentsContract,
|
|
14
15
|
spawnDocumentContract,
|
|
@@ -61,6 +62,14 @@ export function documentsApi({ send, ws }: ApiContext) {
|
|
|
61
62
|
importDocument: (workspaceId: string, source: DocumentSourceKind, body: { ref: string }) =>
|
|
62
63
|
send(importDocumentContract, { pathPrefix: ws(workspaceId), pathParams: { source }, body }),
|
|
63
64
|
|
|
65
|
+
// Re-confirm one stored document against its source now, pulling the new body if the page
|
|
66
|
+
// moved. Keyed by `(source, externalId)` in the BODY, because the target is a stored row
|
|
67
|
+
// rather than a provider surface, and an `externalId` carries slashes.
|
|
68
|
+
refreshDocument: (
|
|
69
|
+
workspaceId: string,
|
|
70
|
+
body: { source: DocumentSourceKind; externalId: string },
|
|
71
|
+
) => send(refreshDocumentContract, { pathPrefix: ws(workspaceId), body }),
|
|
72
|
+
|
|
64
73
|
searchDocumentSource: (workspaceId: string, source: DocumentSourceKind, query: string) =>
|
|
65
74
|
send(searchDocumentsContract, {
|
|
66
75
|
pathPrefix: ws(workspaceId),
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type {
|
|
3
|
+
DocumentFreshness,
|
|
4
|
+
DocumentOrigin,
|
|
5
|
+
DocumentSourceKind,
|
|
6
|
+
RefreshedDocumentView,
|
|
7
|
+
SourceDocument,
|
|
8
|
+
} from '~/types/domain'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A verdict plus WHEN it was reached. The two are one value because either alone is a half-truth:
|
|
12
|
+
* the verdict says the copy matched the source, and only the stamp says how long ago that was true
|
|
13
|
+
* of a page someone else is still editing. A surface handed the verdict without the stamp can only
|
|
14
|
+
* render it as "just now", which is exactly the false confidence this feature exists to remove: an
|
|
15
|
+
* hour-old confirmation would keep showing a green check.
|
|
16
|
+
*
|
|
17
|
+
* `checkedAt` is stamped where the answer LANDS rather than on the server, so it is on the clock the
|
|
18
|
+
* person reading it is looking at. A skewed browser clock renders its own time consistently; a
|
|
19
|
+
* server stamp would render "checked at 14:32" to someone whose clock says 14:29.
|
|
20
|
+
*/
|
|
21
|
+
export interface DatedFreshness {
|
|
22
|
+
readonly verdict: DocumentFreshness
|
|
23
|
+
/** Epoch ms, on the reader's own clock. */
|
|
24
|
+
readonly checkedAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The "is the copy on the board still the current one" half of the documents store: the per-document
|
|
29
|
+
* verdict a manual check produced, the in-flight flags, and the call that produces both.
|
|
30
|
+
*
|
|
31
|
+
* Its own collaborator rather than more lines in the store because it holds a DIFFERENT KIND of
|
|
32
|
+
* state from everything around it. The store's other state is the projection itself (sources,
|
|
33
|
+
* connections, imported rows), which the backend owns and the SPA mirrors; a freshness verdict is a
|
|
34
|
+
* statement about one MOMENT ("as of the click, this was the live revision"), owned by nothing and
|
|
35
|
+
* true of no row. Keeping it separate is what makes the three rules below expressible at all instead
|
|
36
|
+
* of folding into the document list and quietly becoming a claim about it.
|
|
37
|
+
*
|
|
38
|
+
* The rules, in one place so no surface can get half of them right:
|
|
39
|
+
*
|
|
40
|
+
* - **An absent verdict means "nobody has asked", never "unknown".** Listing documents
|
|
41
|
+
* deliberately probes nothing (confirming costs a round trip to the source per page), so a
|
|
42
|
+
* freshly imported document has no verdict and is perfectly fine, which is not the same fact as
|
|
43
|
+
* a check that ran and could not conclude.
|
|
44
|
+
* - **The verdict never merges into the row.** A refresh that finds nothing changed writes
|
|
45
|
+
* nothing, so `syncedAt` legitimately stays where it was; folding a confirmation into the row
|
|
46
|
+
* would either claim a write that never happened or leave the confirmation sitting on a body
|
|
47
|
+
* the source has moved past since.
|
|
48
|
+
* - **A verdict belongs to the BOARD it was asked on.** The same Figma file can be imported into
|
|
49
|
+
* two boards, and the pair `(source, externalId)` is identical in both, so a map keyed by that
|
|
50
|
+
* alone would render board A's "confirmed, revision v3" against board B's row, which nobody
|
|
51
|
+
* checked, breaking the first rule in the one direction that cannot be noticed. Hence
|
|
52
|
+
* {@link keyOf}, and hence the read below asking for the ACTIVE board each time rather than
|
|
53
|
+
* capturing it once.
|
|
54
|
+
*/
|
|
55
|
+
export function useDocumentFreshness(deps: {
|
|
56
|
+
/**
|
|
57
|
+
* The board these maps are ABOUT. A getter rather than a value because it changes under a
|
|
58
|
+
* long-lived store, and every read has to see the change.
|
|
59
|
+
*/
|
|
60
|
+
workspaceId: () => string | null
|
|
61
|
+
/** Ask the backend to re-confirm one document. Only a CONNECTABLE source can be asked. */
|
|
62
|
+
refresh: (source: DocumentSourceKind, externalId: string) => Promise<RefreshedDocumentView>
|
|
63
|
+
/** Reconcile the (possibly rewritten) row back into the list every surface reads. */
|
|
64
|
+
onRefreshed: (document: SourceDocument) => void
|
|
65
|
+
}): {
|
|
66
|
+
refresh: (source: DocumentSourceKind, externalId: string) => Promise<RefreshedDocumentView>
|
|
67
|
+
freshnessFor: (source: DocumentOrigin, externalId: string) => DatedFreshness | undefined
|
|
68
|
+
isRefreshing: (source: DocumentOrigin, externalId: string) => boolean
|
|
69
|
+
} {
|
|
70
|
+
const freshness = ref<Record<string, DatedFreshness>>({})
|
|
71
|
+
const refreshing = ref<Record<string, boolean>>({})
|
|
72
|
+
const keyOf = (workspaceId: string | null, source: DocumentOrigin, externalId: string) =>
|
|
73
|
+
`${workspaceId ?? ''}:${source}:${externalId}`
|
|
74
|
+
|
|
75
|
+
async function refresh(source: DocumentSourceKind, externalId: string) {
|
|
76
|
+
// Captured ONCE, at the start, and used for every write below: a check that outlives a board
|
|
77
|
+
// switch must land under the board it was asked on, not whichever one is showing when it
|
|
78
|
+
// returns. Re-reading the getter at the end would file the answer under a board that never
|
|
79
|
+
// asked, which is the same wrong render the key exists to prevent, just harder to reproduce.
|
|
80
|
+
const board = deps.workspaceId()
|
|
81
|
+
const key = keyOf(board, source, externalId)
|
|
82
|
+
refreshing.value = { ...refreshing.value, [key]: true }
|
|
83
|
+
try {
|
|
84
|
+
const result = await deps.refresh(source, externalId)
|
|
85
|
+
// The document LIST is the active board's, and it is not keyed by board, so a row can only be
|
|
86
|
+
// merged into it while that board is still the one showing. The verdict is filed either way:
|
|
87
|
+
// it stays true of the board it was asked on, and switching back should not have to re-ask.
|
|
88
|
+
if (deps.workspaceId() === board) deps.onRefreshed(result.document)
|
|
89
|
+
freshness.value = {
|
|
90
|
+
...freshness.value,
|
|
91
|
+
[key]: { verdict: result.freshness, checkedAt: Date.now() },
|
|
92
|
+
}
|
|
93
|
+
return result
|
|
94
|
+
} finally {
|
|
95
|
+
// Cleared however the call ended. A failure that left the flag set would disable the button
|
|
96
|
+
// that is the whole remedy, so the person could not try again.
|
|
97
|
+
const { [key]: _settled, ...rest } = refreshing.value
|
|
98
|
+
refreshing.value = rest
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The verdict map itself stays INSIDE: a caller that could write it could record a conclusion
|
|
103
|
+
// nobody reached, which is the one thing this vocabulary exists to make impossible.
|
|
104
|
+
return {
|
|
105
|
+
refresh,
|
|
106
|
+
freshnessFor: (source, externalId) =>
|
|
107
|
+
freshness.value[keyOf(deps.workspaceId(), source, externalId)],
|
|
108
|
+
isRefreshing: (source, externalId) =>
|
|
109
|
+
!!refreshing.value[keyOf(deps.workspaceId(), source, externalId)],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { useAccountsStore } from '~/stores/accounts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The audit feed and the one write that produces a row in it.
|
|
6
|
+
*
|
|
7
|
+
* What these guard is the seam between them. Forcing a member's sessions to end leaves nothing
|
|
8
|
+
* visible in the roster — the row IS the trace — so the feed has to be brought back into step.
|
|
9
|
+
* Doing that by awaiting the read inside the write is what conflated the two: a revocation that
|
|
10
|
+
* had already succeeded was reported to the admin as "could not sign the member out" whenever the
|
|
11
|
+
* follow-up read failed, which on a deployment with no audit store wired is every time.
|
|
12
|
+
*/
|
|
13
|
+
describe('accounts store — audit feed and forced revocation', () => {
|
|
14
|
+
it('reports a successful revocation as successful even when the audit read is broken', async () => {
|
|
15
|
+
// The 204 landed. Nothing about a failing GET afterwards changes that, and telling an admin
|
|
16
|
+
// otherwise invites them to do it again or to escalate a problem they do not have.
|
|
17
|
+
const listAuditEvents = vi.fn(() => Promise.reject(new Error('audit store down')))
|
|
18
|
+
const revokeMemberSessions = vi.fn(() => Promise.resolve())
|
|
19
|
+
vi.stubGlobal('useApi', () => ({ listAuditEvents, revokeMemberSessions }))
|
|
20
|
+
|
|
21
|
+
const store = useAccountsStore()
|
|
22
|
+
await expect(store.revokeMemberSessions('acc_1', 'usr_2')).resolves.toBeUndefined()
|
|
23
|
+
expect(revokeMemberSessions).toHaveBeenCalledWith('acc_1', 'usr_2')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('does not read the audit log from the write path at all', async () => {
|
|
27
|
+
// The write must not pay for a read nothing may be showing: the panel is absent in basic mode
|
|
28
|
+
// and on any deployment that wires no audit store. Marking the feed stale leaves the reload to
|
|
29
|
+
// the viewer, which is the only place that knows it is on screen and the only place that
|
|
30
|
+
// renders a failed read AS a failed read.
|
|
31
|
+
const listAuditEvents = vi.fn(() => Promise.resolve({ events: [], nextCursor: null }))
|
|
32
|
+
vi.stubGlobal('useApi', () => ({
|
|
33
|
+
listAuditEvents,
|
|
34
|
+
revokeMemberSessions: () => Promise.resolve(),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
const store = useAccountsStore()
|
|
38
|
+
await store.revokeMemberSessions('acc_1', 'usr_2')
|
|
39
|
+
|
|
40
|
+
expect(listAuditEvents).not.toHaveBeenCalled()
|
|
41
|
+
expect(store.auditStale).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('propagates a failed revocation, which IS the caller’s business', async () => {
|
|
45
|
+
vi.stubGlobal('useApi', () => ({
|
|
46
|
+
listAuditEvents: () => Promise.resolve({ events: [], nextCursor: null }),
|
|
47
|
+
revokeMemberSessions: () => Promise.reject(new Error('nope')),
|
|
48
|
+
}))
|
|
49
|
+
|
|
50
|
+
const store = useAccountsStore()
|
|
51
|
+
store.auditStale = false
|
|
52
|
+
|
|
53
|
+
await expect(store.revokeMemberSessions('acc_1', 'usr_2')).rejects.toThrow('nope')
|
|
54
|
+
// Nothing was written, so the feed is not behind.
|
|
55
|
+
expect(store.auditStale).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('clears the stale flag on the reload ATTEMPT, not on its success', async () => {
|
|
59
|
+
// Clearing on success would re-trigger the watch that just failed, on every failure, forever.
|
|
60
|
+
// The failure is reported by the viewer's own error slot instead.
|
|
61
|
+
vi.stubGlobal('useApi', () => ({
|
|
62
|
+
listAuditEvents: () => Promise.reject(new Error('still down')),
|
|
63
|
+
revokeMemberSessions: () => Promise.resolve(),
|
|
64
|
+
}))
|
|
65
|
+
|
|
66
|
+
const store = useAccountsStore()
|
|
67
|
+
await store.revokeMemberSessions('acc_1', 'usr_2')
|
|
68
|
+
expect(store.auditStale).toBe(true)
|
|
69
|
+
|
|
70
|
+
await expect(store.loadAuditEvents('acc_1')).rejects.toThrow('still down')
|
|
71
|
+
expect(store.auditStale).toBe(false)
|
|
72
|
+
expect(store.auditLoading).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
})
|