@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
|
@@ -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
|
+
})
|
package/app/stores/accounts.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { computed, ref } from 'vue'
|
|
3
3
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
4
|
+
import type { AuditEventWire } from '@cat-factory/contracts'
|
|
4
5
|
import type {
|
|
5
6
|
Account,
|
|
6
7
|
AccountInvitation,
|
|
@@ -113,6 +114,73 @@ export const useAccountsStore = defineStore(
|
|
|
113
114
|
return updated
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
// ---- audit log + session revocation -----------------------------------
|
|
118
|
+
// The audit log is a paginated, append-only feed, so it is kept OUT of `useUpsertList`: the
|
|
119
|
+
// rows never change and never arrive out of band, and a keyed upsert list would quietly
|
|
120
|
+
// reorder a page whose whole meaning is its order. Pages are appended in the order the server
|
|
121
|
+
// served them, and `auditCursor` is opaque — it round-trips verbatim, never inspected.
|
|
122
|
+
const auditEvents = ref<AuditEventWire[]>([])
|
|
123
|
+
const auditCursor = ref<string | null>(null)
|
|
124
|
+
const auditLoading = ref(false)
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The audit feed has fallen behind something this session did. Set by the writers below and
|
|
128
|
+
* cleared by whoever reloads; the audit viewer watches it.
|
|
129
|
+
*
|
|
130
|
+
* A flag rather than a reload, because reloading here is what conflated two outcomes: the
|
|
131
|
+
* revocation and the feed refresh are separate calls that fail separately, and awaiting the
|
|
132
|
+
* second inside the first reported a revocation that HAD succeeded as "could not sign the
|
|
133
|
+
* member out" whenever the read failed after it. It also fired on surfaces with no audit
|
|
134
|
+
* panel rendered (basic mode, and any account whose deployment wires no audit store), paying
|
|
135
|
+
* for a read nothing was going to show and turning its 503 into an error about the write.
|
|
136
|
+
*
|
|
137
|
+
* The viewer owns the reload instead, which is where it belongs: it already distinguishes a
|
|
138
|
+
* failed page from an empty one, and a refresh failure now renders in that slot rather than
|
|
139
|
+
* as a false report about the revocation.
|
|
140
|
+
*/
|
|
141
|
+
const auditStale = ref(false)
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Load the newest page, replacing whatever was held. Used on open and on refresh, so a reader
|
|
145
|
+
* is never shown a feed spliced from two different moments.
|
|
146
|
+
*/
|
|
147
|
+
async function loadAuditEvents(accountId: string) {
|
|
148
|
+
auditLoading.value = true
|
|
149
|
+
// Cleared on ATTEMPT, not on success. A failed reload is reported by the viewer's own error
|
|
150
|
+
// slot, and leaving the flag set would re-trigger the watch that just failed.
|
|
151
|
+
auditStale.value = false
|
|
152
|
+
try {
|
|
153
|
+
const page = await api.listAuditEvents(accountId)
|
|
154
|
+
auditEvents.value = page.events
|
|
155
|
+
auditCursor.value = page.nextCursor
|
|
156
|
+
} finally {
|
|
157
|
+
auditLoading.value = false
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Append the next (older) page. A no-op at the end of the log. */
|
|
162
|
+
async function loadMoreAuditEvents(accountId: string) {
|
|
163
|
+
if (!auditCursor.value || auditLoading.value) return
|
|
164
|
+
auditLoading.value = true
|
|
165
|
+
try {
|
|
166
|
+
const page = await api.listAuditEvents(accountId, { cursor: auditCursor.value })
|
|
167
|
+
auditEvents.value = [...auditEvents.value, ...page.events]
|
|
168
|
+
auditCursor.value = page.nextCursor
|
|
169
|
+
} finally {
|
|
170
|
+
auditLoading.value = false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* End every session a member holds. Their membership and roles are untouched, so the roster
|
|
176
|
+
* needs no patching — what changed is not visible in it, which is why the audit feed is
|
|
177
|
+
* marked stale instead: the revocation's only lasting trace is the row it wrote.
|
|
178
|
+
*/
|
|
179
|
+
async function revokeMemberSessions(accountId: string, userId: string) {
|
|
180
|
+
await api.revokeMemberSessions(accountId, userId)
|
|
181
|
+
auditStale.value = true
|
|
182
|
+
}
|
|
183
|
+
|
|
116
184
|
// ---- email sender connection -----------------------------------------
|
|
117
185
|
|
|
118
186
|
const emailConnection = ref<EmailConnection | null>(null)
|
|
@@ -144,6 +212,10 @@ export const useAccountsStore = defineStore(
|
|
|
144
212
|
ready,
|
|
145
213
|
members,
|
|
146
214
|
invitations,
|
|
215
|
+
auditEvents,
|
|
216
|
+
auditCursor,
|
|
217
|
+
auditLoading,
|
|
218
|
+
auditStale,
|
|
147
219
|
emailConnection,
|
|
148
220
|
emailConfigured,
|
|
149
221
|
load,
|
|
@@ -155,6 +227,9 @@ export const useAccountsStore = defineStore(
|
|
|
155
227
|
invite,
|
|
156
228
|
revokeInvite,
|
|
157
229
|
setMemberRoles,
|
|
230
|
+
loadAuditEvents,
|
|
231
|
+
loadMoreAuditEvents,
|
|
232
|
+
revokeMemberSessions,
|
|
158
233
|
loadEmailConnection,
|
|
159
234
|
connectEmail,
|
|
160
235
|
disconnectEmail,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import en from '../../../i18n/locales/en.json'
|
|
4
|
+
import { moveRefusalKey } from './moveRefusal'
|
|
5
|
+
|
|
6
|
+
/** The thrown shape the contract client produces for a refused reparent (`ApiError.body`). */
|
|
7
|
+
const refused = (reason: string) => ({
|
|
8
|
+
body: { error: { code: 'forbidden', details: { reason } } },
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const REASONS: RiskPolicySelectionRefusal[] = [
|
|
12
|
+
'relaxes_role_sandbox',
|
|
13
|
+
'relaxes_role_submission_allowlist',
|
|
14
|
+
'relaxes_role_class_rule',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
describe('moveRefusalKey', () => {
|
|
18
|
+
it('maps every refusal reason to a key the catalog actually holds', () => {
|
|
19
|
+
// Derived from the contracts union rather than a list written here, so a new reason fails
|
|
20
|
+
// this rather than silently reaching the user as the backend's untranslated English.
|
|
21
|
+
const catalog = (en as { board: { toast: { moveRefused: Record<string, string> } } }).board
|
|
22
|
+
.toast.moveRefused
|
|
23
|
+
for (const reason of REASONS) {
|
|
24
|
+
const key = moveRefusalKey(refused(reason))
|
|
25
|
+
expect(key, `${reason} has no key`).toBe(`board.toast.moveRefused.${reason}`)
|
|
26
|
+
expect(catalog[reason], `${reason} has no copy`).toBeTruthy()
|
|
27
|
+
}
|
|
28
|
+
// And nothing else: copy for a reason the backend cannot send is copy nobody translates for
|
|
29
|
+
// a purpose.
|
|
30
|
+
expect(Object.keys(catalog).sort()).toEqual([...REASONS].sort())
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('falls back to the raw message for anything else', () => {
|
|
34
|
+
// A 403 the guard did not raise, a network fault, a reason a newer backend knows and this
|
|
35
|
+
// build does not: the backend's own prose is the honest last resort, not a wrong translation.
|
|
36
|
+
expect(moveRefusalKey(refused('some_future_reason'))).toBeNull()
|
|
37
|
+
expect(moveRefusalKey({ body: { error: { code: 'forbidden' } } })).toBeNull()
|
|
38
|
+
expect(moveRefusalKey(new Error('Network down'))).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RiskPolicySelectionRefusal } from '@cat-factory/contracts'
|
|
2
|
+
import { apiErrorReason } from '~/composables/api/errors'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The translated description for a reparent the backend refused on merge-preset grounds, or
|
|
6
|
+
* `null` when the failure is anything else.
|
|
7
|
+
*
|
|
8
|
+
* A cross-home drag carries a task into another workspace's preset library, which re-decides
|
|
9
|
+
* whether its runs are sandboxed for the mover's role (ADR 0037), so the backend refuses one that
|
|
10
|
+
* would drop a restriction they are under. That refusal is a condition a person can act on, and
|
|
11
|
+
* the backend does not localize prose: it emits the machine-readable `details.reason` and the SPA
|
|
12
|
+
* maps it here, exactly as `usePipelineErrorToast` maps a conflict's. Without this the drag toast
|
|
13
|
+
* showed the raw English `ForbiddenError` message to every locale.
|
|
14
|
+
*
|
|
15
|
+
* The copy is deliberately NOT the picker's `riskPolicy.picker.refused.*`, which is worded for
|
|
16
|
+
* someone holding a control: this person picked no policy at all, and telling them about "the
|
|
17
|
+
* policy you picked" sends them looking for a picker they never touched.
|
|
18
|
+
*
|
|
19
|
+
* Keyed on the contracts union so a renamed reason fails the typecheck rather than silently
|
|
20
|
+
* falling through to the untranslated prose. An unrecognised reason returns `null` and the caller
|
|
21
|
+
* falls back to the backend's own message, which is the honest last resort.
|
|
22
|
+
*/
|
|
23
|
+
const MOVE_REFUSAL_KEY: Record<RiskPolicySelectionRefusal, string> = {
|
|
24
|
+
relaxes_role_sandbox: 'board.toast.moveRefused.relaxes_role_sandbox',
|
|
25
|
+
relaxes_role_submission_allowlist: 'board.toast.moveRefused.relaxes_role_submission_allowlist',
|
|
26
|
+
relaxes_role_class_rule: 'board.toast.moveRefused.relaxes_role_class_rule',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The i18n key for a thrown reparent error's refusal reason, else `null`. */
|
|
30
|
+
export function moveRefusalKey(error: unknown): string | null {
|
|
31
|
+
const reason = apiErrorReason(error)
|
|
32
|
+
if (!reason) return null
|
|
33
|
+
return MOVE_REFUSAL_KEY[reason as RiskPolicySelectionRefusal] ?? null
|
|
34
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { UpdateBlockInput } from '@cat-factory/contracts'
|
|
|
2
2
|
import { useServicesStore } from '~/stores/services'
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
4
|
import { createBoardDependencies } from './dependencies'
|
|
5
|
+
import { moveRefusalKey } from './moveRefusal'
|
|
5
6
|
import type { BoardWriteContext } from './context'
|
|
6
7
|
import { UNDO_WINDOW_MS } from './context'
|
|
7
8
|
|
|
@@ -69,9 +70,13 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
69
70
|
} catch (e) {
|
|
70
71
|
b.parentId = prevParentId
|
|
71
72
|
b.position = prevPosition
|
|
73
|
+
// A cross-home drag can be refused on merge-preset grounds, which is a condition the mover
|
|
74
|
+
// can act on rather than a fault. The backend sends the machine-readable reason and no
|
|
75
|
+
// translated prose, so map it here; anything else keeps the raw message as the last resort.
|
|
76
|
+
const refusal = moveRefusalKey(e)
|
|
72
77
|
toast.add({
|
|
73
78
|
title: tr('board.toast.moveFailed'),
|
|
74
|
-
description: e instanceof Error ? e.message : String(e),
|
|
79
|
+
description: refusal ? tr(refusal) : e instanceof Error ? e.message : String(e),
|
|
75
80
|
icon: 'i-lucide-triangle-alert',
|
|
76
81
|
color: 'error',
|
|
77
82
|
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import { useDocumentsStore } from '~/stores/documents'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { DocumentFreshness, SourceDocument } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
// The document store's REFRESH half: what a person gets back when they ask a source whether the
|
|
7
|
+
// copy on the board is still the current one.
|
|
8
|
+
//
|
|
9
|
+
// Two things are worth pinning here rather than leaving to the component. The verdict is kept
|
|
10
|
+
// SEPARATE from the row because it is a statement about a moment, not a property of the projection,
|
|
11
|
+
// so an absent entry has to keep meaning "nobody has asked" (listing documents deliberately probes
|
|
12
|
+
// nothing). And a refresh that pulls a moved page has to reconcile the returned row into the same
|
|
13
|
+
// list every surface reads, or the panel goes on showing the title and excerpt import stored while
|
|
14
|
+
// claiming, one line below, that it just confirmed the newer revision.
|
|
15
|
+
|
|
16
|
+
function doc(over: Partial<SourceDocument> = {}): SourceDocument {
|
|
17
|
+
return {
|
|
18
|
+
source: 'figma',
|
|
19
|
+
externalId: 'file1:1-2',
|
|
20
|
+
title: 'Checkout flow',
|
|
21
|
+
url: 'https://figma.com/design/file1',
|
|
22
|
+
excerpt: 'Checkout',
|
|
23
|
+
linkedBlockId: 'task_1',
|
|
24
|
+
role: null,
|
|
25
|
+
docKind: null,
|
|
26
|
+
syncedAt: 1_000,
|
|
27
|
+
...over,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CONFIRMED: DocumentFreshness = { status: 'confirmed', version: 'v2', change: 'reimported' }
|
|
32
|
+
|
|
33
|
+
/** The store reads `useApi()` off the auto-import, so each test stubs just what it calls. */
|
|
34
|
+
function stubApi(over: Record<string, unknown>) {
|
|
35
|
+
vi.stubGlobal('useApi', () => ({
|
|
36
|
+
listDocuments: () => Promise.resolve([]),
|
|
37
|
+
...over,
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('documents store: manual refresh', () => {
|
|
46
|
+
it('reconciles the refreshed row into the list every surface reads', async () => {
|
|
47
|
+
stubApi({
|
|
48
|
+
listDocuments: () => Promise.resolve([doc()]),
|
|
49
|
+
refreshDocument: () =>
|
|
50
|
+
Promise.resolve({
|
|
51
|
+
document: doc({ title: 'Checkout flow v2', syncedAt: 2_000 }),
|
|
52
|
+
freshness: CONFIRMED,
|
|
53
|
+
}),
|
|
54
|
+
})
|
|
55
|
+
const store = useDocumentsStore()
|
|
56
|
+
await store.loadDocuments()
|
|
57
|
+
|
|
58
|
+
await store.refresh('figma', 'file1:1-2')
|
|
59
|
+
|
|
60
|
+
// Upserted by `(source, externalId)`, not appended: a refresh must not leave the old title
|
|
61
|
+
// sitting beside the new one in a picker keyed by the same document.
|
|
62
|
+
expect(store.documents).toHaveLength(1)
|
|
63
|
+
expect(store.documents[0]?.title).toBe('Checkout flow v2')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('records the verdict beside the row, and reports "nobody asked" until someone does', async () => {
|
|
67
|
+
stubApi({
|
|
68
|
+
refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
|
|
69
|
+
})
|
|
70
|
+
const store = useDocumentsStore()
|
|
71
|
+
|
|
72
|
+
// An unasked document has NO verdict. It must not read as unknown-and-therefore-suspect: a
|
|
73
|
+
// freshly imported page is fine, it simply has not been re-checked since.
|
|
74
|
+
expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
|
|
75
|
+
|
|
76
|
+
await store.refresh('figma', 'file1:1-2')
|
|
77
|
+
|
|
78
|
+
expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
|
|
79
|
+
// Stamped with WHEN it was reached, because a verdict never expires: what keeps an hour-old
|
|
80
|
+
// confirmation from rendering as the present state of a page that has had an hour to move is
|
|
81
|
+
// that the moment travels with it.
|
|
82
|
+
expect(store.freshnessFor('figma', 'file1:1-2')?.checkedAt).toBeTypeOf('number')
|
|
83
|
+
// Scoped to the document that was asked about, never to the source.
|
|
84
|
+
expect(store.freshnessFor('figma', 'other:9-9')).toBeUndefined()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('never shows one board\u2019s verdict against another board\u2019s row', async () => {
|
|
88
|
+
// The same Figma file can be imported into two boards, and `(source, externalId)` is identical
|
|
89
|
+
// in both, so a verdict keyed by that pair alone would render board A's "confirmed, revision
|
|
90
|
+
// v2" against a board B row nobody has ever checked. That breaks the "absent means nobody has
|
|
91
|
+
// asked" rule in the one direction nothing can notice, since the wrong answer looks like a
|
|
92
|
+
// right one.
|
|
93
|
+
stubApi({
|
|
94
|
+
refreshDocument: () => Promise.resolve({ document: doc(), freshness: CONFIRMED }),
|
|
95
|
+
})
|
|
96
|
+
const store = useDocumentsStore()
|
|
97
|
+
await store.refresh('figma', 'file1:1-2')
|
|
98
|
+
expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
|
|
99
|
+
|
|
100
|
+
useWorkspaceStore().workspaceId = 'ws2'
|
|
101
|
+
|
|
102
|
+
expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
|
|
103
|
+
|
|
104
|
+
// …and switching back does not lose it: the verdict stays true of the board it was asked on.
|
|
105
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
106
|
+
expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not merge a check that outlived a board switch into the new board\u2019s list', async () => {
|
|
110
|
+
// The list is the ACTIVE board's and is not keyed by board, so a row arriving after the switch
|
|
111
|
+
// would be a document from somewhere else appearing on a board that never imported it. The
|
|
112
|
+
// verdict is still filed, under the board that asked.
|
|
113
|
+
let resolve!: (v: unknown) => void
|
|
114
|
+
stubApi({
|
|
115
|
+
listDocuments: () => Promise.resolve([]),
|
|
116
|
+
refreshDocument: () =>
|
|
117
|
+
new Promise((res) => {
|
|
118
|
+
resolve = res
|
|
119
|
+
}),
|
|
120
|
+
})
|
|
121
|
+
const store = useDocumentsStore()
|
|
122
|
+
await store.loadDocuments()
|
|
123
|
+
|
|
124
|
+
const pending = store.refresh('figma', 'file1:1-2')
|
|
125
|
+
useWorkspaceStore().workspaceId = 'ws2'
|
|
126
|
+
resolve({ document: doc(), freshness: CONFIRMED })
|
|
127
|
+
await pending
|
|
128
|
+
|
|
129
|
+
expect(store.documents).toHaveLength(0)
|
|
130
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
131
|
+
expect(store.freshnessFor('figma', 'file1:1-2')?.verdict).toEqual(CONFIRMED)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('reports in-flight state per document, and clears it when the source refuses', async () => {
|
|
135
|
+
let reject!: (e: Error) => void
|
|
136
|
+
stubApi({
|
|
137
|
+
refreshDocument: () =>
|
|
138
|
+
new Promise((_res, rej) => {
|
|
139
|
+
reject = rej
|
|
140
|
+
}),
|
|
141
|
+
})
|
|
142
|
+
const store = useDocumentsStore()
|
|
143
|
+
|
|
144
|
+
const pending = store.refresh('figma', 'file1:1-2')
|
|
145
|
+
expect(store.isRefreshing('figma', 'file1:1-2')).toBe(true)
|
|
146
|
+
expect(store.isRefreshing('figma', 'other:9-9')).toBe(false)
|
|
147
|
+
|
|
148
|
+
reject(new Error('figma 429'))
|
|
149
|
+
await expect(pending).rejects.toThrow('figma 429')
|
|
150
|
+
|
|
151
|
+
// A failure that left the flag set would disable the button that is the whole remedy, and the
|
|
152
|
+
// person would have no way to try again.
|
|
153
|
+
expect(store.isRefreshing('figma', 'file1:1-2')).toBe(false)
|
|
154
|
+
expect(store.freshnessFor('figma', 'file1:1-2')).toBeUndefined()
|
|
155
|
+
})
|
|
156
|
+
})
|
package/app/stores/documents.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SourceDocument,
|
|
14
14
|
} from '~/types/domain'
|
|
15
15
|
import { isConnectableSource } from '@cat-factory/contracts'
|
|
16
|
+
import { useDocumentFreshness } from '~/composables/useDocumentFreshness'
|
|
16
17
|
import { useSourceIntegration } from '~/composables/useSourceIntegration'
|
|
17
18
|
import { useUpsertList } from '~/composables/useUpsertList'
|
|
18
19
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
@@ -56,6 +57,16 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
56
57
|
})
|
|
57
58
|
const loading = ref(false)
|
|
58
59
|
|
|
60
|
+
// The "is this still the current revision" half, in its own collaborator: a verdict is a
|
|
61
|
+
// statement about a moment rather than a property of a row, and `useDocumentFreshness` owns why
|
|
62
|
+
// the two must not merge.
|
|
63
|
+
const { refresh, freshnessFor, isRefreshing } = useDocumentFreshness({
|
|
64
|
+
workspaceId: () => workspace.workspaceId,
|
|
65
|
+
refresh: (source, externalId) =>
|
|
66
|
+
api.refreshDocument(workspace.requireId(), { source, externalId }),
|
|
67
|
+
onRefreshed: upsertDoc,
|
|
68
|
+
})
|
|
69
|
+
|
|
59
70
|
// Workspace+DocKind template / exemplar role links (WS1). Loaded lazily when the management
|
|
60
71
|
// panel opens; the full list of role-tagged documents across kinds.
|
|
61
72
|
const roleLinks = ref<SourceDocument[]>([])
|
|
@@ -224,6 +235,9 @@ export const useDocumentsStore = defineStore('documents', () => {
|
|
|
224
235
|
loadDocuments,
|
|
225
236
|
resolveRef,
|
|
226
237
|
importDocument,
|
|
238
|
+
refresh,
|
|
239
|
+
freshnessFor,
|
|
240
|
+
isRefreshing,
|
|
227
241
|
search,
|
|
228
242
|
plan,
|
|
229
243
|
spawn,
|
package/app/types/documents.ts
CHANGED