Skip to content

Commit 3b33951

Browse files
Dumbrisclaude
andcommitted
feat(web): surface connect/disconnect backup path in wizard and Connect modal
First slice of specs/078-connect-trust-preview (backup visibility, spec US2 / FR-005+FR-006): the backend has always written a timestamped backup before modifying a client config and returned it as ConnectResult.backup_path (internal/connect/backup.go, connect.go), and the CLI prints it — but the Web UI dropped it. Now: - ConnectModal.vue: after a successful connect OR disconnect, the result area shows "A backup of your previous config was saved to <path>" with a one-click "Copy path" button (same navigator.clipboard pattern as the existing copy-tccutil affordance). A success with no backup_path renders the explicit "No prior config file existed, so no backup was needed." message instead of a blank path (FR-006 no-prior-file case, e.g. bridge clients whose config is created by connect). - OnboardingWizard.vue: ClientRow now renders the same backup line (path + Copy path, or the no-prior-file message) inside the client's row after a connect performed in the wizard session. TDD: added 3 failing ConnectModal cases (backup path + copy, no-prior-file, disconnect backup) to tests/unit/connect-modal.spec.ts and a new tests/unit/onboarding-wizard-backup-path.spec.ts (2 cases) before implementing; full frontend suite passes (32 files, 235 tests). Built via make build so the Go binary embeds the updated web/frontend/dist. Assumptions (zero-interruption policy): - The TS contract already carries backup_path?: string on ConnectResult (frontend/src/types/api.ts) — no contract change needed. - An empty-string backup_path on success is treated the same as absent (backend omits it when no prior file existed; never render a blank path). - Preview/undo/TCC copy (spec US1/US3/US4) intentionally NOT built here. - No tasks.md exists under specs/078-connect-trust-preview/ — nothing to check off; spec.md left untouched. - npm-ci metadata churn in frontend/package-lock.json was reverted, not committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eff06ce commit 3b33951

4 files changed

Lines changed: 432 additions & 8 deletions

File tree

frontend/src/components/ConnectModal.vue

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,34 @@
129129
<div class="alert alert-sm" :class="resultSuccess ? 'alert-success' : 'alert-error'">
130130
<span class="text-sm">{{ resultMessage }}</span>
131131
</div>
132+
<!-- Spec 078 US2 / FR-006: surface the timestamped backup after a
133+
successful connect/disconnect; the "no prior file" case is stated
134+
explicitly rather than showing a blank path. -->
135+
<div
136+
v-if="resultSuccess && resultBackupPath"
137+
data-test="connect-backup-path"
138+
class="mt-2 flex items-start justify-between gap-2 rounded-lg bg-base-200 px-3 py-2"
139+
>
140+
<span class="text-xs leading-relaxed min-w-0">
141+
A backup of your previous config was saved to
142+
<code class="font-mono text-[11px] break-all" :title="resultBackupPath">{{ resultBackupPath }}</code>
143+
</span>
144+
<button
145+
data-test="connect-copy-backup"
146+
@click="copyBackupPath"
147+
class="btn btn-ghost btn-xs shrink-0"
148+
title="Copy the backup path to the clipboard"
149+
>
150+
{{ copiedBackup ? 'Copied ✓' : 'Copy path' }}
151+
</button>
152+
</div>
153+
<div
154+
v-else-if="resultSuccess && resultBackupPath === null"
155+
data-test="connect-no-backup"
156+
class="mt-2 rounded-lg bg-base-200 px-3 py-2 text-xs opacity-70"
157+
>
158+
No prior config file existed, so no backup was needed.
159+
</div>
132160
</div>
133161

