@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.
Files changed (47) hide show
  1. package/app/components/board/AgentFailureCard.vue +3 -1
  2. package/app/components/board/AgentStopButton.vue +3 -0
  3. package/app/components/board/BoardCanvas.vue +16 -3
  4. package/app/components/board/RecurringPipelineModal.vue +7 -1
  5. package/app/components/board/nodes/BlockNode.vue +47 -41
  6. package/app/components/brainstorm/BrainstormWindow.vue +25 -5
  7. package/app/components/clarity/ClarityReviewWindow.vue +17 -4
  8. package/app/components/docs/DocInterviewWindow.vue +5 -1
  9. package/app/components/followUp/FollowUpWindow.vue +15 -1
  10. package/app/components/forkDecision/ForkDecisionWindow.vue +7 -2
  11. package/app/components/gates/GateResultView.vue +7 -1
  12. package/app/components/humanTest/HumanTestWindow.vue +11 -5
  13. package/app/components/layout/BoardSwitcher.vue +24 -13
  14. package/app/components/layout/BoardToolbar.vue +8 -4
  15. package/app/components/layout/CommandBar.vue +137 -123
  16. package/app/components/layout/NotificationsInbox.vue +5 -1
  17. package/app/components/layout/SideBar.vue +157 -115
  18. package/app/components/layout/SpendWarningBanner.vue +3 -0
  19. package/app/components/layout/WorkspaceMembersSettings.vue +256 -0
  20. package/app/components/panels/InspectorPanel.vue +23 -10
  21. package/app/components/panels/inspector/TaskExecution.vue +13 -4
  22. package/app/components/prReview/PrReviewWindow.vue +7 -3
  23. package/app/components/requirements/RequirementsReviewWindow.vue +33 -5
  24. package/app/components/settings/WorkspaceSettingsPanel.vue +20 -0
  25. package/app/components/visualConfirm/VisualConfirmationWindow.vue +7 -3
  26. package/app/composables/api/workspaces.ts +31 -1
  27. package/app/composables/useBlockDeletion.ts +7 -0
  28. package/app/composables/useBlockDrag.ts +5 -0
  29. package/app/composables/useFrameResize.ts +4 -0
  30. package/app/composables/useWorkspaceAccess.spec.ts +102 -0
  31. package/app/composables/useWorkspaceAccess.ts +84 -0
  32. package/app/stores/workspace.spec.ts +19 -0
  33. package/app/stores/workspace.ts +27 -7
  34. package/app/stores/workspaceMembers.spec.ts +121 -0
  35. package/app/stores/workspaceMembers.ts +74 -0
  36. package/app/types/domain.ts +6 -0
  37. package/i18n/locales/de.json +41 -1
  38. package/i18n/locales/en.json +44 -1
  39. package/i18n/locales/es.json +41 -1
  40. package/i18n/locales/fr.json +41 -1
  41. package/i18n/locales/he.json +41 -1
  42. package/i18n/locales/it.json +41 -1
  43. package/i18n/locales/ja.json +41 -1
  44. package/i18n/locales/pl.json +41 -1
  45. package/i18n/locales/tr.json +41 -1
  46. package/i18n/locales/uk.json +41 -1
  47. 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
- auth.infrastructure != null ||
40
- auth.localMode?.enabled === true ||
41
- providerConnections.isAvailable('runner-pool') ||
42
- providerConnections.isAvailable('environment'),
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
- <section>
170
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
171
- {{ t('nav.create') }}
172
- </h2>
173
- <div class="space-y-1.5">
174
- <UButton
175
- block
176
- color="primary"
177
- variant="soft"
178
- size="sm"
179
- icon="i-lucide-workflow"
180
- class="justify-start"
181
- @click="ui.openBuilder()"
182
- >
183
- {{ t('nav.buildPipeline') }}
184
- </UButton>
185
- </div>
186
- </section>
187
-
188
- <USeparator />
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
- <section>
191
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
192
- {{ t('nav.repositories') }}
193
- </h2>
194
- <div class="space-y-1.5">
195
- <UButton
196
- v-if="github.available"
197
- block
198
- color="primary"
199
- variant="soft"
200
- size="sm"
201
- icon="i-lucide-folder-git-2"
202
- class="justify-start"
203
- @click="ui.openAddService()"
204
- >
205
- {{ t('nav.addFromRepo') }}
206
- </UButton>
207
- <UButton
208
- block
209
- color="primary"
210
- variant="soft"
211
- size="sm"
212
- icon="i-lucide-git-branch-plus"
213
- class="justify-start"
214
- @click="ui.openBootstrap()"
215
- >
216
- {{ t('nav.bootstrapRepo') }}
217
- </UButton>
218
- </div>
219
- </section>
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="library.available">
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
- <USeparator />
331
- <section>
332
- <h2 class="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
333
- {{ t('nav.configuration') }}
334
- </h2>
335
- <div class="space-y-1.5">
336
- <!-- Merge thresholds, issue writeback and default service best practices are
337
- now tabs inside Workspace settings. -->
338
- <UButton
339
- block
340
- color="primary"
341
- variant="soft"
342
- size="sm"
343
- icon="i-lucide-sliders-horizontal"
344
- class="justify-start"
345
- @click="ui.openWorkspaceSettings()"
346
- >
347
- {{ t('nav.workspaceSettings') }}
348
- </UButton>
349
- <UButton
350
- block
351
- color="primary"
352
- variant="soft"
353
- size="sm"
354
- icon="i-lucide-cpu"
355
- class="justify-start"
356
- @click="ui.openModelConfig()"
357
- >
358
- {{ t('nav.modelConfiguration') }}
359
- </UButton>
360
- <!-- Account & team: members + roles, invitations, email sender, account API keys.
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
- <UButton
363
- v-if="accounts.enabled"
364
- block
365
- color="primary"
366
- variant="soft"
367
- size="sm"
368
- icon="i-lucide-users"
369
- class="justify-start"
370
- @click="ui.openAccountSettings()"
371
- >
372
- {{ t('nav.accountSettings') }}
373
- </UButton>
374
- <!-- Platform observability: deployment-level run health. Admin-only, like the backend gate. -->
375
- <UButton
376
- v-if="accounts.enabled && isAccountAdmin"
377
- block
378
- color="primary"
379
- variant="soft"
380
- size="sm"
381
- icon="i-lucide-gauge"
382
- class="justify-start"
383
- data-testid="nav-operator-dashboard"
384
- @click="ui.openOperatorDashboard()"
385
- >
386
- {{ t('nav.operatorDashboard') }}
387
- </UButton>
388
- </div>
389
- </section>
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>