@cat-factory/app 0.32.2 → 0.34.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.
@@ -1,15 +1,12 @@
1
1
  <script setup lang="ts">
2
- // Prompt-fragment library manager (ADR 0006): curate this board's best-practice
3
- // fragments, link repos of Markdown guidelines (with a "changes available" badge
4
- // + resync), and review the merged catalog (built-in ∪ account ∪ workspace) an
5
- // agent is selected from per run. Workspace-tier focused; the resolved view shows
6
- // every tier so the inheritance is visible.
7
- import type { DocumentSourceKind, ResolvedFragment } from '~/types/domain'
2
+ // The board (workspace-tier) prompt-fragment library modal (ADR 0006). A thin shell
3
+ // around the shared FragmentLibraryManager at the active board's scope including
4
+ // the resolved/merged catalog view so the built-in ∪ account ∪ workspace inheritance
5
+ // is visible. Opened from the navbar / command bar via the ui store.
6
+ import FragmentLibraryManager from '~/components/fragments/FragmentLibraryManager.vue'
8
7
 
9
8
  const ui = useUiStore()
10
- const library = useFragmentLibraryStore()
11
- const documents = useDocumentsStore()
12
- const toast = useToast()
9
+ const workspace = useWorkspaceStore()
13
10
 
14
11
  const open = computed({
15
12
  get: () => ui.fragmentLibraryOpen,
@@ -17,460 +14,17 @@ const open = computed({
17
14
  if (!v) ui.closeFragmentLibrary()
18
15
  },
19
16
  })
20
-
21
- watch(open, (isOpen) => {
22
- if (isOpen) {
23
- void library.probe()
24
- void documents.probe()
25
- }
26
- })
27
-
28
- type Tab = 'catalog' | 'authored' | 'documents' | 'sources'
29
- const tab = ref<Tab>('catalog')
30
-
31
- const tierLabel: Record<ResolvedFragment['tier'], string> = {
32
- builtin: 'Built-in',
33
- account: 'Account',
34
- workspace: 'This board',
35
- }
36
- // `as const` keeps the literal color names (assignable to UBadge's `color`
37
- // union) instead of widening to `string`; `satisfies` still checks the shape.
38
- const tierColor = {
39
- builtin: 'neutral',
40
- account: 'info',
41
- workspace: 'primary',
42
- } as const satisfies Record<ResolvedFragment['tier'], string>
43
-
44
- function notifyError(title: string, e: unknown) {
45
- toast.add({
46
- title,
47
- description: e instanceof Error ? e.message : String(e),
48
- icon: 'i-lucide-triangle-alert',
49
- color: 'error',
50
- })
51
- }
52
-
53
- // ---- create a hand-authored fragment --------------------------------------
54
- const draft = ref({ title: '', summary: '', body: '', tags: '' })
55
- const draftValid = computed(
56
- () => draft.value.title.trim() && draft.value.summary.trim() && draft.value.body.trim(),
57
- )
58
-
59
- async function createFragment() {
60
- if (!draftValid.value) return
61
- try {
62
- await library.create({
63
- title: draft.value.title.trim(),
64
- summary: draft.value.summary.trim(),
65
- body: draft.value.body.trim(),
66
- tags: draft.value.tags
67
- .split(',')
68
- .map((t) => t.trim())
69
- .filter(Boolean),
70
- })
71
- draft.value = { title: '', summary: '', body: '', tags: '' }
72
- toast.add({ title: 'Fragment added', icon: 'i-lucide-check' })
73
- } catch (e) {
74
- notifyError('Could not add fragment', e)
75
- }
76
- }
77
-
78
- async function removeFragment(id: string) {
79
- try {
80
- await library.remove(id)
81
- toast.add({ title: 'Fragment removed', icon: 'i-lucide-trash-2' })
82
- } catch (e) {
83
- notifyError('Could not remove fragment', e)
84
- }
85
- }
86
-
87
- // ---- document-backed (living) fragments -----------------------------------
88
- // Link a Confluence/Notion page or GitHub file as a fragment that is re-resolved
89
- // from the source at run time (a living source of truth, not a frozen snapshot).
90
- const docDraft = ref({ source: '' as DocumentSourceKind | '', ref: '', tags: '' })
91
- const docDraftValid = computed(() => docDraft.value.source && docDraft.value.ref.trim())
92
-
93
- /** The board's existing document-backed fragments (workspace tier). */
94
- const documentFragments = computed(() => library.fragments.filter((f) => f.documentRef))
95
-
96
- async function linkDocumentFragment() {
97
- if (!docDraftValid.value) return
98
- try {
99
- await library.createDocumentFragment({
100
- source: docDraft.value.source as DocumentSourceKind,
101
- ref: docDraft.value.ref.trim(),
102
- tags: docDraft.value.tags
103
- .split(',')
104
- .map((t) => t.trim())
105
- .filter(Boolean),
106
- })
107
- docDraft.value = { source: '', ref: '', tags: '' }
108
- toast.add({ title: 'Document linked as a living fragment', icon: 'i-lucide-link' })
109
- } catch (e) {
110
- notifyError('Could not link document', e)
111
- }
112
- }
113
-
114
- async function refreshFragment(id: string) {
115
- try {
116
- await library.refreshDocumentFragment(id)
117
- toast.add({ title: 'Fragment re-resolved from source', icon: 'i-lucide-refresh-cw' })
118
- } catch (e) {
119
- notifyError('Could not refresh fragment', e)
120
- }
121
- }
122
-
123
- // ---- repo sources ----------------------------------------------------------
124
- const sourceDraft = ref({ repoOwner: '', repoName: '', dirPath: '', gitRef: '' })
125
- const sourceValid = computed(
126
- () => sourceDraft.value.repoOwner.trim() && sourceDraft.value.repoName.trim(),
127
- )
128
-
129
- async function linkSource() {
130
- if (!sourceValid.value) return
131
- try {
132
- const source = await library.linkSource({
133
- repoOwner: sourceDraft.value.repoOwner.trim(),
134
- repoName: sourceDraft.value.repoName.trim(),
135
- dirPath: sourceDraft.value.dirPath.trim() || undefined,
136
- gitRef: sourceDraft.value.gitRef.trim() || undefined,
137
- })
138
- sourceDraft.value = { repoOwner: '', repoName: '', dirPath: '', gitRef: '' }
139
- await library.syncSource(source.id)
140
- toast.add({ title: 'Source linked & synced', icon: 'i-lucide-git-branch' })
141
- } catch (e) {
142
- notifyError('Could not link source', e)
143
- }
144
- }
145
-
146
- async function syncSource(id: string) {
147
- try {
148
- const result = await library.syncSource(id)
149
- toast.add({
150
- title: `Synced: ${result.upserted} updated, ${result.tombstoned} removed`,
151
- icon: 'i-lucide-refresh-cw',
152
- color: 'info',
153
- })
154
- } catch (e) {
155
- notifyError('Could not sync source', e)
156
- }
157
- }
158
-
159
- async function checkSource(id: string) {
160
- try {
161
- const status = await library.checkSource(id)
162
- toast.add({
163
- title: status.changed ? `${status.changedCount} change(s) available` : 'Up to date',
164
- icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
165
- })
166
- } catch (e) {
167
- notifyError('Could not check source', e)
168
- }
169
- }
170
-
171
- async function unlinkSource(id: string) {
172
- try {
173
- await library.unlinkSource(id)
174
- toast.add({ title: 'Source unlinked', icon: 'i-lucide-unplug' })
175
- } catch (e) {
176
- notifyError('Could not unlink source', e)
177
- }
178
- }
179
17
  </script>
180
18
 
181
19
  <template>
182
20
  <UModal v-model:open="open" title="Prompt-fragment library" :ui="{ content: 'max-w-3xl' }">
183
21
  <template #body>
184
- <div class="flex flex-col gap-4">
185
- <p class="text-sm text-slate-400">
186
- Curate the best-practice guidelines agents follow on this board. Fragments are merged from
187
- the built-in catalog, your account, and this board — later tiers override earlier ones —
188
- then the relevant ones are selected for each agent run.
189
- </p>
190
-
191
- <div class="flex gap-2">
192
- <UButton
193
- v-for="t in ['catalog', 'authored', 'documents', 'sources'] as Tab[]"
194
- :key="t"
195
- :color="tab === t ? 'primary' : 'neutral'"
196
- :variant="tab === t ? 'solid' : 'ghost'"
197
- size="sm"
198
- @click="tab = t"
199
- >
200
- {{
201
- t === 'catalog'
202
- ? 'Resolved catalog'
203
- : t === 'authored'
204
- ? 'This board'
205
- : t === 'documents'
206
- ? 'Documents'
207
- : 'Repo sources'
208
- }}
209
- </UButton>
210
- </div>
211
-
212
- <!-- Resolved (merged) catalog -->
213
- <div v-if="tab === 'catalog'" class="flex flex-col gap-2">
214
- <p class="text-xs text-slate-500">
215
- {{ library.resolved.length }} fragment(s) resolved ·
216
- {{ library.builtinCount }} built-in.
217
- </p>
218
- <div
219
- v-for="f in library.resolved"
220
- :key="f.id"
221
- class="rounded-md border border-slate-800 bg-slate-900/60 p-3"
222
- >
223
- <div class="flex items-center gap-2">
224
- <span class="font-medium text-slate-100">{{ f.title }}</span>
225
- <UBadge size="xs" :color="tierColor[f.tier]" variant="subtle">
226
- {{ tierLabel[f.tier] }}
227
- </UBadge>
228
- <UBadge
229
- v-if="f.documentRef"
230
- size="xs"
231
- color="success"
232
- variant="subtle"
233
- icon="i-lucide-radio"
234
- >
235
- Live · {{ f.documentRef.source }}
236
- </UBadge>
237
- <span class="ml-auto font-mono text-[11px] text-slate-500">{{ f.id }}</span>
238
- </div>
239
- <p class="mt-1 text-sm text-slate-400">{{ f.summary }}</p>
240
- <div v-if="f.tags?.length" class="mt-1 flex flex-wrap gap-1">
241
- <UBadge v-for="tag in f.tags" :key="tag" size="xs" variant="outline" color="neutral">
242
- {{ tag }}
243
- </UBadge>
244
- </div>
245
- </div>
246
- </div>
247
-
248
- <!-- Hand-authored (workspace tier) -->
249
- <div v-else-if="tab === 'authored'" class="flex flex-col gap-3">
250
- <div
251
- v-for="f in library.fragments"
252
- :key="f.id"
253
- class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
254
- >
255
- <div class="min-w-0">
256
- <div class="flex items-center gap-2">
257
- <span class="font-medium text-slate-100">{{ f.title }}</span>
258
- <UBadge v-if="f.source" size="xs" color="info" variant="subtle">from repo</UBadge>
259
- </div>
260
- <p class="text-sm text-slate-400">{{ f.summary }}</p>
261
- </div>
262
- <UButton
263
- icon="i-lucide-trash-2"
264
- size="xs"
265
- color="error"
266
- variant="ghost"
267
- class="ml-auto"
268
- @click="removeFragment(f.id)"
269
- />
270
- </div>
271
- <p v-if="!library.fragments.length" class="text-sm text-slate-500">
272
- No board-specific fragments yet. Add one below, or override a built-in by using its id.
273
- </p>
274
-
275
- <div class="rounded-md border border-slate-800 p-3">
276
- <p class="mb-2 text-sm font-medium">Add a fragment</p>
277
- <div class="flex flex-col gap-2">
278
- <UInput v-model="draft.title" placeholder="Title" />
279
- <UInput
280
- v-model="draft.summary"
281
- placeholder="One-line summary (used by the selector)"
282
- />
283
- <UTextarea
284
- v-model="draft.body"
285
- placeholder="Guidance body (injected into the prompt)"
286
- :rows="4"
287
- />
288
- <UInput v-model="draft.tags" placeholder="Tags, comma-separated (e.g. backend, db)" />
289
- <UButton
290
- icon="i-lucide-plus"
291
- size="sm"
292
- :disabled="!draftValid"
293
- :loading="library.loading"
294
- class="self-start"
295
- @click="createFragment"
296
- >
297
- Add fragment
298
- </UButton>
299
- </div>
300
- </div>
301
- </div>
302
-
303
- <!-- Document-backed (living) fragments -->
304
- <div v-else-if="tab === 'documents'" class="flex flex-col gap-3">
305
- <p class="text-xs text-slate-500">
306
- Link a Confluence/Notion page or a GitHub file as a best-practice fragment. Its guidance
307
- is re-resolved from the source at run time — edit the doc and the next agent run follows
308
- the new version (no re-import).
309
- </p>
310
-
311
- <div
312
- v-for="f in documentFragments"
313
- :key="f.id"
314
- class="flex items-start gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
315
- >
316
- <UIcon name="i-lucide-radio" class="mt-0.5 h-4 w-4 text-emerald-400" />
317
- <div class="min-w-0">
318
- <div class="flex items-center gap-2">
319
- <span class="font-medium text-slate-100">{{ f.title }}</span>
320
- <UBadge size="xs" color="success" variant="subtle">
321
- {{ f.documentRef?.source }}
322
- </UBadge>
323
- </div>
324
- <p class="text-sm text-slate-400">{{ f.summary }}</p>
325
- <p v-if="f.resolvedAt" class="text-[11px] text-slate-500">
326
- last resolved {{ new Date(f.resolvedAt).toLocaleString() }}
327
- </p>
328
- </div>
329
- <div class="ml-auto flex gap-1">
330
- <UButton
331
- icon="i-lucide-refresh-cw"
332
- size="xs"
333
- variant="ghost"
334
- :loading="library.loading"
335
- title="Re-resolve from source now"
336
- @click="refreshFragment(f.id)"
337
- />
338
- <UButton
339
- icon="i-lucide-trash-2"
340
- size="xs"
341
- color="error"
342
- variant="ghost"
343
- @click="removeFragment(f.id)"
344
- />
345
- </div>
346
- </div>
347
- <p v-if="!documentFragments.length" class="text-sm text-slate-500">
348
- No document-backed fragments yet. Link one below.
349
- </p>
350
-
351
- <div class="rounded-md border border-slate-800 p-3">
352
- <p class="mb-2 text-sm font-medium">Link a document</p>
353
- <div v-if="!documents.connectedSources.length" class="text-sm text-slate-500">
354
- Connect a document source (Confluence, Notion or GitHub) under Integrations first.
355
- </div>
356
- <div v-else class="flex flex-col gap-2">
357
- <div class="flex flex-wrap gap-2">
358
- <UButton
359
- v-for="s in documents.connectedSources"
360
- :key="s.source"
361
- size="xs"
362
- :color="docDraft.source === s.source ? 'primary' : 'neutral'"
363
- :variant="docDraft.source === s.source ? 'solid' : 'outline'"
364
- @click="docDraft.source = s.source"
365
- >
366
- {{ s.label }}
367
- </UButton>
368
- </div>
369
- <UInput
370
- v-model="docDraft.ref"
371
- placeholder="Page id or URL (e.g. a Confluence/Notion page or GitHub file URL)"
372
- />
373
- <UInput v-model="docDraft.tags" placeholder="Tags, comma-separated (optional)" />
374
- <UButton
375
- icon="i-lucide-link"
376
- size="sm"
377
- :disabled="!docDraftValid"
378
- :loading="library.loading"
379
- class="self-start"
380
- @click="linkDocumentFragment"
381
- >
382
- Link as living fragment
383
- </UButton>
384
- </div>
385
- </div>
386
- </div>
387
-
388
- <!-- Repo sources -->
389
- <div v-else class="flex flex-col gap-3">
390
- <div
391
- v-for="s in library.sources"
392
- :key="s.id"
393
- class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/60 p-3"
394
- >
395
- <UIcon name="i-lucide-git-branch" class="h-4 w-4 text-slate-400" />
396
- <div class="min-w-0">
397
- <span class="font-mono text-sm text-slate-100">
398
- {{ s.repoOwner }}/{{ s.repoName
399
- }}<span class="text-slate-500">/{{ s.dirPath || '' }}</span>
400
- </span>
401
- <p class="text-xs text-slate-500">
402
- {{ s.lastSyncedAt ? 'synced' : 'never synced' }} · ref {{ s.gitRef }}
403
- </p>
404
- </div>
405
- <UBadge
406
- v-if="library.sourceChanges[s.id]"
407
- size="xs"
408
- color="warning"
409
- variant="subtle"
410
- class="ml-auto"
411
- >
412
- {{ library.sourceChanges[s.id] }} change(s)
413
- </UBadge>
414
- <div class="ml-auto flex gap-1">
415
- <UButton
416
- icon="i-lucide-search-check"
417
- size="xs"
418
- variant="ghost"
419
- @click="checkSource(s.id)"
420
- />
421
- <UButton
422
- icon="i-lucide-refresh-cw"
423
- size="xs"
424
- variant="ghost"
425
- :loading="library.loading"
426
- @click="syncSource(s.id)"
427
- />
428
- <UButton
429
- icon="i-lucide-unplug"
430
- size="xs"
431
- color="error"
432
- variant="ghost"
433
- @click="unlinkSource(s.id)"
434
- />
435
- </div>
436
- </div>
437
- <p v-if="!library.sources.length" class="text-sm text-slate-500">
438
- No linked guideline repos. Link one below to import its Markdown files as fragments.
439
- </p>
440
-
441
- <div class="rounded-md border border-slate-800 p-3">
442
- <p class="mb-2 text-sm font-medium">Link a guideline repo</p>
443
- <div class="flex flex-col gap-2">
444
- <div class="flex gap-2">
445
- <UInput v-model="sourceDraft.repoOwner" placeholder="owner" class="flex-1" />
446
- <UInput v-model="sourceDraft.repoName" placeholder="repo" class="flex-1" />
447
- </div>
448
- <div class="flex gap-2">
449
- <UInput
450
- v-model="sourceDraft.dirPath"
451
- placeholder="dir path (e.g. guidelines)"
452
- class="flex-1"
453
- />
454
- <UInput
455
- v-model="sourceDraft.gitRef"
456
- placeholder="ref (default HEAD)"
457
- class="flex-1"
458
- />
459
- </div>
460
- <UButton
461
- icon="i-lucide-link"
462
- size="sm"
463
- :disabled="!sourceValid"
464
- :loading="library.loading"
465
- class="self-start"
466
- @click="linkSource"
467
- >
468
- Link & sync
469
- </UButton>
470
- </div>
471
- </div>
472
- </div>
473
- </div>
22
+ <FragmentLibraryManager
23
+ v-if="workspace.workspaceId"
24
+ kind="workspace"
25
+ :owner-id="workspace.workspaceId"
26
+ show-catalog
27
+ />
474
28
  </template>
475
29
  </UModal>
476
30
  </template>
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ // Account-tier prompt-fragment library (ADR 0006): best-practice guidelines shared by
3
+ // every board in the account, which each board inherits and can override. A body-only
4
+ // section rendered in the "Context fragments" tab of AccountSettingsPanel; available for
5
+ // ALL account types (unlike the org-only Team tab).
6
+ import { computed } from 'vue'
7
+ import FragmentLibraryManager from '~/components/fragments/FragmentLibraryManager.vue'
8
+
9
+ const props = defineProps<{ accountId: string }>()
10
+ const workspace = useWorkspaceStore()
11
+
12
+ // Account-tier document fragments are fetched through a board's stored
13
+ // document-source connection (credentials are per-workspace). Prefer the active
14
+ // board when it belongs to this account, else the account's first board.
15
+ const viaWorkspaceId = computed(() => {
16
+ const boards = workspace.accountWorkspaces
17
+ return boards.find((w) => w.id === workspace.workspaceId)?.id ?? boards[0]?.id
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <div class="space-y-6 text-sm">
23
+ <section>
24
+ <p class="mb-3 text-[11px] text-slate-400">
25
+ Best-practice guidelines shared by every board in this account. Each board inherits these
26
+ and can override or add its own. Code-aware agents (coder, reviewer, architect, fixers) fold
27
+ the relevant ones into their prompt.
28
+ </p>
29
+ <FragmentLibraryManager
30
+ kind="account"
31
+ :owner-id="accountId"
32
+ :via-workspace-id="viaWorkspaceId"
33
+ :show-catalog="false"
34
+ />
35
+ </section>
36
+ </div>
37
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, ref } from 'vue'
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
3
  import type { AccountRole } from '~/types/domain'
4
4
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
5
5
 
@@ -21,6 +21,12 @@ const ROLE_ITEMS: { label: string; value: AccountRole }[] = [
21
21
 
22
22
  /** Whether the signed-in caller is an admin of this account (drives edit affordances). */
23
23
  const isAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
24
+ /**
25
+ * Members / roles / invitations are org-scoped — the backend rejects membership on a
26
+ * personal account. For a personal account we show a "create an organization" CTA in
27
+ * their place; the email sender + account API keys remain available either way.
28
+ */
29
+ const isOrg = computed(() => accounts.activeAccount?.type === 'org')
24
30
 
25
31
  async function updateMemberRoles(userId: string, roles: AccountRole[]) {
26
32
  try {
@@ -41,16 +47,44 @@ function notifyError(title: string, e: unknown) {
41
47
  })
42
48
  }
43
49
 
44
- onMounted(async () => {
50
+ async function loadAll(accountId: string) {
45
51
  try {
46
- await Promise.all([
47
- accounts.loadRoster(props.accountId),
48
- accounts.loadEmailConnection(props.accountId),
49
- ])
52
+ const jobs: Promise<unknown>[] = [accounts.loadEmailConnection(accountId)]
53
+ // The roster only applies to org accounts.
54
+ if (isOrg.value) jobs.push(accounts.loadRoster(accountId))
55
+ await Promise.all(jobs)
50
56
  } catch (e) {
51
57
  notifyError('Could not load team settings', e)
52
58
  }
53
- })
59
+ }
60
+
61
+ onMounted(() => void loadAll(props.accountId))
62
+ // Reload when the active account changes while the panel is open (e.g. after creating an
63
+ // organization from the CTA below, which switches the active account to the new org).
64
+ watch(
65
+ () => props.accountId,
66
+ (id) => {
67
+ if (id) void loadAll(id)
68
+ },
69
+ )
70
+
71
+ // ---- create organization (personal-account CTA) ---------------------------
72
+ const newOrgName = ref('')
73
+
74
+ async function createOrganization() {
75
+ const name = newOrgName.value.trim()
76
+ if (!name) return
77
+ busy.value = true
78
+ try {
79
+ await accounts.createOrg(name)
80
+ newOrgName.value = ''
81
+ toast.add({ title: 'Organization created', icon: 'i-lucide-check' })
82
+ } catch (e) {
83
+ notifyError('Could not create organization', e)
84
+ } finally {
85
+ busy.value = false
86
+ }
87
+ }
54
88
 
55
89
  // ---- invitations ----------------------------------------------------------
56
90
  const inviteEmail = ref('')
@@ -125,8 +159,23 @@ async function disconnectEmail() {
125
159
 
126
160
  <template>
127
161
  <div class="space-y-6 text-sm">
162
+ <!-- personal-account CTA: members/roles/invitations need an organization -->
163
+ <section v-if="!isOrg" class="rounded-md border border-slate-800 bg-slate-800/40 p-4">
164
+ <h3 class="mb-1 font-semibold text-white">Invite teammates &amp; manage roles</h3>
165
+ <p class="mb-3 text-slate-400">
166
+ Members, roles and invitations live on an organization. Create one to invite teammates and
167
+ manage their roles — your personal boards stay as they are.
168
+ </p>
169
+ <form class="flex gap-2" @submit.prevent="createOrganization">
170
+ <UInput v-model="newOrgName" placeholder="Acme Inc." class="flex-1" />
171
+ <UButton type="submit" color="primary" :loading="busy" icon="i-lucide-plus">
172
+ Create organization
173
+ </UButton>
174
+ </form>
175
+ </section>
176
+
128
177
  <!-- members -->
129
- <section>
178
+ <section v-if="isOrg">
130
179
  <h3 class="mb-2 font-semibold text-white">Members</h3>
131
180
  <ul class="space-y-1">
132
181
  <li
@@ -153,7 +202,7 @@ async function disconnectEmail() {
153
202
  </section>
154
203
 
155
204
  <!-- invitations -->
156
- <section>
205
+ <section v-if="isOrg">
157
206
  <h3 class="mb-2 font-semibold text-white">Invite a teammate</h3>
158
207
  <form class="flex gap-2" @submit.prevent="sendInvite">
159
208
  <UInput
@@ -8,6 +8,7 @@ import type { CloudProvider } from '~/types/domain'
8
8
  // board switcher over the single unscoped context.
9
9
  const accounts = useAccountsStore()
10
10
  const workspace = useWorkspaceStore()
11
+ const ui = useUiStore()
11
12
  const toast = useToast()
12
13
 
13
14
  const busy = ref(false)
@@ -52,10 +53,14 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
52
53
  })),
53
54
  [
54
55
  { label: 'New organization…', icon: 'i-lucide-plus', onSelect: () => openPrompt('account') },
55
- // Team management (members + invitations + email sender) for org accounts.
56
- ...(accounts.activeAccount?.type === 'org'
57
- ? [{ label: 'Manage team…', icon: 'i-lucide-users', onSelect: () => openSettings() }]
58
- : []),
56
+ // Account settings: the unified per-account panel (team + roles + invitations + email
57
+ // sender + account-tier fragment library). The panel itself handles personal accounts
58
+ // (prompting to create an org for the team tab), so this is not gated.
59
+ {
60
+ label: 'Account settings…',
61
+ icon: 'i-lucide-settings',
62
+ onSelect: () => ui.openAccountSettings(),
63
+ },
59
64
  // Admins can set the account-wide default provider new services inherit.
60
65
  ...(accounts.activeAccount?.roles?.includes('admin')
61
66
  ? [
@@ -184,12 +189,6 @@ async function submitPrompt() {
184
189
  busy.value = false
185
190
  }
186
191
  }
187
-
188
- // ---- account settings modal (members / invitations / email) ----------------
189
- const settingsOpen = ref(false)
190
- function openSettings() {
191
- settingsOpen.value = true
192
- }
193
192
  </script>
194
193
 
195
194
  <template>
@@ -266,15 +265,5 @@ function openSettings() {
266
265
  </form>
267
266
  </template>
268
267
  </UModal>
269
-
270
- <!-- account team settings: members, invitations, email sender -->
271
- <UModal v-model:open="settingsOpen" title="Team settings">
272
- <template #body>
273
- <AccountTeamSettings
274
- v-if="accounts.activeAccountId"
275
- :account-id="accounts.activeAccountId"
276
- />
277
- </template>
278
- </UModal>
279
268
  </div>
280
269
  </template>