134162
<div class="modal-action">
@@ -170,6 +198,11 @@ const clients = ref<ClientStatus[]>([])
170198
const error = ref<string | null>(null)
171199
const resultMessage = ref('')
172200
const resultSuccess = ref(false)
201+
// Spec 078 US2: backup path of the last successful connect/disconnect.
202+
// string = timestamped backup created; null = success but no prior file to
203+
// back up; undefined = no successful operation to report on.
204+
const resultBackupPath = ref<string | null | undefined>(undefined)
205+
const copiedBackup = ref(false)
173206
const loading = reactive({
174207
initial: false,
175208
clients: {} as Record<string, boolean>,
@@ -259,12 +292,16 @@ async function fetchClients() {
259292
async function connect(clientId: string) {
260293
loading.clients[clientId] = true
261294
resultMessage.value = ''
295+
resultBackupPath.value = undefined
296+
copiedBackup.value = false
262297
263298
try {
264299
const response = await api.connectClient(clientId)
265300
if (response.success && response.data) {
266301
resultMessage.value = response.data.message || `Connected to ${clientId}`
267302
resultSuccess.value = true
303+
// Empty/absent backup_path on success means no prior file existed.
304+
resultBackupPath.value = response.data.backup_path || null
268305
await fetchClients()
269306
systemStore.addToast({
270307
type: 'success',
@@ -290,13 +327,16 @@ async function connect(clientId: string) {
290327
async function disconnect(clientId: string) {
291328
loading.clients[clientId] = true
292329
resultMessage.value = ''
330+
resultBackupPath.value = undefined
331+
copiedBackup.value = false
293332
294333
try {
295334
const client = clients.value.find(c => c.id === clientId)
296335
const response = await api.disconnectClient(clientId, client?.server_name || 'mcpproxy')
297336
if (response.success && response.data) {
298337
resultMessage.value = response.data.message || `Disconnected from ${clientId}`
299338
resultSuccess.value = true
339+
resultBackupPath.value = response.data.backup_path || null
300340
await fetchClients()
301341
systemStore.addToast({
302342
type: 'info',
@@ -373,6 +413,22 @@ async function copyTccutil(client: ClientStatus) {
373413
}
374414
}
375415
416+
// Spec 078 US2: one-click copy of the backup path (same clipboard pattern as
417+
// copyTccutil above).
418+
async function copyBackupPath() {
419+
if (!resultBackupPath.value) return
420+
try {
421+
await navigator.clipboard.writeText(resultBackupPath.value)
422+
copiedBackup.value = true
423+
setTimeout(() => {
424+
copiedBackup.value = false
425+
}, 2000)
426+
} catch {
427+
// Clipboard unavailable (e.g. insecure context): the full path is already
428+
// rendered, so the user can select and copy it manually.
429+
}
430+
}
431+
376432
async function connectAll() {
377433
for (const client of connectableClients.value) {
378434
await connect(client.id)
@@ -381,6 +437,8 @@ async function connectAll() {
381437
382438
function close() {
383439
resultMessage.value = ''
440+
resultBackupPath.value = undefined
441+
copiedBackup.value = false
384442
emit('close')
385443
}
386444
@@ -392,6 +450,8 @@ watch(() => props.show, (newVal) => {
392450
fetchClients()
393451
void onboarding.fetchState()
394452
resultMessage.value = ''
453+
resultBackupPath.value = undefined
454+
copiedBackup.value = false
395455
}
396456
})
397457
</script>

frontend/src/components/OnboardingWizard.vue

Lines changed: 85 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@
6868
:key="c.id"
6969
:client="c"
7070
:busy="busyClients[c.id]"
71+
:backup-path="connectBackups[c.id]"
72+
:copied-backup="copiedBackupClient === c.id"
73+
@copy-backup="copyBackupPath"
7174
@connect="connectOne"
7275
/>
7376
</div>
@@ -80,6 +83,9 @@
8083
:key="c.id"
8184
:client="c"
8285
:busy="busyClients[c.id]"
86+
:backup-path="connectBackups[c.id]"
87+
:copied-backup="copiedBackupClient === c.id"
88+
@copy-backup="copyBackupPath"
8389
@connect="connectOne"
8490
/>
8591
</div>
@@ -96,6 +102,9 @@
96102
:key="c.id"
97103
:client="c"
98104
:busy="busyClients[c.id]"
105+
:backup-path="connectBackups[c.id]"
106+
:copied-backup="copiedBackupClient === c.id"
107+
@copy-backup="copyBackupPath"
99108
@connect="connectOne"
100109
/>
101110
</div>
@@ -518,6 +527,11 @@ const clientsError = ref<string | null>(null)
518527
const busyClients = reactive<Record<string, boolean>>({})
519528
const connectMessage = ref('')
520529
const connectMessageOk = ref(true)
530+
// Spec 078 US2 / FR-006: backup path per client for connects performed in this
531+
// wizard session. string = timestamped backup created; null = success but no
532+
// prior file existed (nothing to back up); absent = no connect happened yet.
533+
const connectBackups = reactive<Record<string, string | null>>({})
534+
const copiedBackupClient = ref<string | null>(null)
521535
const addServerOpen = ref(false)
522536
const serverAddedJustNow = ref(false)
523537
@@ -1028,6 +1042,9 @@ async function connectOne(clientId: string) {
10281042
if (res.success && res.data) {
10291043
connectMessageOk.value = true
10301044
connectMessage.value = res.data.message || `Connected ${clientId}`
1045+
// Spec 078 US2: keep the backup path so the row can surface it; an
1046+
// empty/absent backup_path on success means no prior file existed.
1047+
connectBackups[clientId] = res.data.backup_path || null
10311048
await fetchClients()
10321049
// Spec 046 — record connect-step completion for telemetry funnel.
10331050
// Only the first successful connect transitions the status; subsequent
@@ -1053,6 +1070,22 @@ async function connectOne(clientId: string) {
10531070
}
10541071
}
10551072
1073+
// Spec 078 US2: one-click copy of a row's backup path (same clipboard pattern
1074+
// as ConnectModal's copy affordances).
1075+
async function copyBackupPath(clientId: string) {
1076+
const path = connectBackups[clientId]
1077+
if (!path) return
1078+
try {
1079+
await navigator.clipboard.writeText(path)
1080+
copiedBackupClient.value = clientId
1081+
setTimeout(() => {
1082+
if (copiedBackupClient.value === clientId) copiedBackupClient.value = null
1083+
}, 2000)
1084+
} catch {
1085+
// Clipboard unavailable: the full path is already rendered in the row.
1086+
}
1087+
}
1088+
10561089
function openAddServer() {
10571090
addServerOpen.value = true
10581091
}
@@ -1111,14 +1144,14 @@ onMounted(() => {
11111144
// --- ClientRow component ------------------------------------------------
11121145
// Inlined as a functional component to keep this file self-contained while
11131146
// the row layout stays consistent across all three lists.
1114-
const ClientRow: FunctionalComponent<{ client: ClientStatus; busy?: boolean }, { connect: (id: string) => void }> = (props, { emit: rowEmit }) => {
1147+
const ClientRow: FunctionalComponent<
1148+
{ client: ClientStatus; busy?: boolean; backupPath?: string | null; copiedBackup?: boolean },
1149+
{ connect: (id: string) => void; 'copy-backup': (id: string) => void }
1150+
> = (props, { emit: rowEmit }) => {
11151151
const c = props.client
1116-
return h(
1152+
const row = h(
11171153
'div',
1118-
{
1119-
class: 'flex items-center justify-between p-2 rounded-lg border border-base-300',
1120-
'data-test': `client-row-${c.id}`,
1121-
},
1154+
{ class: 'flex items-center justify-between' },
11221155
[
11231156
h('div', { class: 'min-w-0 flex-1' }, [
11241157
h('div', { class: 'font-medium text-sm truncate' }, c.name),
@@ -1146,7 +1179,51 @@ const ClientRow: FunctionalComponent<{ client: ClientStatus; busy?: boolean }, {
11461179
]),
11471180
]
11481181
)
1182+
// Spec 078 US2 / FR-006: after a connect performed in this wizard session,
1183+
// surface the timestamped backup (or the honest "no prior file" case) right
1184+
// in the row. undefined = no connect happened for this client yet.
1185+
const backupLine =
1186+
props.backupPath !== undefined
1187+
? h(
1188+
'div',
1189+
{
1190+
class: 'mt-1 flex items-start justify-between gap-2 text-[11px] opacity-70',
1191+
'data-test': `client-backup-${c.id}`,
1192+
},
1193+
props.backupPath
1194+
? [
1195+
h('span', { class: 'min-w-0 break-all leading-relaxed' }, [
1196+
'A backup of your previous config was saved to ',
1197+
h('code', { class: 'font-mono', title: props.backupPath }, props.backupPath),
1198+
]),
1199+
h(
1200+
'button',
1201+
{
1202+
class: 'btn btn-ghost btn-xs shrink-0',
1203+
'data-test': `client-copy-backup-${c.id}`,
1204+
title: 'Copy the backup path to the clipboard',
1205+
onClick: () => rowEmit('copy-backup', c.id),
1206+
},
1207+
props.copiedBackup ? 'Copied ✓' : 'Copy path'
1208+
),
1209+
]
1210+
: 'No prior config file existed, so no backup was needed.'
1211+
)
1212+
: null
1213+
return h(
1214+
'div',
1215+
{
1216+
class: 'p-2 rounded-lg border border-base-300',
1217+
'data-test': `client-row-${c.id}`,
1218+
},
1219+
backupLine ? [row, backupLine] : [row]
1220+
)
1221+
}
1222+
ClientRow.props = {
1223+
client: { type: Object, required: true },
1224+
busy: { type: Boolean, default: false },
1225+
backupPath: { type: String, default: undefined },
1226+
copiedBackup: { type: Boolean, default: false },
11491227
}
1150-
ClientRow.props = { client: { type: Object, required: true }, busy: { type: Boolean, default: false } }
1151-
ClientRow.emits = ['connect']
1228+
ClientRow.emits = ['connect', 'copy-backup']
11521229
</script>

0 commit comments

Comments
 (0)