@cat-factory/app 0.130.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/layout/BoardSwitcher.vue +3 -1
- package/app/components/layout/WorkspaceMembersSettings.vue +256 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +20 -0
- package/app/composables/api/workspaces.ts +31 -1
- package/app/stores/workspaceMembers.spec.ts +121 -0
- package/app/stores/workspaceMembers.ts +74 -0
- package/i18n/locales/de.json +36 -1
- package/i18n/locales/en.json +36 -1
- package/i18n/locales/es.json +36 -1
- package/i18n/locales/fr.json +36 -1
- package/i18n/locales/he.json +36 -1
- package/i18n/locales/it.json +36 -1
- package/i18n/locales/ja.json +36 -1
- package/i18n/locales/pl.json +36 -1
- package/i18n/locales/tr.json +36 -1
- package/i18n/locales/uk.json +36 -1
- package/package.json +1 -1
|
@@ -96,7 +96,9 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
|
|
|
96
96
|
// Badge a board the caller only reaches as a read-only viewer (a restricted board
|
|
97
97
|
// they're not a member/admin of), so the switcher shows why it's read-only.
|
|
98
98
|
label: w.viewerRole === 'viewer' ? `${w.name} · ${t('access.viewerBadge')}` : w.name,
|
|
99
|
-
|
|
99
|
+
// A restricted board (limited to an explicit roster) gets a lock glyph so an admin who
|
|
100
|
+
// manages membership can see at a glance which boards are scoped vs open to the account.
|
|
101
|
+
icon: w.accessMode === 'restricted' ? 'i-lucide-lock' : 'i-lucide-layout-dashboard',
|
|
100
102
|
trailingIcon: w.id === workspace.workspaceId ? 'i-lucide-check' : undefined,
|
|
101
103
|
onSelect: () => void switchBoard(w.id),
|
|
102
104
|
})),
|
|
@@ -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>
|
|
@@ -14,11 +14,14 @@ import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
|
|
|
14
14
|
import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
|
|
15
15
|
import BudgetSettings from '~/components/settings/BudgetSettings.vue'
|
|
16
16
|
import UsageSettings from '~/components/settings/UsageSettings.vue'
|
|
17
|
+
import WorkspaceMembersSettings from '~/components/layout/WorkspaceMembersSettings.vue'
|
|
17
18
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
18
19
|
|
|
19
20
|
const { t, te } = useI18n()
|
|
20
21
|
const ui = useUiStore()
|
|
21
22
|
const store = useWorkspaceSettingsStore()
|
|
23
|
+
const workspace = useWorkspaceStore()
|
|
24
|
+
const access = useWorkspaceAccess()
|
|
22
25
|
const toast = useToast()
|
|
23
26
|
|
|
24
27
|
const open = computed({
|
|
@@ -71,6 +74,18 @@ const tabs = computed(() => [
|
|
|
71
74
|
icon: 'i-lucide-book-open-check',
|
|
72
75
|
slot: 'fragments',
|
|
73
76
|
},
|
|
77
|
+
// Roster + access-mode management is `members.manage` (workspace admins only). Hidden
|
|
78
|
+
// for everyone else — the backend 403s the writes and the tab has nothing to read.
|
|
79
|
+
...(access.canManageMembers.value
|
|
80
|
+
? [
|
|
81
|
+
{
|
|
82
|
+
value: 'members',
|
|
83
|
+
label: t('settings.workspaceSettings.tabs.members'),
|
|
84
|
+
icon: 'i-lucide-users',
|
|
85
|
+
slot: 'members',
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
: []),
|
|
74
89
|
])
|
|
75
90
|
|
|
76
91
|
// Tab strip styling: the labels must always fit (never truncate) and the strip must never
|
|
@@ -359,6 +374,11 @@ async function save() {
|
|
|
359
374
|
<template #fragments>
|
|
360
375
|
<ServiceFragmentDefaultsPanel />
|
|
361
376
|
</template>
|
|
377
|
+
|
|
378
|
+
<!-- Members (workspace RBAC roster + access mode; admins only) -->
|
|
379
|
+
<template v-if="access.canManageMembers.value && workspace.workspaceId" #members>
|
|
380
|
+
<WorkspaceMembersSettings :workspace-id="workspace.workspaceId" />
|
|
381
|
+
</template>
|
|
362
382
|
</UTabs>
|
|
363
383
|
</template>
|
|
364
384
|
</UModal>
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
import {
|
|
2
|
+
addWorkspaceMemberContract,
|
|
2
3
|
createWorkspaceContract,
|
|
3
4
|
deleteWorkspaceContract,
|
|
4
5
|
getWorkspaceContract,
|
|
5
6
|
getWorkspaceSettingsContract,
|
|
7
|
+
listWorkspaceMembersContract,
|
|
6
8
|
listWorkspacesContract,
|
|
9
|
+
removeWorkspaceMemberContract,
|
|
10
|
+
setWorkspaceAccessModeContract,
|
|
11
|
+
setWorkspaceMemberRoleContract,
|
|
7
12
|
updateWorkspaceContract,
|
|
8
13
|
updateWorkspaceSettingsContract,
|
|
9
14
|
} from '@cat-factory/contracts'
|
|
10
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
UpdateWorkspaceSettingsInput,
|
|
17
|
+
WorkspaceAccessMode,
|
|
18
|
+
WorkspaceRole,
|
|
19
|
+
} from '~/types/domain'
|
|
11
20
|
import type { ApiContext } from './context'
|
|
12
21
|
|
|
13
22
|
/** Workspace CRUD + the full snapshot read. */
|
|
@@ -38,5 +47,26 @@ export function workspacesApi({ send, ws }: ApiContext) {
|
|
|
38
47
|
|
|
39
48
|
updateWorkspaceSettings: (workspaceId: string, body: UpdateWorkspaceSettingsInput) =>
|
|
40
49
|
send(updateWorkspaceSettingsContract, { pathPrefix: ws(workspaceId), body }),
|
|
50
|
+
|
|
51
|
+
// ---- workspace membership (RBAC roster + access-mode) -----------------
|
|
52
|
+
// The roster read is open to any resolved role; every write requires `members.manage`
|
|
53
|
+
// (the backend gates it — the SPA only shows this surface to workspace admins).
|
|
54
|
+
listWorkspaceMembers: (workspaceId: string) =>
|
|
55
|
+
send(listWorkspaceMembersContract, { pathParams: { workspaceId } }),
|
|
56
|
+
|
|
57
|
+
addWorkspaceMember: (workspaceId: string, userId: string, role: WorkspaceRole) =>
|
|
58
|
+
send(addWorkspaceMemberContract, { pathParams: { workspaceId }, body: { userId, role } }),
|
|
59
|
+
|
|
60
|
+
setWorkspaceMemberRole: (workspaceId: string, userId: string, role: WorkspaceRole) =>
|
|
61
|
+
send(setWorkspaceMemberRoleContract, {
|
|
62
|
+
pathParams: { workspaceId, userId },
|
|
63
|
+
body: { role },
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
removeWorkspaceMember: (workspaceId: string, userId: string) =>
|
|
67
|
+
send(removeWorkspaceMemberContract, { pathParams: { workspaceId, userId } }),
|
|
68
|
+
|
|
69
|
+
setWorkspaceAccessMode: (workspaceId: string, accessMode: WorkspaceAccessMode) =>
|
|
70
|
+
send(setWorkspaceAccessModeContract, { pathParams: { workspaceId }, body: { accessMode } }),
|
|
41
71
|
}
|
|
42
72
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useWorkspaceMembersStore } from '~/stores/workspaceMembers'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
import type { WorkspaceMember } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
/** Minimal member view — only the fields the store passes through. */
|
|
7
|
+
function member(over: Partial<WorkspaceMember> = {}): WorkspaceMember {
|
|
8
|
+
return {
|
|
9
|
+
workspaceId: 'ws1',
|
|
10
|
+
userId: 'usr_1',
|
|
11
|
+
role: 'member',
|
|
12
|
+
createdAt: 1,
|
|
13
|
+
addedBy: null,
|
|
14
|
+
...over,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('workspaceMembers store', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// The store patches the board-list row on an access-mode flip; seed one restricted-capable row.
|
|
21
|
+
const workspace = useWorkspaceStore()
|
|
22
|
+
workspace.workspaces = [
|
|
23
|
+
{ id: 'ws1', name: 'Board', accountId: 'acc1', accessMode: 'account', viewerRole: 'admin' },
|
|
24
|
+
] as never
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('load replaces the roster and records which board it belongs to', async () => {
|
|
28
|
+
vi.stubGlobal('useApi', () => ({
|
|
29
|
+
listWorkspaceMembers: () =>
|
|
30
|
+
Promise.resolve([member({ userId: 'a' }), member({ userId: 'b' })]),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const store = useWorkspaceMembersStore()
|
|
34
|
+
await store.load('ws1')
|
|
35
|
+
|
|
36
|
+
expect(store.members.map((m) => m.userId)).toEqual(['a', 'b'])
|
|
37
|
+
expect(store.loadedFor).toBe('ws1')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('load is monotonic — a superseded slow fetch cannot clobber a newer board', async () => {
|
|
41
|
+
// Two loads in flight; the OLDER-issued one (wsA) resolves LAST. It must be dropped so
|
|
42
|
+
// the switcher never renders the previous board's roster over the current one.
|
|
43
|
+
const resolvers = new Map<string, (v: WorkspaceMember[]) => void>()
|
|
44
|
+
vi.stubGlobal('useApi', () => ({
|
|
45
|
+
listWorkspaceMembers: (ws: string) =>
|
|
46
|
+
new Promise<WorkspaceMember[]>((resolve) => resolvers.set(ws, resolve)),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
const store = useWorkspaceMembersStore()
|
|
50
|
+
const first = store.load('wsA') // slow, issued first
|
|
51
|
+
const second = store.load('wsB') // newer, issued second
|
|
52
|
+
|
|
53
|
+
resolvers.get('wsB')?.([member({ userId: 'b', workspaceId: 'wsB' })])
|
|
54
|
+
await second
|
|
55
|
+
resolvers.get('wsA')?.([member({ userId: 'a', workspaceId: 'wsA' })])
|
|
56
|
+
await first
|
|
57
|
+
|
|
58
|
+
expect(store.members.map((m) => m.userId)).toEqual(['b'])
|
|
59
|
+
expect(store.loadedFor).toBe('wsB')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('add upserts the returned member', async () => {
|
|
63
|
+
vi.stubGlobal('useApi', () => ({
|
|
64
|
+
listWorkspaceMembers: () => Promise.resolve([member({ userId: 'a' })]),
|
|
65
|
+
addWorkspaceMember: (_ws: string, userId: string, role: WorkspaceMember['role']) =>
|
|
66
|
+
Promise.resolve(member({ userId, role })),
|
|
67
|
+
}))
|
|
68
|
+
|
|
69
|
+
const store = useWorkspaceMembersStore()
|
|
70
|
+
await store.load('ws1')
|
|
71
|
+
await store.add('ws1', 'b', 'viewer')
|
|
72
|
+
|
|
73
|
+
expect(store.members.map((m) => m.userId)).toEqual(['a', 'b'])
|
|
74
|
+
expect(store.members.find((m) => m.userId === 'b')?.role).toBe('viewer')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('setRole replaces the member in place', async () => {
|
|
78
|
+
vi.stubGlobal('useApi', () => ({
|
|
79
|
+
listWorkspaceMembers: () => Promise.resolve([member({ userId: 'a', role: 'member' })]),
|
|
80
|
+
setWorkspaceMemberRole: (_ws: string, userId: string, role: WorkspaceMember['role']) =>
|
|
81
|
+
Promise.resolve(member({ userId, role })),
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
const store = useWorkspaceMembersStore()
|
|
85
|
+
await store.load('ws1')
|
|
86
|
+
await store.setRole('ws1', 'a', 'admin')
|
|
87
|
+
|
|
88
|
+
expect(store.members).toHaveLength(1)
|
|
89
|
+
expect(store.members[0]?.role).toBe('admin')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('remove drops the member from the roster', async () => {
|
|
93
|
+
vi.stubGlobal('useApi', () => ({
|
|
94
|
+
listWorkspaceMembers: () =>
|
|
95
|
+
Promise.resolve([member({ userId: 'a' }), member({ userId: 'b' })]),
|
|
96
|
+
removeWorkspaceMember: () => Promise.resolve(),
|
|
97
|
+
}))
|
|
98
|
+
|
|
99
|
+
const store = useWorkspaceMembersStore()
|
|
100
|
+
await store.load('ws1')
|
|
101
|
+
await store.remove('ws1', 'a')
|
|
102
|
+
|
|
103
|
+
expect(store.members.map((m) => m.userId)).toEqual(['b'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('setAccessMode patches the board-list row in place, preserving the viewerRole annotation', async () => {
|
|
107
|
+
vi.stubGlobal('useApi', () => ({
|
|
108
|
+
setWorkspaceAccessMode: (workspaceId: string, accessMode: string) =>
|
|
109
|
+
// The single-workspace response carries no `viewerRole` (that's a list annotation).
|
|
110
|
+
Promise.resolve({ id: workspaceId, name: 'Board', accountId: 'acc1', accessMode }),
|
|
111
|
+
}))
|
|
112
|
+
|
|
113
|
+
const store = useWorkspaceMembersStore()
|
|
114
|
+
await store.setAccessMode('ws1', 'restricted')
|
|
115
|
+
|
|
116
|
+
const row = useWorkspaceStore().workspaces.find((w) => w.id === 'ws1')
|
|
117
|
+
expect(row?.accessMode).toBe('restricted')
|
|
118
|
+
// The merge must not clobber the list-only badge.
|
|
119
|
+
expect(row?.viewerRole).toBe('admin')
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useUpsertList } from '~/composables/useUpsertList'
|
|
4
|
+
import type { WorkspaceAccessMode, WorkspaceMember, WorkspaceRole } from '~/types/domain'
|
|
5
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The active board's workspace-RBAC roster (member-management, slice 9). Owns the
|
|
9
|
+
* `workspace_members` list plus the board's access-mode flip, sitting BELOW the account
|
|
10
|
+
* tier: an account admin can restrict a board to an explicit member list, while an
|
|
11
|
+
* unrestricted (`account`) board keeps the legacy "every account member sees it" behaviour.
|
|
12
|
+
*
|
|
13
|
+
* Every write requires `members.manage` server-side, so the SPA only mounts this surface
|
|
14
|
+
* (the Members tab in workspace settings) when `useWorkspaceAccess().canManageMembers`.
|
|
15
|
+
* The list is loaded lazily when that panel opens — it is not part of the board snapshot.
|
|
16
|
+
*/
|
|
17
|
+
export const useWorkspaceMembersStore = defineStore('workspaceMembers', () => {
|
|
18
|
+
const api = useApi()
|
|
19
|
+
|
|
20
|
+
const { items: members, upsert: upsertMember } = useUpsertList<WorkspaceMember>({
|
|
21
|
+
key: (m) => m.userId,
|
|
22
|
+
})
|
|
23
|
+
/**
|
|
24
|
+
* The workspace id the currently-committed roster belongs to. The Members panel renders
|
|
25
|
+
* the roster only while this matches its own `workspaceId`, so switching boards never
|
|
26
|
+
* briefly shows the previous board's members before the new load resolves.
|
|
27
|
+
*/
|
|
28
|
+
const loadedFor = ref<string | null>(null)
|
|
29
|
+
// Monotonic request token: only the latest-issued load() commits, so a slow, superseded
|
|
30
|
+
// fetch can't clobber a newer board's roster (the ordering hazard the coherence rules warn about).
|
|
31
|
+
let loadSeq = 0
|
|
32
|
+
|
|
33
|
+
/** Load the board's member roster (enriched with display details by the backend). */
|
|
34
|
+
async function load(workspaceId: string) {
|
|
35
|
+
const token = ++loadSeq
|
|
36
|
+
const roster = await api.listWorkspaceMembers(workspaceId)
|
|
37
|
+
if (token !== loadSeq) return // a newer load() superseded this one; drop the stale result
|
|
38
|
+
members.value = roster
|
|
39
|
+
loadedFor.value = workspaceId
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Add an account member to the board at a workspace role (upsert). */
|
|
43
|
+
async function add(workspaceId: string, userId: string, role: WorkspaceRole) {
|
|
44
|
+
upsertMember(await api.addWorkspaceMember(workspaceId, userId, role))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Change an existing member's workspace role. */
|
|
48
|
+
async function setRole(workspaceId: string, userId: string, role: WorkspaceRole) {
|
|
49
|
+
upsertMember(await api.setWorkspaceMemberRole(workspaceId, userId, role))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Remove a member from the board. */
|
|
53
|
+
async function remove(workspaceId: string, userId: string) {
|
|
54
|
+
await api.removeWorkspaceMember(workspaceId, userId)
|
|
55
|
+
members.value = members.value.filter((m) => m.userId !== userId)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Flip the board's access mode. `restricted` limits it to the roster; `account` restores
|
|
60
|
+
* the legacy behaviour. Patches the board's list row in place so the switcher badge and
|
|
61
|
+
* `activeWorkspace.accessMode` reflect the flip without a re-fetch.
|
|
62
|
+
*/
|
|
63
|
+
async function setAccessMode(workspaceId: string, accessMode: WorkspaceAccessMode) {
|
|
64
|
+
const updated = await api.setWorkspaceAccessMode(workspaceId, accessMode)
|
|
65
|
+
const workspace = useWorkspaceStore()
|
|
66
|
+
const row = workspace.workspaces.find((w) => w.id === workspaceId)
|
|
67
|
+
// Merge only the workspace fields (incl. `accessMode`), preserving the row's
|
|
68
|
+
// `viewerRole` list annotation, which the single-workspace response doesn't carry.
|
|
69
|
+
if (row) Object.assign(row, updated)
|
|
70
|
+
return updated
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { members, loadedFor, load, add, setRole, remove, setAccessMode }
|
|
74
|
+
})
|
package/i18n/locales/de.json
CHANGED
|
@@ -709,7 +709,8 @@
|
|
|
709
709
|
"budget": "Budget",
|
|
710
710
|
"merge": "Risikorichtlinien",
|
|
711
711
|
"tracker": "Issue-Tracker",
|
|
712
|
-
"fragments": "Dienst-Best-Practices"
|
|
712
|
+
"fragments": "Dienst-Best-Practices",
|
|
713
|
+
"members": "Mitglieder"
|
|
713
714
|
},
|
|
714
715
|
"waiting": {
|
|
715
716
|
"heading": "Warten auf einen Menschen",
|
|
@@ -1978,6 +1979,40 @@
|
|
|
1978
1979
|
},
|
|
1979
1980
|
"accountSkills": {
|
|
1980
1981
|
"intro": "Verknüpfe Repositorys, in denen dein Team Claude Skills erstellt (ein SKILL.md-Ordner plus Ressourcendateien). Synchronisierte Skills landen im Katalog dieses Kontos, der von allen Boards geteilt wird, und der Skill-Schritt einer Pipeline führt einen davon für eine Aufgabe aus."
|
|
1982
|
+
},
|
|
1983
|
+
"workspaceMembers": {
|
|
1984
|
+
"you": "{name} (du)",
|
|
1985
|
+
"roles": {
|
|
1986
|
+
"admin": "Admin",
|
|
1987
|
+
"member": "Mitglied",
|
|
1988
|
+
"viewer": "Betrachter"
|
|
1989
|
+
},
|
|
1990
|
+
"accessMode": {
|
|
1991
|
+
"title": "Board-Zugriff",
|
|
1992
|
+
"accountHint": "Alle im zugehörigen Konto können dieses Board sehen.",
|
|
1993
|
+
"restrictedHint": "Nur die unten aufgeführten Mitglieder können dieses Board sehen.",
|
|
1994
|
+
"restrictToggle": "Auf Mitglieder beschränken"
|
|
1995
|
+
},
|
|
1996
|
+
"roster": {
|
|
1997
|
+
"title": "Mitglieder",
|
|
1998
|
+
"empty": "Noch keine Mitglieder.",
|
|
1999
|
+
"remove": "Mitglied entfernen"
|
|
2000
|
+
},
|
|
2001
|
+
"add": {
|
|
2002
|
+
"title": "Mitglied hinzufügen",
|
|
2003
|
+
"selectMember": "Kontomitglied auswählen",
|
|
2004
|
+
"submit": "Hinzufügen",
|
|
2005
|
+
"added": "Mitglied hinzugefügt",
|
|
2006
|
+
"allAdded": "Alle Kontomitglieder sind bereits auf diesem Board.",
|
|
2007
|
+
"noAccount": "Dieses Board ist noch mit keinem Konto verknüpft."
|
|
2008
|
+
},
|
|
2009
|
+
"errors": {
|
|
2010
|
+
"load": "Mitglieder konnten nicht geladen werden",
|
|
2011
|
+
"accessMode": "Board-Zugriff konnte nicht geändert werden",
|
|
2012
|
+
"setRole": "Mitgliedsrolle konnte nicht aktualisiert werden",
|
|
2013
|
+
"add": "Mitglied konnte nicht hinzugefügt werden",
|
|
2014
|
+
"remove": "Mitglied konnte nicht entfernt werden"
|
|
2015
|
+
}
|
|
1981
2016
|
}
|
|
1982
2017
|
},
|
|
1983
2018
|
"board": {
|
package/i18n/locales/en.json
CHANGED
|
@@ -1951,6 +1951,40 @@
|
|
|
1951
1951
|
},
|
|
1952
1952
|
"accountSkills": {
|
|
1953
1953
|
"intro": "Link repos where your team authors Claude Skills (a SKILL.md folder plus resource files). Synced skills join this account's catalog, shared by every board, and a pipeline's Skill step runs one against a task."
|
|
1954
|
+
},
|
|
1955
|
+
"workspaceMembers": {
|
|
1956
|
+
"you": "{name} (you)",
|
|
1957
|
+
"roles": {
|
|
1958
|
+
"admin": "Admin",
|
|
1959
|
+
"member": "Member",
|
|
1960
|
+
"viewer": "Viewer"
|
|
1961
|
+
},
|
|
1962
|
+
"accessMode": {
|
|
1963
|
+
"title": "Board access",
|
|
1964
|
+
"accountHint": "Everyone in the owning account can see this board.",
|
|
1965
|
+
"restrictedHint": "Only the members listed below can see this board.",
|
|
1966
|
+
"restrictToggle": "Restrict to members"
|
|
1967
|
+
},
|
|
1968
|
+
"roster": {
|
|
1969
|
+
"title": "Members",
|
|
1970
|
+
"empty": "No members yet.",
|
|
1971
|
+
"remove": "Remove member"
|
|
1972
|
+
},
|
|
1973
|
+
"add": {
|
|
1974
|
+
"title": "Add a member",
|
|
1975
|
+
"selectMember": "Select an account member",
|
|
1976
|
+
"submit": "Add",
|
|
1977
|
+
"added": "Member added",
|
|
1978
|
+
"allAdded": "Every account member is already on this board.",
|
|
1979
|
+
"noAccount": "This board isn't linked to an account yet."
|
|
1980
|
+
},
|
|
1981
|
+
"errors": {
|
|
1982
|
+
"load": "Could not load members",
|
|
1983
|
+
"accessMode": "Could not change board access",
|
|
1984
|
+
"setRole": "Could not update member role",
|
|
1985
|
+
"add": "Could not add member",
|
|
1986
|
+
"remove": "Could not remove member"
|
|
1987
|
+
}
|
|
1954
1988
|
}
|
|
1955
1989
|
},
|
|
1956
1990
|
"settings": {
|
|
@@ -2666,7 +2700,8 @@
|
|
|
2666
2700
|
"budget": "Budget",
|
|
2667
2701
|
"merge": "Risk policies",
|
|
2668
2702
|
"tracker": "Issue tracker",
|
|
2669
|
-
"fragments": "Service best practices"
|
|
2703
|
+
"fragments": "Service best practices",
|
|
2704
|
+
"members": "Members"
|
|
2670
2705
|
},
|
|
2671
2706
|
"waiting": {
|
|
2672
2707
|
"heading": "Waiting for a human",
|
package/i18n/locales/es.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "Vincula repositorios donde tu equipo crea Claude Skills (una carpeta SKILL.md más archivos de recursos). Las habilidades sincronizadas se añaden al catálogo de esta cuenta, compartido por todos los tableros, y el paso Skill de una tubería ejecuta una sobre una tarea."
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name} (tú)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "Administrador",
|
|
1896
|
+
"member": "Miembro",
|
|
1897
|
+
"viewer": "Observador"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "Acceso al tablero",
|
|
1901
|
+
"accountHint": "Todos los miembros de la cuenta propietaria pueden ver este tablero.",
|
|
1902
|
+
"restrictedHint": "Solo los miembros que aparecen abajo pueden ver este tablero.",
|
|
1903
|
+
"restrictToggle": "Restringir a miembros"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "Miembros",
|
|
1907
|
+
"empty": "Aún no hay miembros.",
|
|
1908
|
+
"remove": "Quitar miembro"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "Añadir un miembro",
|
|
1912
|
+
"selectMember": "Selecciona un miembro de la cuenta",
|
|
1913
|
+
"submit": "Añadir",
|
|
1914
|
+
"added": "Miembro añadido",
|
|
1915
|
+
"allAdded": "Todos los miembros de la cuenta ya están en este tablero.",
|
|
1916
|
+
"noAccount": "Este tablero aún no está vinculado a una cuenta."
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "No se pudieron cargar los miembros",
|
|
1920
|
+
"accessMode": "No se pudo cambiar el acceso al tablero",
|
|
1921
|
+
"setRole": "No se pudo actualizar el rol del miembro",
|
|
1922
|
+
"add": "No se pudo añadir el miembro",
|
|
1923
|
+
"remove": "No se pudo quitar el miembro"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2478,7 +2512,8 @@
|
|
|
2478
2512
|
"budget": "Presupuesto",
|
|
2479
2513
|
"merge": "Políticas de riesgo",
|
|
2480
2514
|
"tracker": "Gestor de incidencias",
|
|
2481
|
-
"fragments": "Buenas prácticas del servicio"
|
|
2515
|
+
"fragments": "Buenas prácticas del servicio",
|
|
2516
|
+
"members": "Miembros"
|
|
2482
2517
|
},
|
|
2483
2518
|
"waiting": {
|
|
2484
2519
|
"heading": "Esperando a una persona",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "Reliez les dépôts où votre équipe crée des Claude Skills (un dossier SKILL.md plus des fichiers de ressources). Les compétences synchronisées rejoignent le catalogue de ce compte, partagé par tous les tableaux, et l'étape Skill d'un pipeline en exécute une sur une tâche."
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name} (vous)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "Administrateur",
|
|
1896
|
+
"member": "Membre",
|
|
1897
|
+
"viewer": "Observateur"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "Accès au tableau",
|
|
1901
|
+
"accountHint": "Tous les membres du compte propriétaire peuvent voir ce tableau.",
|
|
1902
|
+
"restrictedHint": "Seuls les membres listés ci-dessous peuvent voir ce tableau.",
|
|
1903
|
+
"restrictToggle": "Limiter aux membres"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "Membres",
|
|
1907
|
+
"empty": "Aucun membre pour l'instant.",
|
|
1908
|
+
"remove": "Retirer le membre"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "Ajouter un membre",
|
|
1912
|
+
"selectMember": "Sélectionner un membre du compte",
|
|
1913
|
+
"submit": "Ajouter",
|
|
1914
|
+
"added": "Membre ajouté",
|
|
1915
|
+
"allAdded": "Tous les membres du compte sont déjà sur ce tableau.",
|
|
1916
|
+
"noAccount": "Ce tableau n'est pas encore lié à un compte."
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "Impossible de charger les membres",
|
|
1920
|
+
"accessMode": "Impossible de modifier l'accès au tableau",
|
|
1921
|
+
"setRole": "Impossible de mettre à jour le rôle du membre",
|
|
1922
|
+
"add": "Impossible d'ajouter le membre",
|
|
1923
|
+
"remove": "Impossible de retirer le membre"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2478,7 +2512,8 @@
|
|
|
2478
2512
|
"budget": "Budget",
|
|
2479
2513
|
"merge": "Politiques de risque",
|
|
2480
2514
|
"tracker": "Suivi des tickets",
|
|
2481
|
-
"fragments": "Bonnes pratiques du service"
|
|
2515
|
+
"fragments": "Bonnes pratiques du service",
|
|
2516
|
+
"members": "Membres"
|
|
2482
2517
|
},
|
|
2483
2518
|
"waiting": {
|
|
2484
2519
|
"heading": "En attente d'un humain",
|
package/i18n/locales/he.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "קישור מאגרים שבהם הצוות שלך יוצרת Claude Skills (תיקיית SKILL.md בתוספת קבצי משאבים). כישורים מסונכרנים מתווספים לקטלוג של חשבון זה, המשותף לכל הלוחות, ושלב ה-Skill של צינור מריץ אחד מהם על משימה."
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name} (אתה)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "מנהל",
|
|
1896
|
+
"member": "חבר",
|
|
1897
|
+
"viewer": "צופה"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "גישה ללוח",
|
|
1901
|
+
"accountHint": "כל מי שבחשבון הבעלים יכול לראות את הלוח הזה.",
|
|
1902
|
+
"restrictedHint": "רק החברים המופיעים למטה יכולים לראות את הלוח הזה.",
|
|
1903
|
+
"restrictToggle": "הגבל לחברים"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "חברים",
|
|
1907
|
+
"empty": "אין חברים עדיין.",
|
|
1908
|
+
"remove": "הסר חבר"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "הוסף חבר",
|
|
1912
|
+
"selectMember": "בחר חבר חשבון",
|
|
1913
|
+
"submit": "הוסף",
|
|
1914
|
+
"added": "החבר נוסף",
|
|
1915
|
+
"allAdded": "כל חברי החשבון כבר נמצאים בלוח הזה.",
|
|
1916
|
+
"noAccount": "הלוח הזה עדיין לא מקושר לחשבון."
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "לא ניתן לטעון את החברים",
|
|
1920
|
+
"accessMode": "לא ניתן לשנות את הגישה ללוח",
|
|
1921
|
+
"setRole": "לא ניתן לעדכן את תפקיד החבר",
|
|
1922
|
+
"add": "לא ניתן להוסיף את החבר",
|
|
1923
|
+
"remove": "לא ניתן להסיר את החבר"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2599,7 +2633,8 @@
|
|
|
2599
2633
|
"budget": "תקציב",
|
|
2600
2634
|
"merge": "מדיניות סיכון",
|
|
2601
2635
|
"tracker": "מעקב כרטיסים",
|
|
2602
|
-
"fragments": "שיטות עבודה מומלצות לשירות"
|
|
2636
|
+
"fragments": "שיטות עבודה מומלצות לשירות",
|
|
2637
|
+
"members": "חברים"
|
|
2603
2638
|
},
|
|
2604
2639
|
"waiting": {
|
|
2605
2640
|
"heading": "ממתין לאדם",
|
package/i18n/locales/it.json
CHANGED
|
@@ -709,7 +709,8 @@
|
|
|
709
709
|
"budget": "Budget",
|
|
710
710
|
"merge": "Criteri di rischio",
|
|
711
711
|
"tracker": "Tracker degli issue",
|
|
712
|
-
"fragments": "Best practice dei servizi"
|
|
712
|
+
"fragments": "Best practice dei servizi",
|
|
713
|
+
"members": "Membri"
|
|
713
714
|
},
|
|
714
715
|
"waiting": {
|
|
715
716
|
"heading": "In attesa di un umano",
|
|
@@ -1978,6 +1979,40 @@
|
|
|
1978
1979
|
},
|
|
1979
1980
|
"accountSkills": {
|
|
1980
1981
|
"intro": "Collega i repository dove il tuo team crea le Claude Skills (una cartella SKILL.md più file di risorse). Le competenze sincronizzate entrano nel catalogo di questo account, condiviso da tutte le board, e il passo Skill di una pipeline ne esegue una su un'attività."
|
|
1982
|
+
},
|
|
1983
|
+
"workspaceMembers": {
|
|
1984
|
+
"you": "{name} (tu)",
|
|
1985
|
+
"roles": {
|
|
1986
|
+
"admin": "Amministratore",
|
|
1987
|
+
"member": "Membro",
|
|
1988
|
+
"viewer": "Osservatore"
|
|
1989
|
+
},
|
|
1990
|
+
"accessMode": {
|
|
1991
|
+
"title": "Accesso alla board",
|
|
1992
|
+
"accountHint": "Tutti i membri dell'account proprietario possono vedere questa board.",
|
|
1993
|
+
"restrictedHint": "Solo i membri elencati di seguito possono vedere questa board.",
|
|
1994
|
+
"restrictToggle": "Limita ai membri"
|
|
1995
|
+
},
|
|
1996
|
+
"roster": {
|
|
1997
|
+
"title": "Membri",
|
|
1998
|
+
"empty": "Ancora nessun membro.",
|
|
1999
|
+
"remove": "Rimuovi membro"
|
|
2000
|
+
},
|
|
2001
|
+
"add": {
|
|
2002
|
+
"title": "Aggiungi un membro",
|
|
2003
|
+
"selectMember": "Seleziona un membro dell'account",
|
|
2004
|
+
"submit": "Aggiungi",
|
|
2005
|
+
"added": "Membro aggiunto",
|
|
2006
|
+
"allAdded": "Tutti i membri dell'account sono già su questa board.",
|
|
2007
|
+
"noAccount": "Questa board non è ancora collegata a un account."
|
|
2008
|
+
},
|
|
2009
|
+
"errors": {
|
|
2010
|
+
"load": "Impossibile caricare i membri",
|
|
2011
|
+
"accessMode": "Impossibile modificare l'accesso alla board",
|
|
2012
|
+
"setRole": "Impossibile aggiornare il ruolo del membro",
|
|
2013
|
+
"add": "Impossibile aggiungere il membro",
|
|
2014
|
+
"remove": "Impossibile rimuovere il membro"
|
|
2015
|
+
}
|
|
1981
2016
|
}
|
|
1982
2017
|
},
|
|
1983
2018
|
"board": {
|
package/i18n/locales/ja.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "チームが Claude Skills(SKILL.md フォルダーとリソースファイル)を作成しているリポジトリをリンクします。同期されたスキルはこのアカウントのカタログに追加され、すべてのボードで共有されます。パイプラインの Skill ステップがタスクに対してその 1 つを実行します。"
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name}(あなた)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "管理者",
|
|
1896
|
+
"member": "メンバー",
|
|
1897
|
+
"viewer": "閲覧者"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "ボードのアクセス",
|
|
1901
|
+
"accountHint": "所有アカウントの全員がこのボードを閲覧できます。",
|
|
1902
|
+
"restrictedHint": "以下に表示されているメンバーのみがこのボードを閲覧できます。",
|
|
1903
|
+
"restrictToggle": "メンバーに限定する"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "メンバー",
|
|
1907
|
+
"empty": "まだメンバーがいません。",
|
|
1908
|
+
"remove": "メンバーを削除"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "メンバーを追加",
|
|
1912
|
+
"selectMember": "アカウントメンバーを選択",
|
|
1913
|
+
"submit": "追加",
|
|
1914
|
+
"added": "メンバーを追加しました",
|
|
1915
|
+
"allAdded": "アカウントの全メンバーがすでにこのボードにいます。",
|
|
1916
|
+
"noAccount": "このボードはまだアカウントに紐づいていません。"
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "メンバーを読み込めませんでした",
|
|
1920
|
+
"accessMode": "ボードのアクセスを変更できませんでした",
|
|
1921
|
+
"setRole": "メンバーのロールを更新できませんでした",
|
|
1922
|
+
"add": "メンバーを追加できませんでした",
|
|
1923
|
+
"remove": "メンバーを削除できませんでした"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2600,7 +2634,8 @@
|
|
|
2600
2634
|
"budget": "予算",
|
|
2601
2635
|
"merge": "リスクポリシー",
|
|
2602
2636
|
"tracker": "課題トラッカー",
|
|
2603
|
-
"fragments": "サービスのベストプラクティス"
|
|
2637
|
+
"fragments": "サービスのベストプラクティス",
|
|
2638
|
+
"members": "メンバー"
|
|
2604
2639
|
},
|
|
2605
2640
|
"waiting": {
|
|
2606
2641
|
"heading": "人による対応待ち",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "Połącz repozytoria, w których Twój zespół tworzy Claude Skills (folder SKILL.md wraz z plikami zasobów). Zsynchronizowane umiejętności trafiają do katalogu tego konta, współdzielonego przez wszystkie tablice, a krok Skill w potoku uruchamia jedną z nich dla zadania."
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name} (Ty)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "Administrator",
|
|
1896
|
+
"member": "Członek",
|
|
1897
|
+
"viewer": "Obserwator"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "Dostęp do tablicy",
|
|
1901
|
+
"accountHint": "Każdy w koncie właściciela może zobaczyć tę tablicę.",
|
|
1902
|
+
"restrictedHint": "Tylko członkowie wymienieni poniżej mogą zobaczyć tę tablicę.",
|
|
1903
|
+
"restrictToggle": "Ogranicz do członków"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "Członkowie",
|
|
1907
|
+
"empty": "Brak członków.",
|
|
1908
|
+
"remove": "Usuń członka"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "Dodaj członka",
|
|
1912
|
+
"selectMember": "Wybierz członka konta",
|
|
1913
|
+
"submit": "Dodaj",
|
|
1914
|
+
"added": "Dodano członka",
|
|
1915
|
+
"allAdded": "Wszyscy członkowie konta są już na tej tablicy.",
|
|
1916
|
+
"noAccount": "Ta tablica nie jest jeszcze powiązana z kontem."
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "Nie udało się wczytać członków",
|
|
1920
|
+
"accessMode": "Nie udało się zmienić dostępu do tablicy",
|
|
1921
|
+
"setRole": "Nie udało się zaktualizować roli członka",
|
|
1922
|
+
"add": "Nie udało się dodać członka",
|
|
1923
|
+
"remove": "Nie udało się usunąć członka"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2478,7 +2512,8 @@
|
|
|
2478
2512
|
"budget": "Budżet",
|
|
2479
2513
|
"merge": "Zasady ryzyka",
|
|
2480
2514
|
"tracker": "System zgłoszeń",
|
|
2481
|
-
"fragments": "Dobre praktyki usługi"
|
|
2515
|
+
"fragments": "Dobre praktyki usługi",
|
|
2516
|
+
"members": "Członkowie"
|
|
2482
2517
|
},
|
|
2483
2518
|
"waiting": {
|
|
2484
2519
|
"heading": "Oczekiwanie na człowieka",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "Ekibinizin Claude Skills oluşturduğu depoları bağlayın (bir SKILL.md klasörü artı kaynak dosyaları). Senkronize edilen beceriler bu hesabın kataloguna eklenir, tüm panolarca paylaşılır ve bir hattın Skill adımı bir görev için bunlardan birini çalıştırır."
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name} (sen)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "Yönetici",
|
|
1896
|
+
"member": "Üye",
|
|
1897
|
+
"viewer": "İzleyici"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "Pano erişimi",
|
|
1901
|
+
"accountHint": "Sahip hesaptaki herkes bu panoyu görebilir.",
|
|
1902
|
+
"restrictedHint": "Bu panoyu yalnızca aşağıda listelenen üyeler görebilir.",
|
|
1903
|
+
"restrictToggle": "Üyelerle sınırla"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "Üyeler",
|
|
1907
|
+
"empty": "Henüz üye yok.",
|
|
1908
|
+
"remove": "Üyeyi kaldır"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "Üye ekle",
|
|
1912
|
+
"selectMember": "Bir hesap üyesi seç",
|
|
1913
|
+
"submit": "Ekle",
|
|
1914
|
+
"added": "Üye eklendi",
|
|
1915
|
+
"allAdded": "Hesabın tüm üyeleri zaten bu panoda.",
|
|
1916
|
+
"noAccount": "Bu pano henüz bir hesaba bağlı değil."
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "Üyeler yüklenemedi",
|
|
1920
|
+
"accessMode": "Pano erişimi değiştirilemedi",
|
|
1921
|
+
"setRole": "Üye rolü güncellenemedi",
|
|
1922
|
+
"add": "Üye eklenemedi",
|
|
1923
|
+
"remove": "Üye kaldırılamadı"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2600,7 +2634,8 @@
|
|
|
2600
2634
|
"budget": "Bütçe",
|
|
2601
2635
|
"merge": "Risk ilkeleri",
|
|
2602
2636
|
"tracker": "Sorun takipçisi",
|
|
2603
|
-
"fragments": "Servis en iyi uygulamaları"
|
|
2637
|
+
"fragments": "Servis en iyi uygulamaları",
|
|
2638
|
+
"members": "Üyeler"
|
|
2604
2639
|
},
|
|
2605
2640
|
"waiting": {
|
|
2606
2641
|
"heading": "Bir kişi bekleniyor",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -1888,6 +1888,40 @@
|
|
|
1888
1888
|
},
|
|
1889
1889
|
"accountSkills": {
|
|
1890
1890
|
"intro": "Пов'яжіть репозиторії, де ваша команда створює Claude Skills (папка SKILL.md разом із файлами ресурсів). Синхронізовані навички потрапляють до каталогу цього облікового запису, спільного для всіх дошок, а крок Skill конвеєра виконує одну з них для завдання."
|
|
1891
|
+
},
|
|
1892
|
+
"workspaceMembers": {
|
|
1893
|
+
"you": "{name} (ви)",
|
|
1894
|
+
"roles": {
|
|
1895
|
+
"admin": "Адміністратор",
|
|
1896
|
+
"member": "Учасник",
|
|
1897
|
+
"viewer": "Глядач"
|
|
1898
|
+
},
|
|
1899
|
+
"accessMode": {
|
|
1900
|
+
"title": "Доступ до дошки",
|
|
1901
|
+
"accountHint": "Усі в обліковому записі-власнику можуть бачити цю дошку.",
|
|
1902
|
+
"restrictedHint": "Цю дошку можуть бачити лише перелічені нижче учасники.",
|
|
1903
|
+
"restrictToggle": "Обмежити учасниками"
|
|
1904
|
+
},
|
|
1905
|
+
"roster": {
|
|
1906
|
+
"title": "Учасники",
|
|
1907
|
+
"empty": "Ще немає учасників.",
|
|
1908
|
+
"remove": "Вилучити учасника"
|
|
1909
|
+
},
|
|
1910
|
+
"add": {
|
|
1911
|
+
"title": "Додати учасника",
|
|
1912
|
+
"selectMember": "Виберіть учасника облікового запису",
|
|
1913
|
+
"submit": "Додати",
|
|
1914
|
+
"added": "Учасника додано",
|
|
1915
|
+
"allAdded": "Усі учасники облікового запису вже на цій дошці.",
|
|
1916
|
+
"noAccount": "Ця дошка ще не пов'язана з обліковим записом."
|
|
1917
|
+
},
|
|
1918
|
+
"errors": {
|
|
1919
|
+
"load": "Не вдалося завантажити учасників",
|
|
1920
|
+
"accessMode": "Не вдалося змінити доступ до дошки",
|
|
1921
|
+
"setRole": "Не вдалося оновити роль учасника",
|
|
1922
|
+
"add": "Не вдалося додати учасника",
|
|
1923
|
+
"remove": "Не вдалося вилучити учасника"
|
|
1924
|
+
}
|
|
1891
1925
|
}
|
|
1892
1926
|
},
|
|
1893
1927
|
"settings": {
|
|
@@ -2478,7 +2512,8 @@
|
|
|
2478
2512
|
"budget": "Бюджет",
|
|
2479
2513
|
"merge": "Політики ризику",
|
|
2480
2514
|
"tracker": "Трекер задач",
|
|
2481
|
-
"fragments": "Найкращі практики сервісу"
|
|
2515
|
+
"fragments": "Найкращі практики сервісу",
|
|
2516
|
+
"members": "Учасники"
|
|
2482
2517
|
},
|
|
2483
2518
|
"waiting": {
|
|
2484
2519
|
"heading": "Очікування на людину",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.131.0",
|
|
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",
|