Skip to content

Commit 960f65a

Browse files
committed
feat(060): settings UX polish — copy key, confirm regen/opt-out, validate duration, name prompts
API key field: - add a copy-to-clipboard button (with a transient ✓ + clipboard fallback); - regenerating the key now opens a confirmation dialog first (the value only changes after you confirm, and nothing persists until Save). Confirmations: - turning OFF "Anonymous usage telemetry" now asks for confirmation with an informational (non-alarming) tone explaining telemetry helps improve mcpproxy — new DangerSpec.tone:'info' (neutral styling, no "sensitive" badge). Validation: - duration fields (e.g. Tool call timeout) are validated against Go duration syntax; invalid input shows an inline error and blocks Save. Shared validateField() now gates both number and duration fields. Clarity: - "Expose MCP prompts" now names the built-in prompts (setup-new-mcp-server, troubleshoot-mcp-server). Verification spec extended to cover copy button, regen confirm, telemetry opt-out confirm, and duration validation. Relates to Spec 060.
1 parent 75baf46 commit 960f65a

4 files changed

Lines changed: 213 additions & 79 deletions

File tree

frontend/src/components/settings/SettingField.vue

Lines changed: 108 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
<span v-if="field.restart" class="badge badge-warning badge-xs gap-1" title="Requires restart">
2323
restart
2424
</span>
25-
<span v-if="field.danger" class="badge badge-error badge-xs" title="Sensitive change">
25+
<span v-if="field.danger && field.danger.tone !== 'info'" class="badge badge-error badge-xs" title="Sensitive change">
2626
sensitive
2727
</span>
2828
</div>
@@ -53,54 +53,88 @@
5353
</select>
5454

5555
<!-- number -->
56-
<input
57-
v-else-if="field.control === 'number'"
58-
type="number"
59-
class="input input-bordered input-sm w-32"
60-
:class="{ 'input-error': numberError }"
61-
:value="modelValue"
62-
:min="field.min"
63-
:max="field.max"
64-
:step="field.step"
65-
:data-test="`setting-number-${field.key}`"
66-
@input="onNumber(($event.target as HTMLInputElement).value)"
67-
/>
56+
<div v-else-if="field.control === 'number'" class="flex flex-col items-end">
57+
<input
58+
type="number"
59+
class="input input-bordered input-sm w-32"
60+
:class="{ 'input-error': validationError }"
61+
:value="modelValue"
62+
:min="field.min"
63+
:max="field.max"
64+
:step="field.step"
65+
:data-test="`setting-number-${field.key}`"
66+
@input="onNumber(($event.target as HTMLInputElement).value)"
67+
/>
68+
<span v-if="validationError" class="text-error text-xs mt-1" :data-test="`setting-error-${field.key}`">{{ validationError }}</span>
69+
</div>
6870

