@cat-factory/app 0.6.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 (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. package/package.json +45 -0
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ // A reusable GitHub repo tree browser: lists one level of a repo at a time
3
+ // (breadcrumb-navigable) and lets the user PICK a path. Two modes:
4
+ // - `dir` — pick a subdirectory (the monorepo service-directory picker), and
5
+ // - `file` — pick a file (the service docker-compose location picker).
6
+ // The selected path (relative to the repo root, as GitHub returns it) is exposed
7
+ // via `v-model`. The component owns its own navigation/loading state so callers
8
+ // just bind a repo id + mode; it self-loads on mount and when those change.
9
+ import type { RepoTreeEntry } from '~/types/domain'
10
+
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ repoGithubId: number
14
+ mode?: 'dir' | 'file'
15
+ /** Currently picked path (repo-root-relative), via v-model. */
16
+ modelValue?: string
17
+ /** Directory to open at (e.g. a monorepo service's subdirectory). */
18
+ startPath?: string
19
+ }>(),
20
+ { mode: 'dir', startPath: '' },
21
+ )
22
+ const emit = defineEmits<{ 'update:modelValue': [string | undefined] }>()
23
+
24
+ const github = useGitHubStore()
25
+ const toast = useToast()
26
+
27
+ const currentPath = ref(props.startPath)
28
+ const treeEntries = ref<RepoTreeEntry[]>([])
29
+ const loading = ref(false)
30
+
31
+ const dirEntries = computed(() => treeEntries.value.filter((e) => e.type === 'dir'))
32
+ const fileEntries = computed(() => treeEntries.value.filter((e) => e.type === 'file'))
33
+ const isEmpty = computed(() =>
34
+ props.mode === 'dir' ? dirEntries.value.length === 0 : treeEntries.value.length === 0,
35
+ )
36
+
37
+ const breadcrumbs = computed(() => {
38
+ const segments = currentPath.value ? currentPath.value.split('/') : []
39
+ let acc = ''
40
+ return segments.map((seg) => {
41
+ acc = acc ? `${acc}/${seg}` : seg
42
+ return { label: seg, path: acc }
43
+ })
44
+ })
45
+
46
+ async function browseTo(path: string) {
47
+ loading.value = true
48
+ try {
49
+ currentPath.value = path
50
+ treeEntries.value = await github.loadRepoTree(props.repoGithubId, path)
51
+ } catch (e) {
52
+ treeEntries.value = []
53
+ toast.add({
54
+ title: 'Could not list directory',
55
+ description: e instanceof Error ? e.message : String(e),
56
+ icon: 'i-lucide-triangle-alert',
57
+ color: 'error',
58
+ })
59
+ } finally {
60
+ loading.value = false
61
+ }
62
+ }
63
+
64
+ function pick(path: string) {
65
+ emit('update:modelValue', path)
66
+ }
67
+
68
+ // Re-open at the start path whenever the repo (or requested root) changes.
69
+ watch(
70
+ () => [props.repoGithubId, props.startPath] as const,
71
+ () => void browseTo(props.startPath ?? ''),
72
+ { immediate: true },
73
+ )
74
+ </script>
75
+
76
+ <template>
77
+ <div>
78
+ <!-- breadcrumbs -->
79
+ <div class="mb-2 flex flex-wrap items-center gap-1 text-sm">
80
+ <UButton
81
+ size="xs"
82
+ variant="ghost"
83
+ color="neutral"
84
+ icon="i-lucide-folder-tree"
85
+ :disabled="loading"
86
+ @click="browseTo('')"
87
+ >
88
+ root
89
+ </UButton>
90
+ <template v-for="crumb in breadcrumbs" :key="crumb.path">
91
+ <span class="text-slate-600">/</span>
92
+ <UButton
93
+ size="xs"
94
+ variant="ghost"
95
+ color="neutral"
96
+ :disabled="loading"
97
+ @click="browseTo(crumb.path)"
98
+ >
99
+ {{ crumb.label }}
100
+ </UButton>
101
+ </template>
102
+ </div>
103
+
104
+ <!-- listing -->
105
+ <div class="max-h-56 overflow-auto rounded border border-slate-800">
106
+ <div v-if="loading" class="p-3 text-sm text-slate-400">Loading…</div>
107
+ <div v-else-if="isEmpty" class="p-3 text-sm text-slate-400">
108
+ {{ mode === 'dir' ? 'No subdirectories here.' : 'Nothing here.' }}
109
+ </div>
110
+ <ul v-else class="divide-y divide-slate-800">
111
+ <li
112
+ v-for="entry in dirEntries"
113
+ :key="entry.path"
114
+ class="flex items-center justify-between gap-2 px-3 py-1.5"
115
+ >
116
+ <button
117
+ type="button"
118
+ class="flex items-center gap-2 truncate text-sm text-slate-200 hover:text-primary-400"
119
+ @click="browseTo(entry.path)"
120
+ >
121
+ <UIcon name="i-lucide-folder" class="h-4 w-4 shrink-0 text-amber-400" />
122
+ <span class="truncate">{{ entry.name }}</span>
123
+ </button>
124
+ <UButton
125
+ v-if="mode === 'dir'"
126
+ size="xs"
127
+ variant="soft"
128
+ :color="modelValue === entry.path ? 'primary' : 'neutral'"
129
+ @click="pick(entry.path)"
130
+ >
131
+ {{ modelValue === entry.path ? 'Selected' : 'Select' }}
132
+ </UButton>
133
+ </li>
134
+ <template v-if="mode === 'file'">
135
+ <li
136
+ v-for="entry in fileEntries"
137
+ :key="entry.path"
138
+ class="flex items-center justify-between gap-2 px-3 py-1.5"
139
+ >
140
+ <button
141
+ type="button"
142
+ class="flex items-center gap-2 truncate text-sm hover:text-primary-400"
143
+ :class="modelValue === entry.path ? 'text-primary-400' : 'text-slate-300'"
144
+ @click="pick(entry.path)"
145
+ >
146
+ <UIcon name="i-lucide-file" class="h-4 w-4 shrink-0 text-slate-400" />
147
+ <span class="truncate">{{ entry.name }}</span>
148
+ </button>
149
+ <UIcon
150
+ v-if="modelValue === entry.path"
151
+ name="i-lucide-check"
152
+ class="h-4 w-4 shrink-0 text-primary-400"
153
+ />
154
+ </li>
155
+ </template>
156
+ </ul>
157
+ </div>
158
+
159
+ <!-- dir mode: pin the current folder without descending into a child -->
160
+ <div v-if="mode === 'dir' && currentPath" class="mt-2 flex justify-end">
161
+ <UButton
162
+ size="xs"
163
+ variant="soft"
164
+ :color="modelValue === currentPath ? 'primary' : 'neutral'"
165
+ @click="pick(currentPath)"
166
+ >
167
+ Use this folder
168
+ </UButton>
169
+ </div>
170
+ </div>
171
+ </template>
@@ -0,0 +1,237 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue'
3
+ import type { AccountRole } from '~/types/domain'
4
+
5
+ // Team settings for an org account: the member roster (with combinable admin /
6
+ // developer / product roles), pending email invitations, and the per-account
7
+ // transactional-email sender. Admin-only mutations are enforced by the backend; this
8
+ // surface degrades to a read-only view when the caller isn't an admin (actions 4xx).
9
+ const props = defineProps<{ accountId: string }>()
10
+
11
+ const accounts = useAccountsStore()
12
+ const toast = useToast()
13
+ const busy = ref(false)
14
+
15
+ const ROLE_ITEMS: { label: string; value: AccountRole }[] = [
16
+ { label: 'Admin', value: 'admin' },
17
+ { label: 'Developer', value: 'developer' },
18
+ { label: 'Product', value: 'product' },
19
+ ]
20
+
21
+ /** Whether the signed-in caller is an admin of this account (drives edit affordances). */
22
+ const isAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
23
+
24
+ async function updateMemberRoles(userId: string, roles: AccountRole[]) {
25
+ try {
26
+ await accounts.setMemberRoles(props.accountId, userId, roles.length ? roles : ['developer'])
27
+ } catch (e) {
28
+ notifyError('Could not update roles', e)
29
+ }
30
+ }
31
+
32
+ function notifyError(title: string, e: unknown) {
33
+ toast.add({
34
+ title,
35
+ description:
36
+ (e as { data?: { error?: { message?: string } } })?.data?.error?.message ??
37
+ (e instanceof Error ? e.message : String(e)),
38
+ icon: 'i-lucide-triangle-alert',
39
+ color: 'error',
40
+ })
41
+ }
42
+
43
+ onMounted(async () => {
44
+ try {
45
+ await Promise.all([
46
+ accounts.loadRoster(props.accountId),
47
+ accounts.loadEmailConnection(props.accountId),
48
+ ])
49
+ } catch (e) {
50
+ notifyError('Could not load team settings', e)
51
+ }
52
+ })
53
+
54
+ // ---- invitations ----------------------------------------------------------
55
+ const inviteEmail = ref('')
56
+ const inviteRoles = ref<AccountRole[]>(['developer'])
57
+
58
+ async function sendInvite() {
59
+ if (!inviteEmail.value.trim()) return
60
+ busy.value = true
61
+ try {
62
+ const acceptUrl = await accounts.invite(
63
+ props.accountId,
64
+ inviteEmail.value.trim(),
65
+ inviteRoles.value.length ? inviteRoles.value : ['developer'],
66
+ )
67
+ inviteEmail.value = ''
68
+ toast.add({
69
+ title: 'Invitation created',
70
+ description: acceptUrl
71
+ ? 'An email was sent (or copy the link from the list below).'
72
+ : 'Share the accept link with your teammate.',
73
+ icon: 'i-lucide-mail-check',
74
+ })
75
+ } catch (e) {
76
+ notifyError('Could not send invitation', e)
77
+ } finally {
78
+ busy.value = false
79
+ }
80
+ }
81
+
82
+ async function revoke(id: string) {
83
+ try {
84
+ await accounts.revokeInvite(props.accountId, id)
85
+ } catch (e) {
86
+ notifyError('Could not revoke invitation', e)
87
+ }
88
+ }
89
+
90
+ // ---- email sender ---------------------------------------------------------
91
+ const emailProvider = ref<'sendgrid' | 'resend'>('resend')
92
+ const emailApiKey = ref('')
93
+ const emailFrom = ref('')
94
+
95
+ async function connectEmail() {
96
+ if (!emailApiKey.value.trim() || !emailFrom.value.trim()) return
97
+ busy.value = true
98
+ try {
99
+ await accounts.connectEmail(props.accountId, {
100
+ provider: emailProvider.value,
101
+ apiKey: emailApiKey.value.trim(),
102
+ fromAddress: emailFrom.value.trim(),
103
+ })
104
+ emailApiKey.value = ''
105
+ toast.add({ title: 'Email sender connected', icon: 'i-lucide-check' })
106
+ } catch (e) {
107
+ notifyError('Could not connect email sender', e)
108
+ } finally {
109
+ busy.value = false
110
+ }
111
+ }
112
+
113
+ async function disconnectEmail() {
114
+ busy.value = true
115
+ try {
116
+ await accounts.disconnectEmail(props.accountId)
117
+ } catch (e) {
118
+ notifyError('Could not disconnect email sender', e)
119
+ } finally {
120
+ busy.value = false
121
+ }
122
+ }
123
+ </script>
124
+
125
+ <template>
126
+ <div class="space-y-6 text-sm">
127
+ <!-- members -->
128
+ <section>
129
+ <h3 class="mb-2 font-semibold text-white">Members</h3>
130
+ <ul class="space-y-1">
131
+ <li
132
+ v-for="m in accounts.members"
133
+ :key="m.userId"
134
+ class="flex items-center justify-between rounded-md bg-slate-800/40 px-2 py-1"
135
+ >
136
+ <span class="truncate">{{ m.name || m.email || m.userId }}</span>
137
+ <USelect
138
+ v-if="isAdmin"
139
+ :model-value="m.roles"
140
+ multiple
141
+ :items="ROLE_ITEMS"
142
+ size="xs"
143
+ class="w-44"
144
+ @update:model-value="(r: AccountRole[]) => updateMemberRoles(m.userId, r)"
145
+ />
146
+ <span v-else class="text-xs uppercase tracking-wide text-slate-400">
147
+ {{ m.roles.join(', ') }}
148
+ </span>
149
+ </li>
150
+ <li v-if="accounts.members.length === 0" class="text-slate-500">No members yet.</li>
151
+ </ul>
152
+ </section>
153
+
154
+ <!-- invitations -->
155
+ <section>
156
+ <h3 class="mb-2 font-semibold text-white">Invite a teammate</h3>
157
+ <form class="flex gap-2" @submit.prevent="sendInvite">
158
+ <UInput
159
+ v-model="inviteEmail"
160
+ type="email"
161
+ placeholder="teammate@example.com"
162
+ class="flex-1"
163
+ />
164
+ <USelect v-model="inviteRoles" multiple :items="ROLE_ITEMS" class="w-44" />
165
+ <UButton type="submit" color="primary" :loading="busy" icon="i-lucide-send">Invite</UButton>
166
+ </form>
167
+
168
+ <ul v-if="accounts.invitations.length" class="mt-3 space-y-1">
169
+ <li
170
+ v-for="inv in accounts.invitations"
171
+ :key="inv.id"
172
+ class="flex items-center justify-between rounded-md bg-slate-800/40 px-2 py-1"
173
+ >
174
+ <span class="truncate">{{ inv.email }}</span>
175
+ <span class="flex items-center gap-2 text-xs">
176
+ <span class="uppercase tracking-wide text-slate-400">{{ inv.status }}</span>
177
+ <UButton
178
+ v-if="inv.status === 'pending'"
179
+ size="xs"
180
+ color="error"
181
+ variant="ghost"
182
+ icon="i-lucide-x"
183
+ @click="revoke(inv.id)"
184
+ />
185
+ </span>
186
+ </li>
187
+ </ul>
188
+ </section>
189
+
190
+ <!-- email sender -->
191
+ <section>
192
+ <h3 class="mb-2 font-semibold text-white">Email sender</h3>
193
+ <p v-if="!accounts.emailConfigured" class="text-slate-500">
194
+ Email delivery is not enabled on this deployment. Invitations still produce a shareable
195
+ accept link.
196
+ </p>
197
+ <template v-else>
198
+ <div
199
+ v-if="accounts.emailConnection"
200
+ class="flex items-center justify-between rounded-md bg-slate-800/40 px-2 py-1.5"
201
+ >
202
+ <span>
203
+ Connected via <strong>{{ accounts.emailConnection.provider }}</strong> as
204
+ {{ accounts.emailConnection.fromAddress }}
205
+ </span>
206
+ <UButton size="xs" color="error" variant="ghost" :loading="busy" @click="disconnectEmail">
207
+ Disconnect
208
+ </UButton>
209
+ </div>
210
+ <form v-else class="space-y-2" @submit.prevent="connectEmail">
211
+ <USelect
212
+ v-model="emailProvider"
213
+ :items="[
214
+ { label: 'Resend', value: 'resend' },
215
+ { label: 'SendGrid', value: 'sendgrid' },
216
+ ]"
217
+ class="w-full"
218
+ />
219
+ <UInput v-model="emailFrom" type="email" placeholder="From address" class="w-full" />
220
+ <UInput
221
+ v-model="emailApiKey"
222
+ type="password"
223
+ placeholder="Provider API key"
224
+ class="w-full"
225
+ />
226
+ <UButton type="submit" color="primary" :loading="busy">Connect email sender</UButton>
227
+ </form>
228
+ </template>
229
+ </section>
230
+
231
+ <!-- account-wide provider API keys (admin-only) -->
232
+ <section v-if="isAdmin">
233
+ <h3 class="mb-2 font-semibold text-white">Account API keys</h3>
234
+ <ProvidersApiKeysSection :account-id="accountId" />
235
+ </section>
236
+ </div>
237
+ </template>
@@ -0,0 +1,280 @@
1
+ <script setup lang="ts">
2
+ import type { DropdownMenuItem } from '@nuxt/ui'
3
+ import type { CloudProvider } from '~/types/domain'
4
+
5
+ // Account + board switching. Picks the active account (personal / org) and the
6
+ // active board within it, and manages boards (new / rename / delete). The account
7
+ // row is shown only when accounts exist (auth on); in dev it falls back to a plain
8
+ // board switcher over the single unscoped context.
9
+ const accounts = useAccountsStore()
10
+ const workspace = useWorkspaceStore()
11
+ const toast = useToast()
12
+
13
+ const busy = ref(false)
14
+
15
+ function notifyError(title: string, e: unknown) {
16
+ toast.add({
17
+ title,
18
+ description: e instanceof Error ? e.message : String(e),
19
+ icon: 'i-lucide-triangle-alert',
20
+ color: 'error',
21
+ })
22
+ }
23
+
24
+ // The cloud provider new services in the active account default to (a service may
25
+ // override it per-frame). `docker` is the local Docker/Podman backend.
26
+ const PROVIDERS: { value: CloudProvider; label: string }[] = [
27
+ { value: 'cloudflare', label: 'Cloudflare' },
28
+ { value: 'docker', label: 'Docker (local)' },
29
+ { value: 'aws', label: 'AWS' },
30
+ { value: 'gcp', label: 'GCP' },
31
+ { value: 'azure', label: 'Azure' },
32
+ { value: 'custom', label: 'Custom' },
33
+ ]
34
+
35
+ async function setDefaultProvider(provider: CloudProvider) {
36
+ const id = accounts.activeAccountId
37
+ if (!id) return
38
+ try {
39
+ await accounts.setDefaultCloudProvider(id, provider)
40
+ } catch (e) {
41
+ notifyError('Could not update default provider', e)
42
+ }
43
+ }
44
+
45
+ // ---- account + board menus -------------------------------------------------
46
+ const accountItems = computed<DropdownMenuItem[][]>(() => [
47
+ accounts.accounts.map((a) => ({
48
+ label: a.name,
49
+ icon: a.type === 'org' ? 'i-lucide-users' : 'i-lucide-user',
50
+ trailingIcon: a.id === accounts.activeAccountId ? 'i-lucide-check' : undefined,
51
+ onSelect: () => void selectAccount(a.id),
52
+ })),
53
+ [
54
+ { 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
+ : []),
59
+ // Admins can set the account-wide default provider new services inherit.
60
+ ...(accounts.activeAccount?.roles?.includes('admin')
61
+ ? [
62
+ {
63
+ label: 'Default cloud provider',
64
+ icon: 'i-lucide-cloud',
65
+ children: PROVIDERS.map((p) => ({
66
+ label: p.label,
67
+ trailingIcon:
68
+ (accounts.activeAccount?.defaultCloudProvider ?? 'cloudflare') === p.value
69
+ ? 'i-lucide-check'
70
+ : undefined,
71
+ onSelect: () => void setDefaultProvider(p.value),
72
+ })),
73
+ },
74
+ ]
75
+ : []),
76
+ ],
77
+ ])
78
+
79
+ const boardItems = computed<DropdownMenuItem[][]>(() => [
80
+ workspace.accountWorkspaces.map((w) => ({
81
+ label: w.name,
82
+ icon: 'i-lucide-layout-dashboard',
83
+ trailingIcon: w.id === workspace.workspaceId ? 'i-lucide-check' : undefined,
84
+ onSelect: () => void switchBoard(w.id),
85
+ })),
86
+ [
87
+ { label: 'New board…', icon: 'i-lucide-plus', onSelect: () => openPrompt('board') },
88
+ { label: 'Rename board…', icon: 'i-lucide-pencil', onSelect: () => openPrompt('rename') },
89
+ {
90
+ label: 'Delete board',
91
+ icon: 'i-lucide-trash-2',
92
+ color: 'error' as const,
93
+ onSelect: () => void removeBoard(),
94
+ },
95
+ ],
96
+ ])
97
+
98
+ async function selectAccount(id: string) {
99
+ if (id === accounts.activeAccountId) return
100
+ busy.value = true
101
+ try {
102
+ await workspace.selectAccount(id)
103
+ } catch (e) {
104
+ notifyError('Could not switch account', e)
105
+ } finally {
106
+ busy.value = false
107
+ }
108
+ }
109
+
110
+ async function switchBoard(id: string) {
111
+ busy.value = true
112
+ try {
113
+ await workspace.switchTo(id)
114
+ } catch (e) {
115
+ notifyError('Could not open board', e)
116
+ } finally {
117
+ busy.value = false
118
+ }
119
+ }
120
+
121
+ async function removeBoard() {
122
+ const id = workspace.workspaceId
123
+ if (!id) return
124
+ busy.value = true
125
+ try {
126
+ await workspace.remove(id)
127
+ toast.add({ title: 'Board deleted', icon: 'i-lucide-check' })
128
+ } catch (e) {
129
+ notifyError('Could not delete board', e)
130
+ } finally {
131
+ busy.value = false
132
+ }
133
+ }
134
+
135
+ // ---- prompt modal (create account / create board / rename) -----------------
136
+ type PromptKind = 'account' | 'board' | 'rename'
137
+ const prompt = ref<PromptKind | null>(null)
138
+ const promptValue = ref('')
139
+ // Board create/rename also carries an optional description (Part C of onboarding).
140
+ const promptDescription = ref('')
141
+ const promptOpen = computed({
142
+ get: () => prompt.value !== null,
143
+ set: (v: boolean) => {
144
+ if (!v) prompt.value = null
145
+ },
146
+ })
147
+ const promptMeta: Record<PromptKind, { title: string; placeholder: string; cta: string }> = {
148
+ account: { title: 'New organization', placeholder: 'Acme Inc.', cta: 'Create' },
149
+ board: { title: 'New board', placeholder: 'Untitled board', cta: 'Create' },
150
+ rename: { title: 'Board settings', placeholder: 'Board name', cta: 'Save' },
151
+ }
152
+ /** Whether the current prompt edits a board (so it shows the description field). */
153
+ const promptHasDescription = computed(() => prompt.value === 'board' || prompt.value === 'rename')
154
+
155
+ function openPrompt(kind: PromptKind) {
156
+ prompt.value = kind
157
+ promptValue.value = kind === 'rename' ? (workspace.activeWorkspace?.name ?? '') : ''
158
+ promptDescription.value = kind === 'rename' ? (workspace.activeWorkspace?.description ?? '') : ''
159
+ }
160
+
161
+ async function submitPrompt() {
162
+ const kind = prompt.value
163
+ const name = promptValue.value.trim()
164
+ const description = promptDescription.value.trim()
165
+ if (!kind || (!name && kind !== 'board')) return
166
+ busy.value = true
167
+ try {
168
+ if (kind === 'account') {
169
+ await accounts.createOrg(name)
170
+ // The new org starts empty — open (create) its first board.
171
+ await workspace.selectAccount(accounts.activeAccountId!)
172
+ } else if (kind === 'board') {
173
+ await workspace.create(name || undefined, description || undefined)
174
+ } else if (workspace.workspaceId) {
175
+ await workspace.update(workspace.workspaceId, {
176
+ name,
177
+ description: description || null,
178
+ })
179
+ }
180
+ prompt.value = null
181
+ } catch (e) {
182
+ notifyError('Action failed', e)
183
+ } finally {
184
+ busy.value = false
185
+ }
186
+ }
187
+
188
+ // ---- account settings modal (members / invitations / email) ----------------
189
+ const settingsOpen = ref(false)
190
+ function openSettings() {
191
+ settingsOpen.value = true
192
+ }
193
+ </script>
194
+
195
+ <template>
196
+ <div class="space-y-1.5">
197
+ <!-- account selector (only when accounts exist) -->
198
+ <UDropdownMenu
199
+ v-if="accounts.enabled"
200
+ :items="accountItems"
201
+ :content="{ align: 'start' }"
202
+ class="w-full"
203
+ >
204
+ <button
205
+ type="button"
206
+ class="flex w-full items-center gap-2 rounded-md px-1.5 py-1 text-left transition hover:bg-slate-800/60"
207
+ :disabled="busy"
208
+ >
209
+ <UIcon
210
+ :name="accounts.activeAccount?.type === 'org' ? 'i-lucide-users' : 'i-lucide-user'"
211
+ class="h-3.5 w-3.5 shrink-0 text-slate-400"
212
+ />
213
+ <span class="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
214
+ {{ accounts.activeAccount?.name ?? 'Account' }}
215
+ </span>
216
+ <UIcon
217
+ name="i-lucide-chevrons-up-down"
218
+ class="ml-auto h-3.5 w-3.5 shrink-0 text-slate-600"
219
+ />
220
+ </button>
221
+ </UDropdownMenu>
222
+
223
+ <!-- board selector -->
224
+ <UDropdownMenu :items="boardItems" :content="{ align: 'start' }" class="w-full">
225
+ <button
226
+ type="button"
227
+ class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 px-2.5 py-1.5 text-left transition hover:bg-slate-800/60"
228
+ :disabled="busy"
229
+ >
230
+ <UIcon name="i-lucide-layout-dashboard" class="h-4 w-4 shrink-0 text-indigo-400" />
231
+ <span class="truncate text-sm font-medium text-white">
232
+ {{ workspace.activeWorkspace?.name ?? 'Board' }}
233
+ </span>
234
+ <UIcon name="i-lucide-chevron-down" class="ml-auto h-4 w-4 shrink-0 text-slate-500" />
235
+ </button>
236
+ </UDropdownMenu>
237
+
238
+ <!-- create / rename prompt -->
239
+ <UModal v-model:open="promptOpen" :title="prompt ? promptMeta[prompt].title : ''">
240
+ <template #body>
241
+ <form class="space-y-3" @submit.prevent="submitPrompt">
242
+ <UFormField label="Name">
243
+ <UInput
244
+ v-model="promptValue"
245
+ autofocus
246
+ :placeholder="prompt ? promptMeta[prompt].placeholder : ''"
247
+ class="w-full"
248
+ />
249
+ </UFormField>
250
+ <UFormField v-if="promptHasDescription" label="Description" hint="Optional">
251
+ <UTextarea
252
+ v-model="promptDescription"
253
+ :rows="3"
254
+ placeholder="What is this board for?"
255
+ class="w-full"
256
+ />
257
+ </UFormField>
258
+ <div class="flex justify-end gap-2">
259
+ <UButton color="neutral" variant="ghost" :disabled="busy" @click="prompt = null">
260
+ Cancel
261
+ </UButton>
262
+ <UButton type="submit" color="primary" :loading="busy">
263
+ {{ prompt ? promptMeta[prompt].cta : '' }}
264
+ </UButton>
265
+ </div>
266
+ </form>
267
+ </template>
268
+ </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
+ </div>
280
+ </template>