@cat-factory/app 0.318.0 → 0.319.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 +8 -0
- package/app/components/settings/ApiTokensPanel.vue +62 -40
- package/app/composables/useNewApiKeyDeepLink.spec.ts +10 -1
- package/app/composables/useNewApiKeyDeepLink.ts +18 -3
- package/app/modular/tutorial-tours.spec.ts +79 -48
- package/app/stores/publicApiKeys.spec.ts +35 -3
- package/app/stores/publicApiKeys.ts +9 -5
- package/i18n/locales/de.json +3 -0
- package/i18n/locales/en.json +3 -0
- package/i18n/locales/es.json +3 -0
- package/i18n/locales/fr.json +3 -0
- package/i18n/locales/he.json +3 -0
- package/i18n/locales/it.json +3 -0
- package/i18n/locales/ja.json +3 -0
- package/i18n/locales/pl.json +3 -0
- package/i18n/locales/tr.json +3 -0
- package/i18n/locales/uk.json +3 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1625,8 +1625,16 @@ pnpm lint # oxlint + oxfmt over the w
|
|
|
1625
1625
|
pnpm exec vitest run <file> # run the spec your change touched
|
|
1626
1626
|
pnpm i18n:check # catalog is well-formed
|
|
1627
1627
|
pnpm i18n:parity # locales are in parity
|
|
1628
|
+
|
|
1629
|
+
# Component stories, from the repo root:
|
|
1630
|
+
pnpm --filter @cat-factory/storybook run storybook # http://localhost:6006
|
|
1631
|
+
pnpm --filter @cat-factory/storybook run test:stories # every story in headless Chromium
|
|
1628
1632
|
```
|
|
1629
1633
|
|
|
1634
|
+
A component's stories sit beside it as `Foo.stories.ts`; the fixtures and helpers they share are in
|
|
1635
|
+
`app/stories/` (neither ships in the package). Conventions, helpers and the traps the setup already
|
|
1636
|
+
paid for: [Component stories](https://github.com/kibertoad/cat-factory/blob/main/docs/internal/storybook.md).
|
|
1637
|
+
|
|
1630
1638
|
To see the SPA with data while you work on it, run it against the e2e backend: it needs only a
|
|
1631
1639
|
Postgres, fakes the LLM, the agent runner and GitHub, and can seed boards. See
|
|
1632
1640
|
[Running the SPA against the e2e backend](https://github.com/kibertoad/cat-factory/blob/main/backend/internal/e2e/README.md#running-the-spa-against-the-e2e-backend-ui-development).
|
|
@@ -43,23 +43,66 @@ const store = usePublicApiKeysStore()
|
|
|
43
43
|
const accounts = useAccountsStore()
|
|
44
44
|
const workspace = useWorkspaceStore()
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
46
|
+
// WHO the next key runs as. Two named options rather than an unchecked box, because they are two
|
|
47
|
+
// different credentials with two different blast radii and neither is a mere setting on the other:
|
|
48
|
+
//
|
|
49
|
+
// - `system` (the default): the token belongs to the workspace. Its runs are attributed to nobody
|
|
50
|
+
// and it can reach no personal subscription, so a leaked shared credential can never spend one
|
|
51
|
+
// person's Claude quota. This is what a CI job or a shared integration should hold.
|
|
52
|
+
// - `self`: the token belongs to the person minting it. Its runs are theirs and may unlock their
|
|
53
|
+
// personal Claude / Codex / GLM subscription, with the password sent on each such call.
|
|
54
|
+
//
|
|
55
|
+
// NOT gated on the interface mode, unlike an override field. This whole panel is the Integrations
|
|
56
|
+
// hub's Development surface, reached only by someone already minting an API key, and `scope` next
|
|
57
|
+
// to it is ungated for the same reason. Hiding it in basic mode would leave that person a key that
|
|
58
|
+
// silently cannot run their own models, with nothing on screen to say why.
|
|
59
|
+
type TokenIdentity = 'system' | 'self'
|
|
60
|
+
const identity = ref<TokenIdentity>('system')
|
|
61
|
+
// Nobody to bind on a board with no signed-in user (a dev-open deployment): the server refuses
|
|
62
|
+
// such a mint outright, so the choice is withheld and every key is a system key, which is what
|
|
63
|
+
// that deployment can honestly offer.
|
|
64
|
+
const canBindSelf = computed(() => auth.user !== null && auth.user !== undefined)
|
|
65
|
+
const identityItems = computed(() => [
|
|
66
|
+
{ value: 'system' as const, label: t('settings.apiTokens.add.identitySystem') },
|
|
67
|
+
{ value: 'self' as const, label: t('settings.apiTokens.add.identitySelf') },
|
|
68
|
+
])
|
|
69
|
+
|
|
70
|
+
// A key's reach. The default is this board alone. 'all' and 'selected' are an account admin's to
|
|
71
|
+
// make, because a directory webhook needs a key that reaches every workspace. 'memberships' is
|
|
72
|
+
// anyone's for a key that runs as them: it reaches whatever they can act in, call by call, and
|
|
73
|
+
// never more than their role allows in each, so it is the default for such a key.
|
|
74
|
+
type TokenReach = 'this' | 'all' | 'selected' | 'memberships'
|
|
50
75
|
const reach = ref<TokenReach>('this')
|
|
51
76
|
const selectedWorkspaceIds = ref<string[]>([])
|
|
52
77
|
const canWidenReach = computed(() => accounts.activeAccount?.roles?.includes('admin') === true)
|
|
53
78
|
const reachItems = computed(() => [
|
|
54
79
|
{ value: 'this' as const, label: t('settings.apiTokens.add.reachThis') },
|
|
55
|
-
|
|
56
|
-
|
|
80
|
+
...(identity.value === 'self'
|
|
81
|
+
? [{ value: 'memberships' as const, label: t('settings.apiTokens.add.reachMemberships') }]
|
|
82
|
+
: []),
|
|
83
|
+
{
|
|
84
|
+
value: 'all' as const,
|
|
85
|
+
label: t('settings.apiTokens.add.reachAll'),
|
|
86
|
+
disabled: !canWidenReach.value,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
value: 'selected' as const,
|
|
90
|
+
label: t('settings.apiTokens.add.reachSelected'),
|
|
91
|
+
disabled: !canWidenReach.value,
|
|
92
|
+
},
|
|
57
93
|
])
|
|
94
|
+
const reachHelp = computed(() => {
|
|
95
|
+
if (reach.value === 'memberships') return t('settings.apiTokens.add.reachMembershipsHelp')
|
|
96
|
+
return canWidenReach.value
|
|
97
|
+
? t('settings.apiTokens.add.reachHelp')
|
|
98
|
+
: t('settings.apiTokens.add.reachAdminOnly')
|
|
99
|
+
})
|
|
58
100
|
const workspaceItems = computed(() =>
|
|
59
101
|
workspace.accountWorkspaces.map((w) => ({ value: w.id, label: w.name })),
|
|
60
102
|
)
|
|
61
|
-
/** The
|
|
62
|
-
function requestedReach(): string[] | null | undefined {
|
|
103
|
+
/** The reach to mint with: omitted for this board, `null` for all, the picked list, or memberships. */
|
|
104
|
+
function requestedReach(): string[] | null | 'memberships' | undefined {
|
|
105
|
+
if (reach.value === 'memberships') return identity.value === 'self' ? 'memberships' : undefined
|
|
63
106
|
if (!canWidenReach.value || reach.value === 'this') return undefined
|
|
64
107
|
if (reach.value === 'all') return null
|
|
65
108
|
return selectedWorkspaceIds.value
|
|
@@ -73,6 +116,7 @@ const reachIncomplete = computed(
|
|
|
73
116
|
|
|
74
117
|
/** The reach badge for a listed key; none for a key reaching this one board. */
|
|
75
118
|
function reachLabel(key: PublicApiKey): string | null {
|
|
119
|
+
if (key.followsMemberships) return t('settings.apiTokens.list.reachMemberships')
|
|
76
120
|
if (key.workspaceIds === null) return t('settings.apiTokens.list.reachAll')
|
|
77
121
|
if (key.workspaceIds.length > 1) {
|
|
78
122
|
return t('settings.apiTokens.list.reachCount', { count: key.workspaceIds.length })
|
|
@@ -130,29 +174,13 @@ const back = useIntegrationBack(open)
|
|
|
130
174
|
const label = ref('')
|
|
131
175
|
// The scope the next minted key will carry; defaults to the safe middle of the ladder.
|
|
132
176
|
const scope = ref<PublicApiScope>('write')
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
// personal Claude / Codex / GLM subscription, with the password sent on each such call.
|
|
141
|
-
//
|
|
142
|
-
// NOT gated on the interface mode, unlike an override field. This whole panel is the Integrations
|
|
143
|
-
// hub's Development surface, reached only by someone already minting an API key, and `scope` next
|
|
144
|
-
// to it is ungated for the same reason. Hiding it in basic mode would leave that person a key that
|
|
145
|
-
// silently cannot run their own models, with nothing on screen to say why.
|
|
146
|
-
type TokenIdentity = 'system' | 'self'
|
|
147
|
-
const identity = ref<TokenIdentity>('system')
|
|
148
|
-
// Nobody to bind on a board with no signed-in user (a dev-open deployment): the server refuses
|
|
149
|
-
// such a mint outright, so the choice is withheld and every key is a system key, which is what
|
|
150
|
-
// that deployment can honestly offer.
|
|
151
|
-
const canBindSelf = computed(() => auth.user !== null && auth.user !== undefined)
|
|
152
|
-
const identityItems = computed(() => [
|
|
153
|
-
{ value: 'system' as const, label: t('settings.apiTokens.add.identitySystem') },
|
|
154
|
-
{ value: 'self' as const, label: t('settings.apiTokens.add.identitySelf') },
|
|
155
|
-
])
|
|
177
|
+
// A key that runs as its person follows their workspaces unless they pick otherwise, and one that
|
|
178
|
+
// runs as nobody has no memberships to follow.
|
|
179
|
+
watch(identity, (next) => {
|
|
180
|
+
if (next === 'self' && reach.value === 'this') reach.value = 'memberships'
|
|
181
|
+
if (next === 'system' && reach.value === 'memberships') reach.value = 'this'
|
|
182
|
+
})
|
|
183
|
+
|
|
156
184
|
/** The help text under the picker, so each option explains ITSELF rather than only its opposite. */
|
|
157
185
|
const identityHelp = computed(() =>
|
|
158
186
|
identity.value === 'self'
|
|
@@ -189,6 +217,7 @@ function applyPrefill() {
|
|
|
189
217
|
if (prefill.label) label.value = prefill.label
|
|
190
218
|
if (prefill.scope) scope.value = prefill.scope
|
|
191
219
|
if (prefill.runsAsSelf && canBindSelf.value) identity.value = 'self'
|
|
220
|
+
if (prefill.reach === 'memberships' && identity.value === 'self') reach.value = 'memberships'
|
|
192
221
|
ui.apiTokensPrefill = null
|
|
193
222
|
}
|
|
194
223
|
|
|
@@ -392,18 +421,11 @@ async function revokeToken(key: PublicApiKey) {
|
|
|
392
421
|
data-testid="api-token-identity"
|
|
393
422
|
/>
|
|
394
423
|
</UFormField>
|
|
395
|
-
<UFormField
|
|
396
|
-
:label="t('settings.apiTokens.add.reach')"
|
|
397
|
-
:help="
|
|
398
|
-
canWidenReach
|
|
399
|
-
? t('settings.apiTokens.add.reachHelp')
|
|
400
|
-
: t('settings.apiTokens.add.reachAdminOnly')
|
|
401
|
-
"
|
|
402
|
-
>
|
|
424
|
+
<UFormField :label="t('settings.apiTokens.add.reach')" :help="reachHelp">
|
|
403
425
|
<USelect
|
|
404
426
|
v-model="reach"
|
|
405
427
|
:items="reachItems"
|
|
406
|
-
:disabled="!canWidenReach"
|
|
428
|
+
:disabled="!canWidenReach && identity !== 'self'"
|
|
407
429
|
class="w-full"
|
|
408
430
|
data-testid="api-token-reach"
|
|
409
431
|
/>
|
|
@@ -13,11 +13,14 @@ describe('captureNewApiKeyDeepLink', () => {
|
|
|
13
13
|
})
|
|
14
14
|
|
|
15
15
|
it('parses the link an integration builds, and strips only its own params', () => {
|
|
16
|
-
setUrl(
|
|
16
|
+
setUrl(
|
|
17
|
+
'?newApiKey=1&label=sainte-beuve&scope=decide&runsAsSelf=1&reach=memberships&ws=ws_1&keep=yes',
|
|
18
|
+
)
|
|
17
19
|
expect(captureNewApiKeyDeepLink()).toEqual({
|
|
18
20
|
label: 'sainte-beuve',
|
|
19
21
|
scope: 'decide',
|
|
20
22
|
runsAsSelf: true,
|
|
23
|
+
reach: 'memberships',
|
|
21
24
|
workspaceId: 'ws_1',
|
|
22
25
|
})
|
|
23
26
|
expect(window.location.search).toBe('?keep=yes')
|
|
@@ -30,11 +33,17 @@ describe('captureNewApiKeyDeepLink', () => {
|
|
|
30
33
|
label: null,
|
|
31
34
|
scope: null,
|
|
32
35
|
runsAsSelf: false,
|
|
36
|
+
reach: null,
|
|
33
37
|
workspaceId: null,
|
|
34
38
|
})
|
|
35
39
|
expect(window.location.search).toBe('')
|
|
36
40
|
})
|
|
37
41
|
|
|
42
|
+
it('drops a reach this build does not know rather than guessing one', () => {
|
|
43
|
+
setUrl('?newApiKey=1&runsAsSelf=1&reach=everything')
|
|
44
|
+
expect(captureNewApiKeyDeepLink()).toMatchObject({ reach: null })
|
|
45
|
+
})
|
|
46
|
+
|
|
38
47
|
it('drops a scope this build does not know rather than guessing one', () => {
|
|
39
48
|
setUrl('?newApiKey=1&scope=superuser')
|
|
40
49
|
expect(captureNewApiKeyDeepLink()).toMatchObject({ scope: null })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Boot-time replay of a NEW API KEY deep link:
|
|
3
|
-
* `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&ws=<id>`.
|
|
3
|
+
* `?newApiKey=1&label=<label>&scope=<scope>&runsAsSelf=1&reach=memberships&ws=<id>`.
|
|
4
4
|
*
|
|
5
5
|
* An integration that needs a person's OWN key (one bound to them, which is what lets a run use
|
|
6
6
|
* their personal model subscription) cannot mint it for them: a bound key can only be minted by
|
|
@@ -23,13 +23,22 @@ export interface NewApiKeyPrefill {
|
|
|
23
23
|
label: string | null
|
|
24
24
|
scope: PublicApiScope | null
|
|
25
25
|
runsAsSelf: boolean
|
|
26
|
+
/** `memberships`: reach whatever the person can act in. Only with `runsAsSelf`. */
|
|
27
|
+
reach: 'memberships' | null
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
interface PendingNewApiKeyLink extends NewApiKeyPrefill {
|
|
29
31
|
workspaceId: string | null
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
const NEW_API_KEY_LINK_PARAMS = [
|
|
34
|
+
const NEW_API_KEY_LINK_PARAMS = [
|
|
35
|
+
'newApiKey',
|
|
36
|
+
'label',
|
|
37
|
+
'scope',
|
|
38
|
+
'runsAsSelf',
|
|
39
|
+
'reach',
|
|
40
|
+
'ws',
|
|
41
|
+
] as const
|
|
33
42
|
|
|
34
43
|
/** The form's own label limit, so a prefill never starts out invalid. */
|
|
35
44
|
const LABEL_MAX_LENGTH = 120
|
|
@@ -53,6 +62,7 @@ export function captureNewApiKeyDeepLink(): PendingNewApiKeyLink | null {
|
|
|
53
62
|
label,
|
|
54
63
|
scope: scopeOf(params.get('scope')),
|
|
55
64
|
runsAsSelf: params.get('runsAsSelf') === '1',
|
|
65
|
+
reach: params.get('reach') === 'memberships' ? 'memberships' : null,
|
|
56
66
|
workspaceId: params.get('ws'),
|
|
57
67
|
}
|
|
58
68
|
for (const key of NEW_API_KEY_LINK_PARAMS) params.delete(key)
|
|
@@ -79,7 +89,12 @@ export function useNewApiKeyDeepLink(): void {
|
|
|
79
89
|
(ready) => {
|
|
80
90
|
if (!ready || applied) return
|
|
81
91
|
applied = true
|
|
82
|
-
ui.openApiTokens({
|
|
92
|
+
ui.openApiTokens({
|
|
93
|
+
label: link.label,
|
|
94
|
+
scope: link.scope,
|
|
95
|
+
runsAsSelf: link.runsAsSelf,
|
|
96
|
+
reach: link.reach,
|
|
97
|
+
})
|
|
83
98
|
stop?.()
|
|
84
99
|
},
|
|
85
100
|
{ immediate: true },
|
|
@@ -134,27 +134,6 @@ function navAnchoredSteps(): {
|
|
|
134
134
|
)
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
/**
|
|
138
|
-
* Every combination of the {@link NavGates} booleans, yielded lazily so the 2^N gate sets are
|
|
139
|
-
* never all live at once.
|
|
140
|
-
*
|
|
141
|
-
* Enumerating rather than reasoning is deliberate. The pairing below is an IMPLICATION over
|
|
142
|
-
* gate sets — anything that satisfies a tour must also render its entry — between two
|
|
143
|
-
* predicates written independently in two files, and nothing about their shape is guaranteed
|
|
144
|
-
* (either may be a conjunction, a disjunction, or read a field the other doesn't). The matrix
|
|
145
|
-
* doubles with every field {@link NavGates} gains, so the cost is worth re-measuring rather than
|
|
146
|
-
* quoting from here: at the field count this spec runs against today it is a fraction of a
|
|
147
|
-
* second, a fair price for a guard that needs no assumption about how either side is spelled.
|
|
148
|
-
*/
|
|
149
|
-
function* everyGateSet(): Generator<NavGates> {
|
|
150
|
-
const keys = Object.keys(ALL_GATES) as (keyof NavGates)[]
|
|
151
|
-
for (let mask = 0; mask < 2 ** keys.length; mask++) {
|
|
152
|
-
const gates = {} as Record<keyof NavGates, boolean>
|
|
153
|
-
for (const [bit, key] of keys.entries()) gates[key] = (mask & (1 << bit)) !== 0
|
|
154
|
-
yield gates as NavGates
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
137
|
/** The gate fields a witness has turned OFF — the interesting half of a failure message. */
|
|
159
138
|
function absentGates(gates: NavGates): readonly string[] {
|
|
160
139
|
return Object.entries(gates)
|
|
@@ -162,39 +141,91 @@ function absentGates(gates: NavGates): readonly string[] {
|
|
|
162
141
|
.map(([key]) => key)
|
|
163
142
|
}
|
|
164
143
|
|
|
144
|
+
/** One pair's verdict over `gates`: does it offer the tour while the entry is NOT rendered? */
|
|
145
|
+
function drifts(pair: ReturnType<typeof navAnchoredSteps>[number], gates: NavGates): boolean {
|
|
146
|
+
if (!(pair.tour.requires ?? []).every((requirement) => requirement.met(gates))) return false
|
|
147
|
+
if (pair.step.when && !pair.step.when(gates)) return false
|
|
148
|
+
return !navItemVisible(pair.item, gates)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Thrown by the gate proxy on the first read of a field the current branch has not assigned. */
|
|
152
|
+
class UnassignedGate {
|
|
153
|
+
constructor(readonly key: keyof NavGates) {}
|
|
154
|
+
}
|
|
155
|
+
|
|
165
156
|
/**
|
|
166
|
-
* For
|
|
157
|
+
* For one pair, a gate set that OFFERS its tour while the entry its step clicks is NOT
|
|
167
158
|
* rendered, or `undefined` when no such set exists (what the guard wants). A step the gate set
|
|
168
|
-
* DROPS (`when`) needs no anchor, so it cannot be a counterexample.
|
|
159
|
+
* DROPS (`when`) needs no anchor, so it cannot be a counterexample. Also returns the gate
|
|
160
|
+
* fields the search branched on, so the test can see that it varied something.
|
|
169
161
|
*
|
|
170
162
|
* Of the many counterexamples one break produces, the one reported is the SMALLEST: the gate
|
|
171
163
|
* set closest to fully-permitted, so its absent fields are exactly the ones that matter. The
|
|
172
|
-
* first witness
|
|
164
|
+
* first witness a search happens to reach names most of `NavGates` and reads as noise, which
|
|
173
165
|
* is the difference between a failure that says "declare `advancedMode`" and one that says
|
|
174
166
|
* "something about fourteen gates".
|
|
175
167
|
*
|
|
176
|
-
* The
|
|
177
|
-
*
|
|
168
|
+
* The search branches lazily on the fields the pair's predicates read. It evaluates the pair
|
|
169
|
+
* through a proxy; the first read of a field this branch has not assigned stops the
|
|
170
|
+
* evaluation, and the search tries that field `false` and then `true`. A branch whose
|
|
171
|
+
* evaluation completes has a verdict for every gate set that agrees with its assignments, and
|
|
172
|
+
* its witness holds every unassigned field `true`, which keeps it as small as one can be. So
|
|
173
|
+
* the cost follows what one tour and one entry read, not the size of `NavGates`.
|
|
174
|
+
*
|
|
175
|
+
* This is sound only if the predicates (the tour's `requires`, the step's `when` and
|
|
176
|
+
* `navItemVisible`) are PURE and read each gate as a plain property (`g.field`). A read the
|
|
177
|
+
* proxy cannot see would let a field the verdict depends on stay held `true`, so the proxy
|
|
178
|
+
* refuses `in`, the key list and property descriptors loudly.
|
|
178
179
|
*/
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
180
|
+
function navRequirementDrift(pair: ReturnType<typeof navAnchoredSteps>[number]): {
|
|
181
|
+
witness: NavGates | undefined
|
|
182
|
+
branched: ReadonlySet<keyof NavGates>
|
|
183
|
+
} {
|
|
184
|
+
const assigned: Partial<Record<keyof NavGates, boolean>> = {}
|
|
185
|
+
const branched = new Set<keyof NavGates>()
|
|
186
|
+
const refuse = (how: string) => () => {
|
|
187
|
+
throw new Error(`a nav predicate read the gates through ${how}; read each field directly`)
|
|
188
|
+
}
|
|
189
|
+
const gates = new Proxy(
|
|
190
|
+
{ ...ALL_GATES },
|
|
191
|
+
{
|
|
192
|
+
get(target, key) {
|
|
193
|
+
if (typeof key !== 'string' || !Object.hasOwn(target, key)) return Reflect.get(target, key)
|
|
194
|
+
const value = assigned[key as keyof NavGates]
|
|
195
|
+
if (value === undefined) throw new UnassignedGate(key as keyof NavGates)
|
|
196
|
+
return value
|
|
197
|
+
},
|
|
198
|
+
has: refuse('`in`'),
|
|
199
|
+
ownKeys: refuse('the key list'),
|
|
200
|
+
getOwnPropertyDescriptor: refuse('a property descriptor'),
|
|
201
|
+
},
|
|
202
|
+
) as NavGates
|
|
203
|
+
|
|
204
|
+
let witness: NavGates | undefined
|
|
205
|
+
let fewestAbsent = Number.POSITIVE_INFINITY
|
|
206
|
+
const explore = (): void => {
|
|
207
|
+
let verdict: boolean
|
|
208
|
+
try {
|
|
209
|
+
verdict = drifts(pair, gates)
|
|
210
|
+
} catch (error) {
|
|
211
|
+
if (!(error instanceof UnassignedGate)) throw error
|
|
212
|
+
branched.add(error.key)
|
|
213
|
+
for (const value of [false, true]) {
|
|
214
|
+
assigned[error.key] = value
|
|
215
|
+
explore()
|
|
194
216
|
}
|
|
217
|
+
delete assigned[error.key]
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (!verdict) return
|
|
221
|
+
const absent = Object.values(assigned).filter((value) => value === false).length
|
|
222
|
+
if (absent < fewestAbsent) {
|
|
223
|
+
fewestAbsent = absent
|
|
224
|
+
witness = { ...ALL_GATES, ...assigned }
|
|
195
225
|
}
|
|
196
226
|
}
|
|
197
|
-
|
|
227
|
+
explore()
|
|
228
|
+
return { witness, branched }
|
|
198
229
|
}
|
|
199
230
|
|
|
200
231
|
/** Every static test id this layer actually renders. */
|
|
@@ -307,14 +338,14 @@ describe('the built-in tutorial tour catalog', () => {
|
|
|
307
338
|
const pairs = navAnchoredSteps()
|
|
308
339
|
// Guard the guard, twice over. A pairing that matched nothing would pass vacuously, and
|
|
309
340
|
// there is one per tour that opens a sidebar surface: `add-service` plus every platform
|
|
310
|
-
// tour. And a
|
|
311
|
-
//
|
|
341
|
+
// tour. And a search that branched on no field would pass without varying anything:
|
|
342
|
+
// `navItemVisible` always reads `advancedMode` or `fullSurface`, so every pair must branch.
|
|
312
343
|
expect(pairs.length).toBeGreaterThanOrEqual(8)
|
|
313
|
-
|
|
344
|
+
const results = pairs.map(navRequirementDrift)
|
|
345
|
+
expect(results.filter((result) => result.branched.size === 0)).toEqual([])
|
|
314
346
|
|
|
315
|
-
const witnesses = navRequirementDrifts(pairs)
|
|
316
347
|
const drifted = pairs.flatMap((pair, index) => {
|
|
317
|
-
const witness =
|
|
348
|
+
const witness = results[index]!.witness
|
|
318
349
|
if (witness === undefined) return []
|
|
319
350
|
return [`${pair.label} is offered without ${absentGates(witness).join(' / ')}`]
|
|
320
351
|
})
|
|
@@ -481,7 +512,7 @@ describe('tour availability across the catalog', () => {
|
|
|
481
512
|
|
|
482
513
|
it('holds the interface tier as a requirement of the tours whose surface hides in basic', () => {
|
|
483
514
|
// Two different reasons, one requirement. `share-services` clicks a nav entry marked
|
|
484
|
-
// `advanced: true` (which `
|
|
515
|
+
// `advanced: true` (which `navRequirementDrift` also enforces); `panel-reviews` clicks a
|
|
485
516
|
// BASIC entry whose consensus section renders only in advanced mode or once a group exists,
|
|
486
517
|
// one level below anything the nav guard can see. Basic is the shipped default, so without
|
|
487
518
|
// this both would be offered to nearly every user and find nothing.
|
|
@@ -10,6 +10,7 @@ function key(over: Partial<PublicApiKey> = {}): PublicApiKey {
|
|
|
10
10
|
accountId: 'acc1',
|
|
11
11
|
workspaceId: 'ws1',
|
|
12
12
|
workspaceIds: ['ws1'],
|
|
13
|
+
followsMemberships: false,
|
|
13
14
|
label: 'CI',
|
|
14
15
|
scope: 'write',
|
|
15
16
|
createdByUserId: null,
|
|
@@ -105,10 +106,20 @@ describe('publicApiKeys store', () => {
|
|
|
105
106
|
|
|
106
107
|
const store = usePublicApiKeysStore()
|
|
107
108
|
await store.create('ci', 'write')
|
|
108
|
-
expect(body).toHaveBeenCalledWith({
|
|
109
|
+
expect(body).toHaveBeenCalledWith({
|
|
110
|
+
label: 'ci',
|
|
111
|
+
scope: 'write',
|
|
112
|
+
actsAsSelf: false,
|
|
113
|
+
followsMemberships: false,
|
|
114
|
+
})
|
|
109
115
|
|
|
110
116
|
await store.create('mine', 'write', true)
|
|
111
|
-
expect(body).toHaveBeenCalledWith({
|
|
117
|
+
expect(body).toHaveBeenCalledWith({
|
|
118
|
+
label: 'mine',
|
|
119
|
+
scope: 'write',
|
|
120
|
+
actsAsSelf: true,
|
|
121
|
+
followsMemberships: false,
|
|
122
|
+
})
|
|
112
123
|
})
|
|
113
124
|
|
|
114
125
|
it('sends a reach only when one was chosen, so the default stays this workspace alone', async () => {
|
|
@@ -119,13 +130,19 @@ describe('publicApiKeys store', () => {
|
|
|
119
130
|
|
|
120
131
|
const store = usePublicApiKeysStore()
|
|
121
132
|
await store.create('ci', 'read')
|
|
122
|
-
expect(body).toHaveBeenLastCalledWith({
|
|
133
|
+
expect(body).toHaveBeenLastCalledWith({
|
|
134
|
+
label: 'ci',
|
|
135
|
+
scope: 'read',
|
|
136
|
+
actsAsSelf: false,
|
|
137
|
+
followsMemberships: false,
|
|
138
|
+
})
|
|
123
139
|
|
|
124
140
|
await store.create('all', 'read', false, null)
|
|
125
141
|
expect(body).toHaveBeenLastCalledWith({
|
|
126
142
|
label: 'all',
|
|
127
143
|
scope: 'read',
|
|
128
144
|
actsAsSelf: false,
|
|
145
|
+
followsMemberships: false,
|
|
129
146
|
workspaceIds: null,
|
|
130
147
|
})
|
|
131
148
|
|
|
@@ -134,8 +151,23 @@ describe('publicApiKeys store', () => {
|
|
|
134
151
|
label: 'two',
|
|
135
152
|
scope: 'read',
|
|
136
153
|
actsAsSelf: false,
|
|
154
|
+
followsMemberships: false,
|
|
137
155
|
workspaceIds: ['ws1', 'ws2'],
|
|
138
156
|
})
|
|
157
|
+
|
|
158
|
+
// A key that follows its person's memberships names no workspaces, and lists here at once.
|
|
159
|
+
body.mockResolvedValueOnce({
|
|
160
|
+
key: key({ id: 'pak_follow', workspaceIds: [], followsMemberships: true }),
|
|
161
|
+
secret: 's',
|
|
162
|
+
})
|
|
163
|
+
await store.create('mine', 'decide', true, 'memberships')
|
|
164
|
+
expect(body).toHaveBeenLastCalledWith({
|
|
165
|
+
label: 'mine',
|
|
166
|
+
scope: 'decide',
|
|
167
|
+
actsAsSelf: true,
|
|
168
|
+
followsMemberships: true,
|
|
169
|
+
})
|
|
170
|
+
expect(store.keys.map((k) => k.id)).toContain('pak_follow')
|
|
139
171
|
})
|
|
140
172
|
|
|
141
173
|
it('revoke drops the key from the list', async () => {
|
|
@@ -59,14 +59,15 @@ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
|
|
|
59
59
|
* it starts can unlock them with the password sent on that call. Passed through rather than
|
|
60
60
|
* defaulted here: the server writes the id off the session, so this is only ever a yes/no.
|
|
61
61
|
*
|
|
62
|
-
* `
|
|
63
|
-
*
|
|
62
|
+
* `reach` is omitted for this workspace alone, `null` for every workspace in the account, a list,
|
|
63
|
+
* or `'memberships'` for whatever the person can act in on each call (a bound key only). A list
|
|
64
|
+
* or `null` needs an account admin; following memberships does not.
|
|
64
65
|
*/
|
|
65
66
|
async function create(
|
|
66
67
|
label: string,
|
|
67
68
|
scope: PublicApiScope,
|
|
68
69
|
actsAsSelf = false,
|
|
69
|
-
|
|
70
|
+
reach?: string[] | null | 'memberships',
|
|
70
71
|
): Promise<CreatedPublicApiKey> {
|
|
71
72
|
const ws = useWorkspaceStore()
|
|
72
73
|
const workspaceId = ws.requireId()
|
|
@@ -74,14 +75,17 @@ export const usePublicApiKeysStore = defineStore('publicApiKeys', () => {
|
|
|
74
75
|
label,
|
|
75
76
|
scope,
|
|
76
77
|
actsAsSelf,
|
|
77
|
-
|
|
78
|
+
followsMemberships: reach === 'memberships',
|
|
79
|
+
...(reach === undefined || reach === 'memberships' ? {} : { workspaceIds: reach }),
|
|
78
80
|
})
|
|
79
81
|
// Prepend: the backend lists newest-first, so the freshly minted key belongs at the
|
|
80
82
|
// top — matching the order a subsequent `load()` would produce. A key picked to reach only
|
|
81
83
|
// other workspaces is not in this board's list, and revoking it from here would be refused as
|
|
82
84
|
// a no-op, so it is not shown.
|
|
83
85
|
const reaches =
|
|
84
|
-
created.key.
|
|
86
|
+
created.key.followsMemberships ||
|
|
87
|
+
created.key.workspaceIds === null ||
|
|
88
|
+
created.key.workspaceIds.includes(workspaceId)
|
|
85
89
|
if (reaches) keys.value = [created.key, ...keys.value]
|
|
86
90
|
available.value = true
|
|
87
91
|
return created
|
package/i18n/locales/de.json
CHANGED
|
@@ -848,6 +848,7 @@
|
|
|
848
848
|
"boundToYou": "Ihr Abonnement",
|
|
849
849
|
"boundToOther": "Abonnement von {user}",
|
|
850
850
|
"reachAll": "Alle Arbeitsbereiche",
|
|
851
|
+
"reachMemberships": "Folgt den Arbeitsbereichen seiner Person",
|
|
851
852
|
"reachCount": "{count} Arbeitsbereiche"
|
|
852
853
|
},
|
|
853
854
|
"add": {
|
|
@@ -869,6 +870,8 @@
|
|
|
869
870
|
"reachThis": "Nur dieser Arbeitsbereich",
|
|
870
871
|
"reachAll": "Alle Arbeitsbereiche des Kontos",
|
|
871
872
|
"reachSelected": "Ausgewählte Arbeitsbereiche",
|
|
873
|
+
"reachMemberships": "Meine Arbeitsbereiche (folgt meinen Mitgliedschaften)",
|
|
874
|
+
"reachMembershipsHelp": "Erreicht bei jedem Aufruf jeden Arbeitsbereich, in dem du handeln kannst: Beitritt oder Austritt ändert das, ohne neues Token. In jedem Arbeitsbereich kann es nicht mehr, als deine Rolle dort erlaubt.",
|
|
872
875
|
"reachPick": "Arbeitsbereiche auswählen"
|
|
873
876
|
},
|
|
874
877
|
"scopes": {
|
package/i18n/locales/en.json
CHANGED
|
@@ -3916,6 +3916,7 @@
|
|
|
3916
3916
|
"boundToYou": "Your subscription",
|
|
3917
3917
|
"boundToOther": "{user}'s subscription",
|
|
3918
3918
|
"reachAll": "All workspaces",
|
|
3919
|
+
"reachMemberships": "Follows its owner's workspaces",
|
|
3919
3920
|
"reachCount": "{count} workspaces"
|
|
3920
3921
|
},
|
|
3921
3922
|
"add": {
|
|
@@ -3937,6 +3938,8 @@
|
|
|
3937
3938
|
"reachThis": "This workspace only",
|
|
3938
3939
|
"reachAll": "Every workspace in the account",
|
|
3939
3940
|
"reachSelected": "Selected workspaces",
|
|
3941
|
+
"reachMemberships": "My workspaces (follows my memberships)",
|
|
3942
|
+
"reachMembershipsHelp": "Reaches every workspace you can act in, on each call: joining or leaving one changes it, with nothing to re-create. In each workspace it can do no more than your role there allows.",
|
|
3940
3943
|
"reachPick": "Choose workspaces"
|
|
3941
3944
|
},
|
|
3942
3945
|
"scopes": {
|
package/i18n/locales/es.json
CHANGED
|
@@ -3608,6 +3608,7 @@
|
|
|
3608
3608
|
"boundToYou": "Tu suscripción",
|
|
3609
3609
|
"boundToOther": "Suscripción de {user}",
|
|
3610
3610
|
"reachAll": "Todos los espacios de trabajo",
|
|
3611
|
+
"reachMemberships": "Sigue los espacios de trabajo de su persona",
|
|
3611
3612
|
"reachCount": "{count} espacios de trabajo"
|
|
3612
3613
|
},
|
|
3613
3614
|
"add": {
|
|
@@ -3629,6 +3630,8 @@
|
|
|
3629
3630
|
"reachThis": "Solo este espacio de trabajo",
|
|
3630
3631
|
"reachAll": "Todos los espacios de trabajo de la cuenta",
|
|
3631
3632
|
"reachSelected": "Espacios de trabajo seleccionados",
|
|
3633
|
+
"reachMemberships": "Mis espacios de trabajo (sigue mis membresías)",
|
|
3634
|
+
"reachMembershipsHelp": "Llega a todos los espacios de trabajo en los que puedes actuar, en cada llamada: unirte o salir de uno lo cambia, sin crear nada de nuevo. En cada uno no puede hacer más de lo que permite tu rol allí.",
|
|
3632
3635
|
"reachPick": "Elige espacios de trabajo"
|
|
3633
3636
|
},
|
|
3634
3637
|
"scopes": {
|
package/i18n/locales/fr.json
CHANGED
|
@@ -3608,6 +3608,7 @@
|
|
|
3608
3608
|
"boundToYou": "Votre abonnement",
|
|
3609
3609
|
"boundToOther": "Abonnement de {user}",
|
|
3610
3610
|
"reachAll": "Tous les espaces de travail",
|
|
3611
|
+
"reachMemberships": "Suit les espaces de travail de sa personne",
|
|
3611
3612
|
"reachCount": "{count} espaces de travail"
|
|
3612
3613
|
},
|
|
3613
3614
|
"add": {
|
|
@@ -3629,6 +3630,8 @@
|
|
|
3629
3630
|
"reachThis": "Cet espace de travail uniquement",
|
|
3630
3631
|
"reachAll": "Tous les espaces de travail du compte",
|
|
3631
3632
|
"reachSelected": "Espaces de travail sélectionnés",
|
|
3633
|
+
"reachMemberships": "Mes espaces de travail (suit mes adhésions)",
|
|
3634
|
+
"reachMembershipsHelp": "Atteint à chaque appel chaque espace de travail où vous pouvez agir : en rejoindre ou en quitter un le change, sans rien recréer. Dans chacun, il ne peut pas faire plus que votre rôle ne le permet.",
|
|
3632
3635
|
"reachPick": "Choisir des espaces de travail"
|
|
3633
3636
|
},
|
|
3634
3637
|
"scopes": {
|
package/i18n/locales/he.json
CHANGED
|
@@ -3749,6 +3749,7 @@
|
|
|
3749
3749
|
"boundToYou": "המנוי שלך",
|
|
3750
3750
|
"boundToOther": "המנוי של {user}",
|
|
3751
3751
|
"reachAll": "כל סביבות העבודה",
|
|
3752
|
+
"reachMemberships": "עוקב אחרי סביבות העבודה של בעליו",
|
|
3752
3753
|
"reachCount": "{count} סביבות עבודה"
|
|
3753
3754
|
},
|
|
3754
3755
|
"add": {
|
|
@@ -3770,6 +3771,8 @@
|
|
|
3770
3771
|
"reachThis": "סביבת העבודה הזו בלבד",
|
|
3771
3772
|
"reachAll": "כל סביבות העבודה בחשבון",
|
|
3772
3773
|
"reachSelected": "סביבות עבודה נבחרות",
|
|
3774
|
+
"reachMemberships": "סביבות העבודה שלי (עוקב אחרי החברויות שלי)",
|
|
3775
|
+
"reachMembershipsHelp": "מגיע בכל קריאה לכל סביבת עבודה שבה אתה יכול לפעול: הצטרפות או עזיבה משנות זאת, בלי ליצור מחדש. בכל סביבה הוא לא יכול יותר ממה שהתפקיד שלך שם מאפשר.",
|
|
3773
3776
|
"reachPick": "בחירת סביבות עבודה"
|
|
3774
3777
|
},
|
|
3775
3778
|
"scopes": {
|
package/i18n/locales/it.json
CHANGED
|
@@ -848,6 +848,7 @@
|
|
|
848
848
|
"boundToYou": "Il tuo abbonamento",
|
|
849
849
|
"boundToOther": "Abbonamento di {user}",
|
|
850
850
|
"reachAll": "Tutti gli spazi di lavoro",
|
|
851
|
+
"reachMemberships": "Segue gli spazi di lavoro della sua persona",
|
|
851
852
|
"reachCount": "{count} spazi di lavoro"
|
|
852
853
|
},
|
|
853
854
|
"add": {
|
|
@@ -869,6 +870,8 @@
|
|
|
869
870
|
"reachThis": "Solo questo spazio di lavoro",
|
|
870
871
|
"reachAll": "Tutti gli spazi di lavoro dell’account",
|
|
871
872
|
"reachSelected": "Spazi di lavoro selezionati",
|
|
873
|
+
"reachMemberships": "I miei spazi di lavoro (segue le mie appartenenze)",
|
|
874
|
+
"reachMembershipsHelp": "Raggiunge a ogni chiamata ogni spazio di lavoro in cui puoi agire: entrarne o uscirne lo cambia, senza ricreare nulla. In ciascuno non può fare più di quanto consente il tuo ruolo lì.",
|
|
872
875
|
"reachPick": "Scegli gli spazi di lavoro"
|
|
873
876
|
},
|
|
874
877
|
"scopes": {
|
package/i18n/locales/ja.json
CHANGED
|
@@ -3749,6 +3749,7 @@
|
|
|
3749
3749
|
"boundToYou": "あなたのサブスクリプション",
|
|
3750
3750
|
"boundToOther": "{user} のサブスクリプション",
|
|
3751
3751
|
"reachAll": "すべてのワークスペース",
|
|
3752
|
+
"reachMemberships": "所有者のワークスペースに追従",
|
|
3752
3753
|
"reachCount": "{count} 個のワークスペース"
|
|
3753
3754
|
},
|
|
3754
3755
|
"add": {
|
|
@@ -3770,6 +3771,8 @@
|
|
|
3770
3771
|
"reachThis": "このワークスペースのみ",
|
|
3771
3772
|
"reachAll": "アカウント内のすべてのワークスペース",
|
|
3772
3773
|
"reachSelected": "選択したワークスペース",
|
|
3774
|
+
"reachMemberships": "自分のワークスペース(メンバーシップに追従)",
|
|
3775
|
+
"reachMembershipsHelp": "呼び出しのたびに、あなたが操作できるすべてのワークスペースに届きます。参加や脱退で変わり、作り直しは不要です。各ワークスペースでは、そこでのあなたのロールが許す以上のことはできません。",
|
|
3773
3776
|
"reachPick": "ワークスペースを選択"
|
|
3774
3777
|
},
|
|
3775
3778
|
"scopes": {
|
package/i18n/locales/pl.json
CHANGED
|
@@ -3608,6 +3608,7 @@
|
|
|
3608
3608
|
"boundToYou": "Twoja subskrypcja",
|
|
3609
3609
|
"boundToOther": "Subskrypcja użytkownika {user}",
|
|
3610
3610
|
"reachAll": "Wszystkie obszary robocze",
|
|
3611
|
+
"reachMemberships": "Śledzi obszary robocze swojej osoby",
|
|
3611
3612
|
"reachCount": "Obszary robocze: {count}"
|
|
3612
3613
|
},
|
|
3613
3614
|
"add": {
|
|
@@ -3629,6 +3630,8 @@
|
|
|
3629
3630
|
"reachThis": "Tylko ten obszar roboczy",
|
|
3630
3631
|
"reachAll": "Wszystkie obszary robocze konta",
|
|
3631
3632
|
"reachSelected": "Wybrane obszary robocze",
|
|
3633
|
+
"reachMemberships": "Moje obszary robocze (śledzi moje członkostwa)",
|
|
3634
|
+
"reachMembershipsHelp": "Przy każdym wywołaniu sięga do każdego obszaru roboczego, w którym możesz działać: dołączenie lub odejście to zmienia, bez tworzenia od nowa. W każdym może tyle, ile pozwala tam Twoja rola.",
|
|
3632
3635
|
"reachPick": "Wybierz obszary robocze"
|
|
3633
3636
|
},
|
|
3634
3637
|
"scopes": {
|
package/i18n/locales/tr.json
CHANGED
|
@@ -3749,6 +3749,7 @@
|
|
|
3749
3749
|
"boundToYou": "Aboneliğiniz",
|
|
3750
3750
|
"boundToOther": "{user} kullanıcısının aboneliği",
|
|
3751
3751
|
"reachAll": "Tüm çalışma alanları",
|
|
3752
|
+
"reachMemberships": "Sahibinin çalışma alanlarını izler",
|
|
3752
3753
|
"reachCount": "{count} çalışma alanı"
|
|
3753
3754
|
},
|
|
3754
3755
|
"add": {
|
|
@@ -3770,6 +3771,8 @@
|
|
|
3770
3771
|
"reachThis": "Yalnızca bu çalışma alanı",
|
|
3771
3772
|
"reachAll": "Hesaptaki tüm çalışma alanları",
|
|
3772
3773
|
"reachSelected": "Seçili çalışma alanları",
|
|
3774
|
+
"reachMemberships": "Çalışma alanlarım (üyeliklerimi izler)",
|
|
3775
|
+
"reachMembershipsHelp": "Her çağrıda işlem yapabildiğiniz her çalışma alanına ulaşır: birine katılmak ya da ayrılmak bunu değiştirir, yeniden oluşturmak gerekmez. Her birinde oradaki rolünüzün izin verdiğinden fazlasını yapamaz.",
|
|
3773
3776
|
"reachPick": "Çalışma alanlarını seçin"
|
|
3774
3777
|
},
|
|
3775
3778
|
"scopes": {
|
package/i18n/locales/uk.json
CHANGED
|
@@ -3608,6 +3608,7 @@
|
|
|
3608
3608
|
"boundToYou": "Ваша підписка",
|
|
3609
3609
|
"boundToOther": "Підписка {user}",
|
|
3610
3610
|
"reachAll": "Усі робочі простори",
|
|
3611
|
+
"reachMemberships": "Стежить за робочими просторами свого власника",
|
|
3611
3612
|
"reachCount": "Робочих просторів: {count}"
|
|
3612
3613
|
},
|
|
3613
3614
|
"add": {
|
|
@@ -3629,6 +3630,8 @@
|
|
|
3629
3630
|
"reachThis": "Лише цей робочий простір",
|
|
3630
3631
|
"reachAll": "Усі робочі простори облікового запису",
|
|
3631
3632
|
"reachSelected": "Вибрані робочі простори",
|
|
3633
|
+
"reachMemberships": "Мої робочі простори (стежить за моїми членствами)",
|
|
3634
|
+
"reachMembershipsHelp": "Під час кожного виклику сягає кожного робочого простору, де ви можете діяти: приєднання чи вихід змінюють це без повторного створення. У кожному він може не більше, ніж дозволяє ваша роль там.",
|
|
3632
3635
|
"reachPick": "Виберіть робочі простори"
|
|
3633
3636
|
},
|
|
3634
3637
|
"scopes": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.319.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"app",
|
|
12
|
+
"!app/stories",
|
|
13
|
+
"!app/**/*.stories.ts",
|
|
12
14
|
"i18n",
|
|
13
15
|
"nuxt.config.ts"
|
|
14
16
|
],
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
"access": "public"
|
|
19
21
|
},
|
|
20
22
|
"dependencies": {
|
|
21
|
-
"@cat-factory/contracts": "0.
|
|
23
|
+
"@cat-factory/contracts": "0.373.0",
|
|
22
24
|
"@modular-frontend/core": "0.6.0",
|
|
23
25
|
"@modular-vue/core": "^1.5.0",
|
|
24
26
|
"@modular-vue/journeys": "^1.4.0",
|
|
@@ -44,10 +46,12 @@
|
|
|
44
46
|
"wretch": "^3.0.9"
|
|
45
47
|
},
|
|
46
48
|
"devDependencies": {
|
|
49
|
+
"@storybook/vue3": "10.6.1",
|
|
47
50
|
"@toad-contracts/testing": "1.0.0",
|
|
48
51
|
"happy-dom": "^20.14.5",
|
|
49
52
|
"msw": "^2.15.0",
|
|
50
53
|
"nuxt": "^4.6.1",
|
|
54
|
+
"storybook": "10.6.1",
|
|
51
55
|
"typescript": "^6.0.3",
|
|
52
56
|
"vitest": "^4.1.11",
|
|
53
57
|
"vue-i18n-extract": "^2.0.7",
|