6971
<!-- secret -->
70-
<div v-else-if="field.control === 'secret'" class="join">
72+
<template v-else-if="field.control === 'secret'">
73+
<div class="join">
74+
<input
75+
:type="showSecret ? 'text' : 'password'"
76+
class="input input-bordered input-sm join-item w-56 font-mono"
77+
:value="modelValue ?? ''"
78+
:placeholder="field.placeholder"
79+
:data-test="`setting-secret-${field.key}`"
80+
@input="emitVal(($event.target as HTMLInputElement).value)"
81+
/>
82+
<button type="button" class="btn btn-sm join-item" :title="showSecret ? 'Hide' : 'Show'" @click="showSecret = !showSecret">
83+
{{ showSecret ? '🙈' : '👁' }}
84+
</button>
85+
<button
86+
type="button"
87+
class="btn btn-sm join-item"
88+
:class="{ 'btn-success': copied }"
89+
:data-test="`setting-copy-${field.key}`"
90+
:title="copied ? 'Copied!' : 'Copy to clipboard'"
91+
:disabled="!modelValue"
92+
@click="copy"
93+
>
94+
{{ copied ? '✓' : '📋' }}
95+
</button>
96+
<button
97+
type="button"
98+
class="btn btn-sm join-item"
99+
:data-test="`setting-regenerate-${field.key}`"
100+
title="Generate a new random key"
101+
@click="regenEl?.showModal()"
102+
>
103+
104+
</button>
105+
</div>
106+
<dialog ref="regenEl" class="modal" :data-test="`setting-regenerate-confirm-${field.key}`">
107+
<div class="modal-box">
108+
<h3 class="font-bold text-lg">Generate a new {{ field.label }}?</h3>
109+
<p class="text-sm mt-2">
110+
A new random value will replace the current one in the field. Nothing changes until you click
111+
<b>Save changes</b> — after saving you'll need to update connected clients and restart.
112+
</p>
113+
<div class="modal-action">
114+
<button class="btn btn-sm" @click="regenEl?.close()" :data-test="`setting-regenerate-cancel-${field.key}`">Cancel</button>
115+
<button class="btn btn-sm btn-primary" @click="confirmRegenerate" :data-test="`setting-regenerate-proceed-${field.key}`">
116+
Generate
117+
</button>
118+
</div>
119+
</div>
120+
<form method="dialog" class="modal-backdrop"><button>close</button></form>
121+
</dialog>
122+
</template>
123+
124+
<!-- text / duration -->
125+
<div v-else-if="field.control === 'text' || field.control === 'duration'" class="flex flex-col items-end">
71126
<input
72-
:type="showSecret ? 'text' : 'password'"
73-
class="input input-bordered input-sm join-item w-56 font-mono"
127+
type="text"
128+
class="input input-bordered input-sm w-56 font-mono"
129+
:class="{ 'input-error': validationError }"
74130
:value="modelValue ?? ''"
75131
:placeholder="field.placeholder"
76-
:data-test="`setting-secret-${field.key}`"
132+
:data-test="`setting-text-${field.key}`"
77133
@input="emitVal(($event.target as HTMLInputElement).value)"
78134
/>
79-
<button type="button" class="btn btn-sm join-item" :title="showSecret ? 'Hide' : 'Show'" @click="showSecret = !showSecret">
80-
{{ showSecret ? '🙈' : '👁' }}
81-
</button>
82-
<button
83-
type="button"
84-
class="btn btn-sm join-item"
85-
:data-test="`setting-regenerate-${field.key}`"
86-
title="Generate a new random key"
87-
@click="regenerate"
88-
>
89-
90-
</button>
135+
<span v-if="validationError" class="text-error text-xs mt-1" :data-test="`setting-error-${field.key}`">{{ validationError }}</span>
91136
</div>
92137

93-
<!-- text / duration -->
94-
<input
95-
v-else-if="field.control === 'text' || field.control === 'duration'"
96-
type="text"
97-
class="input input-bordered input-sm w-56 font-mono"
98-
:value="modelValue ?? ''"
99-
:placeholder="field.placeholder"
100-
:data-test="`setting-text-${field.key}`"
101-
@input="emitVal(($event.target as HTMLInputElement).value)"
102-
/>
103-
104138
<!-- multiselect (checkbox group) -->
105139
<div v-else-if="field.control === 'multiselect'" class="flex flex-wrap gap-3 justify-end max-w-xs">
106140
<label v-for="opt in field.options" :key="opt.value" class="label cursor-pointer gap-1 p-0">
@@ -120,22 +154,46 @@
120154

