@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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +143 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AddTaskModal.vue +444 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +183 -0
- package/app/components/board/ContextPicker.vue +367 -0
- package/app/components/board/RecurringPipelineModal.vue +219 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +433 -0
- package/app/components/board/nodes/DecisionBadge.vue +27 -0
- package/app/components/board/nodes/DraggableTask.vue +48 -0
- package/app/components/board/nodes/ModuleFrame.vue +97 -0
- package/app/components/board/nodes/TaskCard.vue +359 -0
- package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/clarity/ClarityReviewWindow.vue +611 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +171 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/gates/GateResultView.vue +282 -0
- package/app/components/github/AddServiceFromRepoModal.vue +354 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubOnboarding.vue +45 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/github/RepoTreeBrowser.vue +171 -0
- package/app/components/layout/AccountTeamSettings.vue +237 -0
- package/app/components/layout/BoardSwitcher.vue +280 -0
- package/app/components/layout/BoardToolbar.vue +156 -0
- package/app/components/layout/CommandBar.vue +336 -0
- package/app/components/layout/GitHubPatBanner.vue +73 -0
- package/app/components/layout/NotificationsInbox.vue +175 -0
- package/app/components/layout/SideBar.vue +314 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/observability/StepMetricsBar.vue +102 -0
- package/app/components/palettes/AgentPalette.vue +86 -0
- package/app/components/panels/AgentStepDetail.vue +737 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +465 -0
- package/app/components/panels/ObservabilityPanel.vue +351 -0
- package/app/components/panels/StepMetadataCard.vue +253 -0
- package/app/components/panels/StepRestartControl.vue +90 -0
- package/app/components/panels/StepResultViewHost.vue +40 -0
- package/app/components/panels/StepTestReport.vue +84 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
- package/app/components/panels/inspector/ServiceFragments.vue +82 -0
- package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
- package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
- package/app/components/panels/inspector/TaskExecution.vue +364 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
- package/app/components/panels/inspector/TaskStructure.vue +96 -0
- package/app/components/pipeline/AgentKindIcon.vue +30 -0
- package/app/components/pipeline/IterationCapPrompt.vue +70 -0
- package/app/components/pipeline/PipelineBuilder.vue +817 -0
- package/app/components/pipeline/PipelineProgress.vue +484 -0
- package/app/components/providers/ApiKeysSection.vue +273 -0
- package/app/components/providers/PersonalCredentialModal.vue +128 -0
- package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
- package/app/components/providers/VendorCredentialsModal.vue +197 -0
- package/app/components/recurring/RecurrenceEditor.vue +124 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
- package/app/components/settings/DatadogPanel.vue +213 -0
- package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
- package/app/components/settings/MergeThresholdsPanel.vue +378 -0
- package/app/components/settings/ModelDefaultsPanel.vue +250 -0
- package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
- package/app/components/slack/SlackPanel.vue +299 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +207 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
- package/app/components/testing/TestReportWindow.vue +404 -0
- package/app/composables/api/accounts.ts +81 -0
- package/app/composables/api/auth.ts +45 -0
- package/app/composables/api/board.ts +101 -0
- package/app/composables/api/bootstrap.ts +62 -0
- package/app/composables/api/context.ts +25 -0
- package/app/composables/api/documents.ts +74 -0
- package/app/composables/api/execution.ts +127 -0
- package/app/composables/api/fragments.ts +71 -0
- package/app/composables/api/github.ts +131 -0
- package/app/composables/api/models.ts +127 -0
- package/app/composables/api/notifications.ts +23 -0
- package/app/composables/api/presets.ts +29 -0
- package/app/composables/api/recurring.ts +68 -0
- package/app/composables/api/releaseHealth.ts +43 -0
- package/app/composables/api/reviews.ts +146 -0
- package/app/composables/api/slack.ts +54 -0
- package/app/composables/api/tasks.ts +72 -0
- package/app/composables/api/workspaces.ts +36 -0
- package/app/composables/useApi.ts +89 -0
- package/app/composables/useBlockDrag.ts +90 -0
- package/app/composables/useBlockQueries.ts +154 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useContextLinking.ts +65 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useFrameResize.ts +54 -0
- package/app/composables/useResultView.ts +48 -0
- package/app/composables/useReviewStage.ts +40 -0
- package/app/composables/useSemanticZoom.ts +31 -0
- package/app/composables/useStepApproval.ts +233 -0
- package/app/composables/useStepProse.ts +78 -0
- package/app/composables/useStepTimer.ts +63 -0
- package/app/composables/useTaskExpansion.ts +92 -0
- package/app/composables/useWorkspaceStream.ts +155 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +141 -0
- package/app/stores/accounts.ts +152 -0
- package/app/stores/agentConfig.ts +35 -0
- package/app/stores/agentRuns.ts +122 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/apiKeys.ts +108 -0
- package/app/stores/auth.ts +166 -0
- package/app/stores/board.spec.ts +205 -0
- package/app/stores/board.ts +286 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/clarity.ts +196 -0
- package/app/stores/consensus.ts +60 -0
- package/app/stores/documents.ts +176 -0
- package/app/stores/execution.ts +273 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +305 -0
- package/app/stores/localModels.ts +51 -0
- package/app/stores/mergePresets.ts +58 -0
- package/app/stores/modelDefaults.ts +76 -0
- package/app/stores/models.ts +134 -0
- package/app/stores/notifications.ts +70 -0
- package/app/stores/observability.ts +144 -0
- package/app/stores/personalSubscriptions.ts +215 -0
- package/app/stores/pipelines.ts +327 -0
- package/app/stores/recurringPipelines.ts +112 -0
- package/app/stores/releaseHealth.ts +75 -0
- package/app/stores/requirements.spec.ts +94 -0
- package/app/stores/requirements.ts +208 -0
- package/app/stores/serviceFragmentDefaults.ts +29 -0
- package/app/stores/services.ts +87 -0
- package/app/stores/slack.ts +142 -0
- package/app/stores/taskExpansion.ts +36 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +176 -0
- package/app/stores/tracker.ts +27 -0
- package/app/stores/ui.ts +434 -0
- package/app/stores/vendorCredentials.ts +54 -0
- package/app/stores/workspace.ts +215 -0
- package/app/stores/workspaceSettings.ts +36 -0
- package/app/types/accounts.ts +77 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/clarity.ts +59 -0
- package/app/types/consensus.ts +91 -0
- package/app/types/documents.ts +104 -0
- package/app/types/domain.ts +495 -0
- package/app/types/execution.ts +383 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +173 -0
- package/app/types/localModels.ts +73 -0
- package/app/types/merge.ts +71 -0
- package/app/types/models.ts +157 -0
- package/app/types/notifications.ts +74 -0
- package/app/types/recurring.ts +69 -0
- package/app/types/releaseHealth.ts +31 -0
- package/app/types/requirements.ts +61 -0
- package/app/types/services.ts +27 -0
- package/app/types/slack.ts +57 -0
- package/app/types/tasks.ts +82 -0
- package/app/types/tracker.ts +18 -0
- package/app/utils/agentOutput.spec.ts +128 -0
- package/app/utils/agentOutput.ts +173 -0
- package/app/utils/catalog.spec.ts +112 -0
- package/app/utils/catalog.ts +455 -0
- package/app/utils/dnd.ts +29 -0
- package/app/utils/observability.ts +52 -0
- package/app/utils/pipelineRender.ts +151 -0
- package/nuxt.config.ts +55 -0
- 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>
|