@cat-factory/app 0.129.0 → 0.131.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/board/AgentFailureCard.vue +3 -1
- package/app/components/board/AgentStopButton.vue +3 -0
- package/app/components/board/BoardCanvas.vue +16 -3
- package/app/components/board/RecurringPipelineModal.vue +7 -1
- package/app/components/board/nodes/BlockNode.vue +47 -41
- package/app/components/brainstorm/BrainstormWindow.vue +25 -5
- package/app/components/clarity/ClarityReviewWindow.vue +17 -4
- package/app/components/docs/DocInterviewWindow.vue +5 -1
- package/app/components/followUp/FollowUpWindow.vue +15 -1
- package/app/components/forkDecision/ForkDecisionWindow.vue +7 -2
- package/app/components/gates/GateResultView.vue +7 -1
- package/app/components/humanTest/HumanTestWindow.vue +11 -5
- package/app/components/layout/BoardSwitcher.vue +24 -13
- package/app/components/layout/BoardToolbar.vue +8 -4
- package/app/components/layout/CommandBar.vue +137 -123
- package/app/components/layout/NotificationsInbox.vue +5 -1
- package/app/components/layout/SideBar.vue +157 -115
- package/app/components/layout/SpendWarningBanner.vue +3 -0
- package/app/components/layout/WorkspaceMembersSettings.vue +256 -0
- package/app/components/panels/InspectorPanel.vue +23 -10
- package/app/components/panels/inspector/TaskExecution.vue +13 -4
- package/app/components/prReview/PrReviewWindow.vue +7 -3
- package/app/components/requirements/RequirementsReviewWindow.vue +33 -5
- package/app/components/settings/WorkspaceSettingsPanel.vue +20 -0
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +7 -3
- package/app/composables/api/workspaces.ts +31 -1
- package/app/composables/useBlockDeletion.ts +7 -0
- package/app/composables/useBlockDrag.ts +5 -0
- package/app/composables/useFrameResize.ts +4 -0
- package/app/composables/useWorkspaceAccess.spec.ts +102 -0
- package/app/composables/useWorkspaceAccess.ts +84 -0
- package/app/stores/workspace.spec.ts +19 -0
- package/app/stores/workspace.ts +27 -7
- package/app/stores/workspaceMembers.spec.ts +121 -0
- package/app/stores/workspaceMembers.ts +74 -0
- package/app/types/domain.ts +6 -0
- package/i18n/locales/de.json +41 -1
- package/i18n/locales/en.json +44 -1
- package/i18n/locales/es.json +41 -1
- package/i18n/locales/fr.json +41 -1
- package/i18n/locales/he.json +41 -1
- package/i18n/locales/it.json +41 -1
- package/i18n/locales/ja.json +41 -1
- package/i18n/locales/pl.json +41 -1
- package/i18n/locales/tr.json +41 -1
- package/i18n/locales/uk.json +41 -1
- package/package.json +1 -1
|
@@ -23,11 +23,30 @@ const accounts = useAccountsStore()
|
|
|
23
23
|
const auth = useAuthStore()
|
|
24
24
|
const providerConnections = useProviderConnectionsStore()
|
|
25
25
|
const ui = useUiStore()
|
|
26
|
+
const access = useWorkspaceAccess()
|
|
26
27
|
|
|
27
28
|
// The operator dashboard (deployment-level run health) is sensitive cross-workspace data,
|
|
28
29
|
// so it's shown only to an admin of the active account (matching the backend admin gate).
|
|
29
30
|
const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
|
|
30
31
|
|
|
32
|
+
// Workspace-RBAC nav gating: each management destination is shown only when the caller
|
|
33
|
+
// holds the permission its writes require (the SPA mirror of the admin-tier controller
|
|
34
|
+
// middleware — a member/viewer who'd only get a 403 never sees the entry). Board authoring
|
|
35
|
+
// (create/repos) needs `board.write`; connections/infra/sandbox/bootstrap need
|
|
36
|
+
// `integrations.manage`; workspace + model configuration and the fragment library need
|
|
37
|
+
// `settings.manage`. Kaizen and account settings are reads / account-scoped and stay.
|
|
38
|
+
// Absent access (dev-open) ⇒ every `can*` is true, so nothing is hidden.
|
|
39
|
+
const showCreate = computed(() => access.canWriteBoard.value)
|
|
40
|
+
const showAddFromRepo = computed(() => github.available && access.canWriteBoard.value)
|
|
41
|
+
const showBootstrap = computed(() => access.canManageIntegrations.value)
|
|
42
|
+
const showRepositories = computed(() => showAddFromRepo.value || showBootstrap.value)
|
|
43
|
+
const showIntegrationsHub = computed(() => access.canManageIntegrations.value)
|
|
44
|
+
const showSandbox = computed(() => access.canManageIntegrations.value)
|
|
45
|
+
// The Configuration section carries workspace/model settings (`settings.manage`) AND the
|
|
46
|
+
// account-scoped account-settings / operator-dashboard entries, so it shows when the caller
|
|
47
|
+
// can manage settings OR accounts are enabled (the account entries have their own gates).
|
|
48
|
+
const showConfiguration = computed(() => access.canManageSettings.value || accounts.enabled)
|
|
49
|
+
|
|
31
50
|
// The Infrastructure menu (agent-container execution + test environments) shows whenever the
|
|
32
51
|
// deployment reports its infrastructure capability — every facade populates `auth.infrastructure`
|
|
33
52
|
// (it drives the execution-backend selector), so there is always an execution + test-env backend
|
|
@@ -36,12 +55,18 @@ const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('a
|
|
|
36
55
|
// (somehow) omits the descriptor.
|
|
37
56
|
const showInfrastructure = computed(
|
|
38
57
|
() =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
// Provisioning/managing infrastructure is `integrations.manage`, so a member/viewer
|
|
59
|
+
// never sees the section (they'd only 403 on the writes inside it).
|
|
60
|
+
access.canManageIntegrations.value &&
|
|
61
|
+
(auth.infrastructure != null ||
|
|
62
|
+
auth.localMode?.enabled === true ||
|
|
63
|
+
providerConnections.isAvailable('runner-pool') ||
|
|
64
|
+
providerConnections.isAvailable('environment')),
|
|
43
65
|
)
|
|
44
66
|
|
|
67
|
+
// The prompt-fragment library is a `settings.manage` authoring surface.
|
|
68
|
+
const showWorkspaceContext = computed(() => library.available && access.canManageSettings.value)
|
|
69
|
+
|
|
45
70
|
// `isCompact` (< lg) is the breakpoint at which the navbar is an off-canvas drawer;
|
|
46
71
|
// above it the aside is static and the drawer flag is inert.
|
|
47
72
|
const { isCompact } = useViewport()
|
|
@@ -166,57 +191,65 @@ watch(
|
|
|
166
191
|
<UKbd value="⌘K" />
|
|
167
192
|
</button>
|
|
168
193
|
|
|
169
|
-
<
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
<template v-if="showCreate">
|
|
195
|
+
<USeparator />
|
|
196
|
+
<section>
|
|
197
|
+
<h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
198
|
+
{{ t('nav.create') }}
|
|
199
|
+
</h2>
|
|
200
|
+
<div class="space-y-1.5">
|
|
201
|
+
<UButton
|
|
202
|
+
block
|
|
203
|
+
color="primary"
|
|
204
|
+
variant="soft"
|
|
205
|
+
size="sm"
|
|
206
|
+
icon="i-lucide-workflow"
|
|
207
|
+
class="justify-start"
|
|
208
|
+
data-testid="nav-build-pipeline"
|
|
209
|
+
@click="ui.openBuilder()"
|
|
210
|
+
>
|
|
211
|
+
{{ t('nav.buildPipeline') }}
|
|
212
|
+
</UButton>
|
|
213
|
+
</div>
|
|
214
|
+
</section>
|
|
215
|
+
</template>
|
|
189
216
|
|
|
190
|
-
<
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
<template v-if="showRepositories">
|
|
218
|
+
<USeparator />
|
|
219
|
+
<section>
|
|
220
|
+
<h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
221
|
+
{{ t('nav.repositories') }}
|
|
222
|
+
</h2>
|
|
223
|
+
<div class="space-y-1.5">
|
|
224
|
+
<UButton
|
|
225
|
+
v-if="showAddFromRepo"
|
|
226
|
+
block
|
|
227
|
+
color="primary"
|
|
228
|
+
variant="soft"
|
|
229
|
+
size="sm"
|
|
230
|
+
icon="i-lucide-folder-git-2"
|
|
231
|
+
class="justify-start"
|
|
232
|
+
data-testid="nav-add-from-repo"
|
|
233
|
+
@click="ui.openAddService()"
|
|
234
|
+
>
|
|
235
|
+
{{ t('nav.addFromRepo') }}
|
|
236
|
+
</UButton>
|
|
237
|
+
<UButton
|
|
238
|
+
v-if="showBootstrap"
|
|
239
|
+
block
|
|
240
|
+
color="primary"
|
|
241
|
+
variant="soft"
|
|
242
|
+
size="sm"
|
|
243
|
+
icon="i-lucide-git-branch-plus"
|
|
244
|
+
class="justify-start"
|
|
245
|
+
data-testid="nav-bootstrap-repo"
|
|
246
|
+
@click="ui.openBootstrap()"
|
|
247
|
+
>
|
|
248
|
+
{{ t('nav.bootstrapRepo') }}
|
|
249
|
+
</UButton>
|
|
250
|
+
</div>
|
|
251
|
+
</section>
|
|
252
|
+
</template>
|
|
220
253
|
|
|
221
254
|
<USeparator />
|
|
222
255
|
|
|
@@ -229,12 +262,14 @@ watch(
|
|
|
229
262
|
this single button — the hub modal lists them grouped (source control,
|
|
230
263
|
communication, documents, trackers, observability, model providers). -->
|
|
231
264
|
<UButton
|
|
265
|
+
v-if="showIntegrationsHub"
|
|
232
266
|
block
|
|
233
267
|
color="primary"
|
|
234
268
|
variant="soft"
|
|
235
269
|
size="sm"
|
|
236
270
|
icon="i-lucide-blocks"
|
|
237
271
|
class="justify-start"
|
|
272
|
+
data-testid="nav-integrations"
|
|
238
273
|
@click="ui.openIntegrations()"
|
|
239
274
|
>
|
|
240
275
|
{{ t('nav.integrations') }}
|
|
@@ -242,6 +277,7 @@ watch(
|
|
|
242
277
|
<!-- The Sandbox: try prompt versions/models against graded fixtures, off to the
|
|
243
278
|
side of the board. Opens the on-demand testing window. -->
|
|
244
279
|
<UButton
|
|
280
|
+
v-if="showSandbox"
|
|
245
281
|
block
|
|
246
282
|
color="primary"
|
|
247
283
|
variant="soft"
|
|
@@ -252,7 +288,8 @@ watch(
|
|
|
252
288
|
>
|
|
253
289
|
{{ t('nav.sandbox') }}
|
|
254
290
|
</UButton>
|
|
255
|
-
<!-- The Kaizen screen: grading history + verified prompt/agent/model combos.
|
|
291
|
+
<!-- The Kaizen screen: grading history + verified prompt/agent/model combos. A
|
|
292
|
+
read surface (grading history), so it stays visible to every resolved role. -->
|
|
256
293
|
<UButton
|
|
257
294
|
block
|
|
258
295
|
color="primary"
|
|
@@ -307,7 +344,7 @@ watch(
|
|
|
307
344
|
</section>
|
|
308
345
|
</template>
|
|
309
346
|
|
|
310
|
-
<template v-if="
|
|
347
|
+
<template v-if="showWorkspaceContext">
|
|
311
348
|
<USeparator />
|
|
312
349
|
<section>
|
|
313
350
|
<h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
@@ -327,66 +364,71 @@ watch(
|
|
|
327
364
|
</section>
|
|
328
365
|
</template>
|
|
329
366
|
|
|
330
|
-
<
|
|
331
|
-
|
|
332
|
-
<
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
367
|
+
<template v-if="showConfiguration">
|
|
368
|
+
<USeparator />
|
|
369
|
+
<section>
|
|
370
|
+
<h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
371
|
+
{{ t('nav.configuration') }}
|
|
372
|
+
</h2>
|
|
373
|
+
<div class="space-y-1.5">
|
|
374
|
+
<!-- Merge thresholds, issue writeback and default service best practices are
|
|
375
|
+
now tabs inside Workspace settings. `settings.manage` — hidden for members/viewers. -->
|
|
376
|
+
<UButton
|
|
377
|
+
v-if="access.canManageSettings.value"
|
|
378
|
+
block
|
|
379
|
+
color="primary"
|
|
380
|
+
variant="soft"
|
|
381
|
+
size="sm"
|
|
382
|
+
icon="i-lucide-sliders-horizontal"
|
|
383
|
+
class="justify-start"
|
|
384
|
+
data-testid="nav-workspace-settings"
|
|
385
|
+
@click="ui.openWorkspaceSettings()"
|
|
386
|
+
>
|
|
387
|
+
{{ t('nav.workspaceSettings') }}
|
|
388
|
+
</UButton>
|
|
389
|
+
<UButton
|
|
390
|
+
v-if="access.canManageSettings.value"
|
|
391
|
+
block
|
|
392
|
+
color="primary"
|
|
393
|
+
variant="soft"
|
|
394
|
+
size="sm"
|
|
395
|
+
icon="i-lucide-cpu"
|
|
396
|
+
class="justify-start"
|
|
397
|
+
@click="ui.openModelConfig()"
|
|
398
|
+
>
|
|
399
|
+
{{ t('nav.modelConfiguration') }}
|
|
400
|
+
</UButton>
|
|
401
|
+
<!-- Account & team: members + roles, invitations, email sender, account API keys.
|
|
361
402
|
Shown once accounts (auth) are enabled. -->
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
403
|
+
<UButton
|
|
404
|
+
v-if="accounts.enabled"
|
|
405
|
+
block
|
|
406
|
+
color="primary"
|
|
407
|
+
variant="soft"
|
|
408
|
+
size="sm"
|
|
409
|
+
icon="i-lucide-users"
|
|
410
|
+
class="justify-start"
|
|
411
|
+
@click="ui.openAccountSettings()"
|
|
412
|
+
>
|
|
413
|
+
{{ t('nav.accountSettings') }}
|
|
414
|
+
</UButton>
|
|
415
|
+
<!-- Platform observability: deployment-level run health. Admin-only, like the backend gate. -->
|
|
416
|
+
<UButton
|
|
417
|
+
v-if="accounts.enabled && isAccountAdmin"
|
|
418
|
+
block
|
|
419
|
+
color="primary"
|
|
420
|
+
variant="soft"
|
|
421
|
+
size="sm"
|
|
422
|
+
icon="i-lucide-gauge"
|
|
423
|
+
class="justify-start"
|
|
424
|
+
data-testid="nav-operator-dashboard"
|
|
425
|
+
@click="ui.openOperatorDashboard()"
|
|
426
|
+
>
|
|
427
|
+
{{ t('nav.operatorDashboard') }}
|
|
428
|
+
</UButton>
|
|
429
|
+
</div>
|
|
430
|
+
</section>
|
|
431
|
+
</template>
|
|
390
432
|
</div>
|
|
391
433
|
|
|
392
434
|
<div class="mt-auto space-y-2">
|
|
@@ -3,6 +3,7 @@ import { computed } from 'vue'
|
|
|
3
3
|
|
|
4
4
|
const { t, n } = useI18n()
|
|
5
5
|
const workspace = useWorkspaceStore()
|
|
6
|
+
const access = useWorkspaceAccess()
|
|
6
7
|
|
|
7
8
|
const spend = computed(() => workspace.spend)
|
|
8
9
|
/** Show the large warning only once the budget has been reached. */
|
|
@@ -80,6 +81,8 @@ async function resume() {
|
|
|
80
81
|
variant="solid"
|
|
81
82
|
icon="i-lucide-play"
|
|
82
83
|
:loading="resuming"
|
|
84
|
+
:disabled="!access.canExecuteRuns.value"
|
|
85
|
+
:title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
|
|
83
86
|
@click="resume"
|
|
84
87
|
>
|
|
85
88
|
{{ t('layout.spendWarningBanner.resume') }}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
4
|
+
import type { WorkspaceAccessMode, WorkspaceRole } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
// Workspace-membership management (workspace-rbac initiative, slice 9). The tier BELOW
|
|
7
|
+
// account tenancy: an account admin restricts a board to an explicit member list (with
|
|
8
|
+
// per-member workspace roles), while an unrestricted (`account`) board keeps the legacy
|
|
9
|
+
// "every account member sees it" behaviour. Every mutation here requires `members.manage`
|
|
10
|
+
// server-side, so the hosting Members tab only renders for a workspace admin.
|
|
11
|
+
//
|
|
12
|
+
// The add picker is sourced from the OWNING account's roster — a contractor joins the
|
|
13
|
+
// account first (the account invitation flow), then gets scoped to a board here.
|
|
14
|
+
const props = defineProps<{ workspaceId: string }>()
|
|
15
|
+
|
|
16
|
+
const workspace = useWorkspaceStore()
|
|
17
|
+
const members = useWorkspaceMembersStore()
|
|
18
|
+
const accounts = useAccountsStore()
|
|
19
|
+
const auth = useAuthStore()
|
|
20
|
+
const toast = useToast()
|
|
21
|
+
const { t } = useI18n()
|
|
22
|
+
const { confirmAction, toastDone } = useConfirmAction()
|
|
23
|
+
|
|
24
|
+
const busy = ref(false)
|
|
25
|
+
|
|
26
|
+
const ROLE_ITEMS = computed<{ label: string; value: WorkspaceRole }[]>(() => [
|
|
27
|
+
{ label: t('layout.workspaceMembers.roles.admin'), value: 'admin' },
|
|
28
|
+
{ label: t('layout.workspaceMembers.roles.member'), value: 'member' },
|
|
29
|
+
{ label: t('layout.workspaceMembers.roles.viewer'), value: 'viewer' },
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
/** The active board's row (carries `accountId` + `accessMode`). */
|
|
33
|
+
const board = computed(() => workspace.workspaces.find((w) => w.id === props.workspaceId) ?? null)
|
|
34
|
+
/** The owning account id — the source of the add-member picker roster. */
|
|
35
|
+
const accountId = computed(() => board.value?.accountId ?? null)
|
|
36
|
+
/** Whether the board is limited to its explicit roster (vs every account member). */
|
|
37
|
+
const restricted = computed(() => (board.value?.accessMode ?? 'account') === 'restricted')
|
|
38
|
+
/**
|
|
39
|
+
* True once THIS board's roster is the committed one — the store guards a slow/superseded
|
|
40
|
+
* load so switching boards never renders the previous board's members. Until then the
|
|
41
|
+
* roster + picker show a loading state rather than stale rows.
|
|
42
|
+
*/
|
|
43
|
+
const rosterReady = computed(() => members.loadedFor === props.workspaceId)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Account members not already on the board — the add picker's options. The service
|
|
47
|
+
* rejects a non-account-member, so the picker never offers one; already-scoped members
|
|
48
|
+
* are filtered out so the list only offers genuine additions.
|
|
49
|
+
*/
|
|
50
|
+
const candidates = computed(() => {
|
|
51
|
+
const existing = new Set(members.members.map((m) => m.userId))
|
|
52
|
+
return accounts.members
|
|
53
|
+
.filter((m) => !existing.has(m.userId))
|
|
54
|
+
.map((m) => ({ label: m.name || m.email || m.userId, value: m.userId }))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const addUserId = ref<string | undefined>(undefined)
|
|
58
|
+
const addRole = ref<WorkspaceRole>('member')
|
|
59
|
+
|
|
60
|
+
function notifyError(title: string, e: unknown) {
|
|
61
|
+
toast.add({
|
|
62
|
+
title,
|
|
63
|
+
description: apiErrorEnvelope(e)?.message ?? (e instanceof Error ? e.message : String(e)),
|
|
64
|
+
icon: 'i-lucide-triangle-alert',
|
|
65
|
+
color: 'error',
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadAll(workspaceId: string) {
|
|
70
|
+
try {
|
|
71
|
+
const jobs: Promise<unknown>[] = [members.load(workspaceId)]
|
|
72
|
+
// The picker needs the owning account's roster; a legacy board with no account (the
|
|
73
|
+
// service auto-heals it on the first write) simply shows no picker candidates.
|
|
74
|
+
if (accountId.value) jobs.push(accounts.loadRoster(accountId.value))
|
|
75
|
+
await Promise.all(jobs)
|
|
76
|
+
} catch (e) {
|
|
77
|
+
notifyError(t('layout.workspaceMembers.errors.load'), e)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
onMounted(() => void loadAll(props.workspaceId))
|
|
82
|
+
watch(
|
|
83
|
+
() => props.workspaceId,
|
|
84
|
+
(id) => {
|
|
85
|
+
if (id) void loadAll(id)
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async function setAccessMode(next: boolean) {
|
|
90
|
+
const mode: WorkspaceAccessMode = next ? 'restricted' : 'account'
|
|
91
|
+
busy.value = true
|
|
92
|
+
try {
|
|
93
|
+
await members.setAccessMode(props.workspaceId, mode)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
notifyError(t('layout.workspaceMembers.errors.accessMode'), e)
|
|
96
|
+
} finally {
|
|
97
|
+
busy.value = false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function updateRole(userId: string, role: WorkspaceRole) {
|
|
102
|
+
busy.value = true
|
|
103
|
+
try {
|
|
104
|
+
await members.setRole(props.workspaceId, userId, role)
|
|
105
|
+
} catch (e) {
|
|
106
|
+
notifyError(t('layout.workspaceMembers.errors.setRole'), e)
|
|
107
|
+
} finally {
|
|
108
|
+
busy.value = false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function addMember() {
|
|
113
|
+
const userId = addUserId.value
|
|
114
|
+
if (!userId) return
|
|
115
|
+
busy.value = true
|
|
116
|
+
try {
|
|
117
|
+
await members.add(props.workspaceId, userId, addRole.value)
|
|
118
|
+
addUserId.value = undefined
|
|
119
|
+
addRole.value = 'member'
|
|
120
|
+
toast.add({ title: t('layout.workspaceMembers.add.added'), icon: 'i-lucide-user-plus' })
|
|
121
|
+
} catch (e) {
|
|
122
|
+
notifyError(t('layout.workspaceMembers.errors.add'), e)
|
|
123
|
+
} finally {
|
|
124
|
+
busy.value = false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function removeMember(userId: string, name: string) {
|
|
129
|
+
if (!(await confirmAction('remove', name))) return
|
|
130
|
+
busy.value = true
|
|
131
|
+
try {
|
|
132
|
+
await members.remove(props.workspaceId, userId)
|
|
133
|
+
toastDone('remove', name)
|
|
134
|
+
} catch (e) {
|
|
135
|
+
notifyError(t('layout.workspaceMembers.errors.remove'), e)
|
|
136
|
+
} finally {
|
|
137
|
+
busy.value = false
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Label a member by their display details; badges the signed-in caller as "you". */
|
|
142
|
+
function memberLabel(userId: string, name?: string | null, email?: string | null): string {
|
|
143
|
+
const base = name || email || userId
|
|
144
|
+
return userId === auth.user?.id ? t('layout.workspaceMembers.you', { name: base }) : base
|
|
145
|
+
}
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<template>
|
|
149
|
+
<div class="space-y-6 text-sm" data-testid="workspace-members-settings">
|
|
150
|
+
<!-- access mode -->
|
|
151
|
+
<section class="rounded-md border border-slate-800 bg-slate-800/40 p-4">
|
|
152
|
+
<div class="flex items-start justify-between gap-4">
|
|
153
|
+
<div>
|
|
154
|
+
<h3 class="mb-1 font-semibold text-white">
|
|
155
|
+
{{ t('layout.workspaceMembers.accessMode.title') }}
|
|
156
|
+
</h3>
|
|
157
|
+
<p class="text-slate-400">
|
|
158
|
+
{{
|
|
159
|
+
restricted
|
|
160
|
+
? t('layout.workspaceMembers.accessMode.restrictedHint')
|
|
161
|
+
: t('layout.workspaceMembers.accessMode.accountHint')
|
|
162
|
+
}}
|
|
163
|
+
</p>
|
|
164
|
+
</div>
|
|
165
|
+
<label class="flex shrink-0 items-center gap-2">
|
|
166
|
+
<USwitch
|
|
167
|
+
:model-value="restricted"
|
|
168
|
+
:disabled="busy"
|
|
169
|
+
size="sm"
|
|
170
|
+
data-testid="workspace-restrict-toggle"
|
|
171
|
+
@update:model-value="setAccessMode"
|
|
172
|
+
/>
|
|
173
|
+
<span class="text-xs text-slate-300">
|
|
174
|
+
{{ t('layout.workspaceMembers.accessMode.restrictToggle') }}
|
|
175
|
+
</span>
|
|
176
|
+
</label>
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
<!-- roster -->
|
|
181
|
+
<section>
|
|
182
|
+
<h3 class="mb-2 font-semibold text-white">{{ t('layout.workspaceMembers.roster.title') }}</h3>
|
|
183
|
+
<p v-if="!rosterReady" class="text-slate-500">{{ t('common.loading') }}</p>
|
|
184
|
+
<ul v-else class="space-y-1" data-testid="workspace-members-roster">
|
|
185
|
+
<li
|
|
186
|
+
v-for="m in members.members"
|
|
187
|
+
:key="m.userId"
|
|
188
|
+
class="flex items-center justify-between gap-2 rounded-md bg-slate-800/40 px-2 py-1"
|
|
189
|
+
data-testid="workspace-member-row"
|
|
190
|
+
>
|
|
191
|
+
<span class="truncate">{{ memberLabel(m.userId, m.name, m.email) }}</span>
|
|
192
|
+
<span class="flex shrink-0 items-center gap-2">
|
|
193
|
+
<USelect
|
|
194
|
+
:model-value="m.role"
|
|
195
|
+
:items="ROLE_ITEMS"
|
|
196
|
+
value-key="value"
|
|
197
|
+
size="xs"
|
|
198
|
+
class="w-32"
|
|
199
|
+
:disabled="busy"
|
|
200
|
+
data-testid="workspace-member-role"
|
|
201
|
+
@update:model-value="(r: WorkspaceRole) => updateRole(m.userId, r)"
|
|
202
|
+
/>
|
|
203
|
+
<UButton
|
|
204
|
+
size="xs"
|
|
205
|
+
color="error"
|
|
206
|
+
variant="ghost"
|
|
207
|
+
icon="i-lucide-user-minus"
|
|
208
|
+
:disabled="busy"
|
|
209
|
+
:aria-label="t('layout.workspaceMembers.roster.remove')"
|
|
210
|
+
data-testid="workspace-member-remove"
|
|
211
|
+
@click="removeMember(m.userId, memberLabel(m.userId, m.name, m.email))"
|
|
212
|
+
/>
|
|
213
|
+
</span>
|
|
214
|
+
</li>
|
|
215
|
+
<li v-if="members.members.length === 0" class="text-slate-500">
|
|
216
|
+
{{ t('layout.workspaceMembers.roster.empty') }}
|
|
217
|
+
</li>
|
|
218
|
+
</ul>
|
|
219
|
+
</section>
|
|
220
|
+
|
|
221
|
+
<!-- add member -->
|
|
222
|
+
<section>
|
|
223
|
+
<h3 class="mb-2 font-semibold text-white">{{ t('layout.workspaceMembers.add.title') }}</h3>
|
|
224
|
+
<p v-if="!accountId" class="text-slate-500">
|
|
225
|
+
{{ t('layout.workspaceMembers.add.noAccount') }}
|
|
226
|
+
</p>
|
|
227
|
+
<template v-else>
|
|
228
|
+
<form class="flex gap-2" @submit.prevent="addMember">
|
|
229
|
+
<USelect
|
|
230
|
+
v-model="addUserId"
|
|
231
|
+
:items="candidates"
|
|
232
|
+
value-key="value"
|
|
233
|
+
:placeholder="t('layout.workspaceMembers.add.selectMember')"
|
|
234
|
+
:disabled="candidates.length === 0"
|
|
235
|
+
class="flex-1"
|
|
236
|
+
data-testid="workspace-member-add-select"
|
|
237
|
+
/>
|
|
238
|
+
<USelect v-model="addRole" :items="ROLE_ITEMS" value-key="value" class="w-32" />
|
|
239
|
+
<UButton
|
|
240
|
+
type="submit"
|
|
241
|
+
color="primary"
|
|
242
|
+
icon="i-lucide-user-plus"
|
|
243
|
+
:loading="busy"
|
|
244
|
+
:disabled="!addUserId"
|
|
245
|
+
data-testid="workspace-member-add-submit"
|
|
246
|
+
>
|
|
247
|
+
{{ t('layout.workspaceMembers.add.submit') }}
|
|
248
|
+
</UButton>
|
|
249
|
+
</form>
|
|
250
|
+
<p v-if="candidates.length === 0" class="mt-2 text-slate-500">
|
|
251
|
+
{{ t('layout.workspaceMembers.add.allAdded') }}
|
|
252
|
+
</p>
|
|
253
|
+
</template>
|
|
254
|
+
</section>
|
|
255
|
+
</div>
|
|
256
|
+
</template>
|