121155
<script setup lang="ts">
122156
import { ref, computed } from 'vue'
123-
import { docsUrl, type SettingField } from '@/views/settings/fields'
157+
import { docsUrl, validateField, type SettingField } from '@/views/settings/fields'
124158
125159
const props = defineProps<{ field: SettingField; modelValue: any; dirty?: boolean }>()
126160
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>()
127161
128162
const showSecret = ref(false)
163+
const copied = ref(false)
164+
const regenEl = ref<HTMLDialogElement | null>(null)
129165
const docsHref = computed(() => docsUrl(props.field.docs))
130166
131-
const numberError = computed(() => {
132-
if (props.field.control !== 'number' || props.modelValue == null || props.modelValue === '') return false
133-
const n = Number(props.modelValue)
134-
if (Number.isNaN(n)) return true
135-
if (props.field.min != null && n < props.field.min) return true
136-
if (props.field.max != null && n > props.field.max) return true
137-
return false
138-
})
167+
async function copy() {
168+
const val = props.modelValue
169+
if (!val) return
170+
try {
171+
await navigator.clipboard.writeText(String(val))
172+
} catch {
173+
// clipboard API unavailable (e.g. non-secure context) — fall back
174+
const ta = document.createElement('textarea')
175+
ta.value = String(val)
176+
document.body.appendChild(ta)
177+
ta.select()
178+
try {
179+
document.execCommand('copy')
180+
} catch {
181+
/* ignore */
182+
}
183+
document.body.removeChild(ta)
184+
}
185+
copied.value = true
186+
setTimeout(() => {
187+
copied.value = false
188+
}, 1500)
189+
}
190+
191+
function confirmRegenerate() {
192+
regenEl.value?.close()
193+
regenerate()
194+
}
195+
196+
const validationError = computed(() => validateField(props.field, props.modelValue))
139197
140198
function emitVal(v: any) {
141199
emit('update:modelValue', v)
@@ -166,5 +224,5 @@ function regenerate() {
166224
emitVal(hex)
167225
}
168226
169-
defineExpose({ numberError })
227+
defineExpose({ validationError })
170228
</script>

frontend/src/components/settings/SettingsSection.vue

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -49,14 +49,23 @@
4949
<!-- danger confirm -->
5050
<dialog ref="confirmEl" class="modal" :data-test="`settings-confirm-${sectionId}`">
5151
<div class="modal-box">
52-
<h3 class="font-bold text-lg text-error">Confirm sensitive change</h3>
52+
<h3 class="font-bold text-lg" :class="pendingInfoOnly ? '' : 'text-error'">
53+
{{ pendingInfoOnly ? 'Are you sure?' : 'Confirm sensitive change' }}
54+
</h3>
5355
<ul class="list-disc list-inside text-sm mt-3 space-y-2">
5456
<li v-for="(m, i) in pendingMessages" :key="i">{{ m }}</li>
5557
</ul>
5658
<div class="modal-action">
57-
<button class="btn btn-sm" @click="cancelConfirm" data-test="settings-confirm-cancel">Cancel</button>
58-
<button class="btn btn-sm btn-error" @click="proceedConfirm" data-test="settings-confirm-proceed">
59-
Apply anyway
59+
<button class="btn btn-sm" @click="cancelConfirm" data-test="settings-confirm-cancel">
60+
{{ pendingInfoOnly ? 'Keep it on' : 'Cancel' }}
61+
</button>
62+
<button
63+
class="btn btn-sm"
64+
:class="pendingInfoOnly ? 'btn-primary' : 'btn-error'"
65+
@click="proceedConfirm"
66+
data-test="settings-confirm-proceed"
67+
>
68+
{{ pendingInfoOnly ? 'Turn off anyway' : 'Apply anyway' }}
6069
</button>
6170
</div>
6271
</div>
@@ -68,7 +77,7 @@
6877
<script setup lang="ts">
6978
import { ref, computed, getCurrentInstance } from 'vue'
7079
import SettingField from './SettingField.vue'
71-
import { getPath, setPath, buildPartial, type SettingField as Field } from '@/views/settings/fields'
80+
import { getPath, setPath, buildPartial, validateField, type SettingField as Field } from '@/views/settings/fields'
7281
import { useSystemStore } from '@/stores/system'
7382
import api from '@/services/api'
7483
@@ -87,21 +96,13 @@ const lastResult = ref<any>(null)
8796
const dirty = ref<Record<string, true>>({})
8897
const confirmEl = ref<HTMLDialogElement | null>(null)
8998
const pendingMessages = ref<string[]>([])
99+
const pendingInfoOnly = ref(false)
90100
91101
const dirtyKeys = computed(() => Object.keys(dirty.value))
92102
93-
// crude validity gate: any number field out of range blocks save
103+
// Block Save when any field (number/duration) is invalid.
94104
const hasInvalid = computed(() =>
95-
props.fields.some((f) => {
96-
if (f.control !== 'number') return false
97-
const v = getPath(props.working, f.key)
98-
if (v === '' || v == null) return true
99-
const n = Number(v)
100-
if (Number.isNaN(n)) return true
101-
if (f.min != null && n < f.min) return true
102-
if (f.max != null && n > f.max) return true
103-
return false
104-
})
105+
props.fields.some((f) => validateField(f, getPath(props.working, f.key)) != null)
105106
)
106107
107108
function eq(a: any, b: any): boolean {
@@ -125,21 +126,22 @@ function isLoopback(addr: any): boolean {
125126
return /^(127\.|localhost|\[::1\]|::1)/.test(s.replace(/^.*@/, '')) || s.startsWith('127.0.0.1')
126127
}
127128
128-
function dangerMessages(): string[] {
129-
const msgs: string[] = []
129+
function dangerMessages(): Array<{ message: string; tone: 'danger' | 'info' }> {
130+
const out: Array<{ message: string; tone: 'danger' | 'info' }> = []
130131
for (const key of dirtyKeys.value) {
131132
const f = props.fields.find((x) => x.key === key)
132133
if (!f?.danger) continue
133134
const val = getPath(props.working, key)
135+
const entry = { message: f.danger.message, tone: f.danger.tone ?? 'danger' }
134136
if ('confirmValue' in f.danger) {
135-
if (eq(val, f.danger.confirmValue)) msgs.push(f.danger.message)
137+
if (eq(val, f.danger.confirmValue)) out.push(entry)
136138
} else if (f.key === 'listen') {
137-
if (!isLoopback(val)) msgs.push(f.danger.message)
139+
if (!isLoopback(val)) out.push(entry)
138140
} else {
139-
msgs.push(f.danger.message)
141+
out.push(entry)
140142
}
141143
}
142-
return msgs
144+
return out
143145
}
144146
145147
function discard() {
@@ -152,9 +154,10 @@ function discard() {
152154
}
153155
154156
function attemptSave() {
155-
const msgs = dangerMessages()
156-
if (msgs.length) {
157-
pendingMessages.value = msgs
157+
const items = dangerMessages()
158+
if (items.length) {
159+
pendingMessages.value = items.map((i) => i.message)
160+
pendingInfoOnly.value = items.every((i) => i.tone === 'info')
158161
confirmEl.value?.showModal()
159162
return
160163
}

frontend/src/views/settings/fields.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export interface DangerSpec {
2323
// equals confirmValue (omit to always confirm on change).
2424
message: string
2525
confirmValue?: unknown
26+
// 'danger' (default) = security-risky change, shown red + "sensitive" badge.
27+
// 'info' = a gentle "are you sure?" (e.g. opting out of telemetry) — neutral
28+
// styling, no sensitive badge.
29+
tone?: 'danger' | 'info'
2630
}
2731

2832
export interface SettingField {
@@ -56,6 +60,29 @@ export function docsUrl(path?: string): string | undefined {
5660
return path.startsWith('http') ? path : DOCS_BASE + path
5761
}
5862

63+
// Matches Go time.Duration syntax: one or more <number><unit> segments,
64+
// e.g. "2m", "90s", "1h30m", "500ms". Used to validate duration fields.
65+
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/
66+
67+
// validateField returns a human-readable error string for an invalid value,
68+
// or null when the value is acceptable. Shared by the field control (to show
69+
// the error) and the section (to block Save).
70+
export function validateField(field: SettingField, value: unknown): string | null {
71+
if (field.control === 'number') {
72+
if (value === '' || value == null) return 'Enter a number'
73+
const n = Number(value)
74+
if (Number.isNaN(n)) return 'Must be a number'
75+
if (field.min != null && n < field.min) return `Must be ≥ ${field.min}`
76+
if (field.max != null && n > field.max) return `Must be ≤ ${field.max}`
77+
}
78+
if (field.control === 'duration') {
79+
const s = String(value ?? '').trim()
80+
if (s === '') return 'Enter a duration, e.g. 2m'
81+
if (!DURATION_RE.test(s)) return 'Use a duration like 2m, 90s, or 1h30m'
82+
}
83+
return null
84+
}
85+
5986
// ---- Section 1: Security & Access (prioritised, security-first) ----
6087
export const SECURITY_FIELDS: SettingField[] = [
6188
{
@@ -160,8 +187,20 @@ export const GENERAL_FIELDS: SettingField[] = [
160187
control: 'select',
161188
options: ['trace', 'debug', 'info', 'warn', 'error'].map((v) => ({ value: v, label: v })),
162189
},
163-
{ key: 'telemetry.enabled', docs: '/features/telemetry', label: 'Anonymous usage telemetry', help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time.', control: 'toggle' },
164-
{ key: 'enable_prompts', label: 'Expose MCP prompts to clients', help: 'Advertise prompt templates from your upstream servers to connected AI clients.', control: 'toggle' },
190+
{
191+
key: 'telemetry.enabled',
192+
docs: '/features/telemetry',
193+
label: 'Anonymous usage telemetry',
194+
help: 'Sends anonymous usage counts (never tool arguments, content, or identities). Opt-out at any time.',
195+
control: 'toggle',
196+
danger: {
197+
confirmValue: false,
198+
tone: 'info',
199+
message:
200+
'Anonymous telemetry is how we see which features matter and catch problems — it never includes your tool arguments, content, or any identifying info. Turning it off removes that signal. Turn it off anyway?',
201+
},
202+
},
203+
{ key: 'enable_prompts', label: 'Expose MCP prompts to clients', help: 'Advertises mcpproxy’s built-in guided prompts to connected AI clients: “setup-new-mcp-server” (add a server) and “troubleshoot-mcp-server” (diagnose connection issues).', control: 'toggle' },
165204
]
166205

167206
// ---- Section 3: Advanced (subsystem accordions) ----

0 commit comments

Comments
 (0)