Skip to content

Commit 8e5ccd3

Browse files
authored
fix: telemetry opt-out shown as effective state; macOS Settings names the connected core (Spec fix-usertest-telemetry-macos) (#1471)
## Summary Two medium findings from the codex first-run user test, fixed on every surface that shows them. Both are on the Spec 109 side of the ownership rule (onboarding and the telemetry notice, Settings, macOS Settings); nothing here touches Spec 108 surfaces. ### F-03: the notice and Settings now show the effective telemetry state With `MCPPROXY_TELEMETRY=false` (or `DO_NOT_TRACK`, `CI`) the core sends nothing, but the Web wizard and Home banner still said "MCPProxy sends anonymous usage statistics", the macOS first-run welcome did the same, and Settings showed the stored `telemetry.enabled: true` as on. - **REST and Go.** `GET /api/v1/status` gains `telemetry: {enabled, source, disabled_by}`. `source` is `env`, `config` or `default`. `disabled_by` appears only for `env` and reuses the existing reasons. The block is withheld from scoped callers (agent tokens), like `activation`. It is resolved from the running config by the new `telemetry.ResolveEffectiveState`, which always agrees with `EffectiveTelemetryEnabled`. `GET /api/v1/config` is unchanged: it stays the stored, round-trippable document. - **Web.** The wizard's Verify step and the Home banner show one of three modes. Standard notice when telemetry is on or the state is unknown. "Anonymous usage telemetry is off — disabled by MCPPROXY_TELEMETRY=false in the environment. Nothing is sent." for an environment opt-out, with no "Manage in Settings" link. Nothing when the user turned telemetry off in their own config. Settings → General shows the telemetry toggle off and disabled with the reason and how to unlock it, and a locked setting never counts as changed or gets saved. The wizard notice is now the banner component's inline variant, so the copy lives in one place. A failed status fetch (for example a 401 before the API key is stored) is retried instead of cached. - **macOS.** The first-run welcome reads the app's own environment, because it is modal at launch before any core answers; a tray-spawned core inherits that environment. Settings locks the telemetry toggle off with the same reason, using the same copy as the Web UI. - **Proof that nothing is sent.** A new test drives the real service with a valid release version against a counting server under `MCPPROXY_TELEMETRY=false`, `FALSE`, `DO_NOT_TRACK=1` and `CI=true` through `Start`, the shutdown flush and the opt-out beacon, and expects zero requests. A control run with no environment variable must reach the server, so the test cannot pass vacuously. Neutering both env gates makes it fail. ### F-10: macOS Settings names the core it is editing The reported "wrong listen address and telemetry state" came from the dev app being attached to the user's real v0.69.0 core: the tray ignores `HOME`, and `open` drops shell variables. Settings was showing the connected core's values correctly; nothing told the tester which core that was. Settings → Security now shows, under Listen address, "Connected core vX is listening on <address>." with "The saved address Y takes effect after a restart." when it differs, and a config without `listen` shows the running address instead of the placeholder without becoming a pending change. `docs/development/macos-tray.md` documents the dev-rig trap. ## Audit findings fixed F-03 (medium) and F-10 (medium) of the codex first-run user test. ## Spec `specs/109-ux-navigation-consistency`: Phase 17 (T172 to T177), US7-7 and US7-8, FR-044a and FR-044b, parity rows 28a and 29a, research D37, quickstart recipe `fix-usertest-telemetry-macos`, plan row. The spec-count assertions in the traceability and parity tests move from 43 to 45 scenarios and from 32 to 34 matrix rows. Docs updated: the REST status section (`status.telemetry`), the telemetry feature page (how the UI shows an environment opt-out) and the macOS tray development page. ## Tests added - Go: `internal/telemetry/effective_state_test.go` (shared fixture `internal/telemetry/testdata/effective_state_cases.json`), `internal/telemetry/env_gate_send_test.go`, `internal/httpapi/status_telemetry_test.go`. - Web: `telemetry-state.spec.ts` (same fixture), `settings-telemetry-env-lock.spec.ts`, extended `telemetry-banner-wizard.spec.ts`. - macOS: `TelemetryNoticeTests.swift` (same fixture), `SettingsEffectiveStateTests.swift`. - The CLI parity walk now resolves `mcpproxy telemetry status`. No new dependencies. `oas/swagger.yaml` changes by the one description line. ## Follow-ups (not in this PR) `config.IsTelemetryEnabled` compares the environment value strictly to `"false"` while `IsDisabledByEnv` trims and case-folds; `ConvertConfigToContract` materialises an environment-forced `false` into the config document when the stored value is nil; the tray could warn when `HOME` differs but `MCPPROXY_HOME` is unset; the Web listen field could show the running address when it differs from the configured one.
1 parent a348403 commit 8e5ccd3

42 files changed

Lines changed: 2277 additions & 93 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
10361036
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
10371037
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
10381038
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 194/195 (99%) |
1039-
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 239/240 (100%) |
1039+
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 245/246 (100%) |
10401040
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
10411041
| [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) |

‎cmd/mcpproxy/parity_109_cli_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func p109HelpRoot() *cobra.Command {
6060
GetAttentionCommand(), GetStatusCommand(), GetDoctorCommand(), GetUpstreamCommand(),
6161
GetReviewCommand(), GetSecurityCommand(), GetToolsCommand(), GetClientCommand(),
6262
GetConnectCommand(), GetTokenCommand(), GetCatalogCommand(), GetRegistryCommand(),
63-
GetActivityCommand(),
63+
GetActivityCommand(), GetTelemetryCommand(),
6464
)
6565
clioutput.SetupHelpJSON(root)
6666
return root
@@ -188,6 +188,7 @@ func TestParity109CLIGroupsAreRegisteredInMain(t *testing.T) {
188188
"upstream": "GetUpstreamCommand", "review": "GetReviewCommand", "tools": "GetToolsCommand",
189189
"client": "GetClientCommand", "connect": "GetConnectCommand", "token": "GetTokenCommand",
190190
"catalog": "GetCatalogCommand", "registry": "GetRegistryCommand", "activity": "GetActivityCommand",
191+
"telemetry": "GetTelemetryCommand",
191192
}
192193
raw, err := os.ReadFile(filepath.Join("..", "..", "specs", "109-ux-navigation-consistency", "parity-matrix.json"))
193194
require.NoError(t, err)

‎docs/api/rest-api.md‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -102,25 +102,35 @@ curl "http://127.0.0.1:8080/api/v1/activity?request_id=a1b2c3d4-e5f6-7890-abcd-e
102102

103103
#### GET /api/v1/status
104104

105-
Get server status and statistics.
105+
Get server status and statistics. The `data` object carries `running`, `edition`, `listen_addr`, `routing_mode`, `upstream_stats`, `started_at`, `timestamp` and the blocks below. (An earlier version of this page showed a different shape; it was stale.)
106106

107-
**Response:**
107+
**Response (abridged):**
108108
```json
109109
{
110-
"status": "running",
111-
"version": "0.11.0",
112-
"uptime": 3600,
113-
"servers": {
114-
"total": 5,
115-
"connected": 4,
116-
"quarantined": 1
117-
},
118-
"tools": {
119-
"total": 42
110+
"success": true,
111+
"data": {
112+
"running": true,
113+
"edition": "personal",
114+
"listen_addr": "127.0.0.1:8080",
115+
"routing_mode": "retrieve_tools",
116+
"upstream_stats": { "total_servers": 5, "connected_servers": 4, "quarantined_servers": 1, "total_tools": 42 },
117+
"telemetry": { "enabled": false, "source": "env", "disabled_by": "MCPPROXY_TELEMETRY=false" }
120118
}
121119
}
122120
```
123121

122+
**`telemetry`** is the effective telemetry state of the running core, so a UI can say whether telemetry is on and why. It is withheld from scoped callers (agent tokens), like `activation`.
123+
124+
| Field | Description |
125+
|-------|-------------|
126+
| `enabled` | Whether the core sends telemetry. Always equal to the resolved state: an environment opt-out wins over the config file. |
127+
| `source` | `env` (an environment variable disabled it), `config` (`telemetry.enabled` is set in the config file, true or false) or `default` (unset, which means on). |
128+
| `disabled_by` | Present only when `source` is `env`: `DO_NOT_TRACK`, `CI` or `MCPPROXY_TELEMETRY=false`. |
129+
130+
`GET /api/v1/config` keeps returning the stored `telemetry.enabled`, which can differ from `enabled` here when an environment variable overrides it. A dev (non-release) build never transmits whatever `enabled` says.
131+
132+
While an environment variable forces telemetry off, `POST /api/v1/config/apply` and `PATCH /api/v1/config` answer `422` and write nothing if the document would change `telemetry.enabled` (the value is judged after decoding, so a miscased key is caught too). A document that leaves `telemetry.enabled` as stored is accepted.
133+
124134
### Servers
125135

126136
#### GET /api/v1/servers

‎docs/development/macos-tray.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ After a change under `Views/Profiles*`, `Views/Client*`, `Views/AccessExplainer*
7272
10. **Scoped views.** Tools "View as Client… / Profile…" greys rows the subject cannot use, with the reason and a "Why?" button that opens the explainer. Activity shows a Caller column and Profile, Client and Token pickers. Home filters usage and sessions by the same three and shows Profile and Source columns. Servers filters by profile.
7373
11. **Width and accessibility.** At a 900 pt window nothing is clipped on Profiles, the editor and Clients; run `check_accessibility`. The accessibility ids are listed in the plan for 108-k (K22): `profile-card-<name>`, `profile-editor`, `profile-editor-save`, `client-profile-picker-<id>`, `client-lock-toggle-<id>`, `clients-warnings-banner`, `access-explainer`, `guard-refusal`, `settings-anonymous-profile`, `connect-profile-picker`, `connect-lock-toggle`, `connect-mgmt-notice`.
7474

75-
**Driving the window without screenshots.** `screenshot_window` needs Screen Recording; when it fails (`Failed to capture window`), drive and read the window through the accessibility tree instead (`AXPress`, `AXValue`, `AXFocused` on the `profile-*`, `client-*`, `token-*`, `connect-*` ids above), keystroke into focused fields with System Events, and check a 900 pt window by listing elements whose frame leaves the window. Navigation shortcuts: ⌘1 Home, ⌘2 Clients, ⌘3 Profiles, ⌘4 Servers, ⌘5 Tools. A tray attached to an already-running core (`MCPPROXY_TRAY_SKIP_CORE=1`) needs the core's `MCPPROXY_SOCKET_PATH`; the SSE stream authenticates with the key it reads from `/api/v1/info`.
75+
**Driving the window without screenshots.** `screenshot_window` needs Screen Recording; when it fails (`Failed to capture window`), drive and read the window through the accessibility tree instead (`AXPress`, `AXValue`, `AXFocused` on the `profile-*`, `client-*`, `token-*`, `connect-*` ids above), keystroke into focused fields with System Events, and check a 900 pt window by listing elements whose frame leaves the window. Navigation shortcuts: ⌘1 Home, ⌘2 Clients, ⌘3 Profiles, ⌘4 Servers, ⌘5 Tools. A tray attached to an already-running core (`MCPPROXY_TRAY_SKIP_CORE=1`) needs the core's `MCPPROXY_SOCKET_PATH`; the SSE stream authenticates with the key it reads from `/api/v1/info`. The tray ignores `HOME` (the Go core honours it), and an app started with plain `open` drops shell variables, so a dev bundle meant for a scratch core must be launched as the binary directly (or with `open --env …`) with `MCPPROXY_TRAY_SKIP_CORE=1 MCPPROXY_SOCKET_PATH=<scratch>/mcpproxy.sock`; otherwise it attaches to the real `~/.mcpproxy` core, which is how a first-run test once reported the wrong version and telemetry state. Before trusting any observation, confirm that Settings → Security shows "Connected core vX is listening on …" naming the scratch build.
7676

7777
**MCP config** (in Claude Code settings or `~/.claude/settings.json`):
7878
```json

‎docs/features/telemetry.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ export MCPPROXY_TELEMETRY=false
319319

320320
This overrides the config file setting and is useful for CI/CD environments or system-wide policies.
321321

322+
### How the UI shows an environment opt-out
323+
324+
When an environment variable (`MCPPROXY_TELEMETRY=false`, `DO_NOT_TRACK` or `CI`) disables telemetry, the Web UI and the macOS app say so instead of showing the usual notice: the setup wizard's last step, the Home banner and the macOS welcome read "Anonymous usage telemetry is off — disabled by MCPPROXY_TELEMETRY=false in the environment. Nothing is sent." The telemetry toggle in Settings is shown off and disabled with the same reason; unset the variable and restart MCPProxy to change it. If you turned telemetry off in the config file yourself, no notice is shown. The core reports this state at `telemetry` in `GET /api/v1/status`.
325+
326+
Development builds (a version that is not a release number) never send telemetry, whatever the setting. That is a property of the build and is not reflected in the UI.
327+
322328
## Data handling
323329

324330
- Telemetry data is sent to a Cloudflare Worker over HTTPS

‎frontend/src/components/OnboardingWizard.vue‎

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -581,21 +581,7 @@
581581
final step. TelemetryBanner.vue hides itself while the wizard
582582
is open, so this is the only place it appears until the user
583583
closes the wizard. -->
584-
<p
585-
v-if="!telemetryNoticeDismissed"
586-
class="mt-4 border-t border-base-300 pt-3 text-[11px] opacity-60 flex items-center gap-2"
587-
data-test="wizard-telemetry-notice"
588-
>
589-
<span class="flex-1">
590-
MCPProxy sends anonymous usage statistics to help improve the product. No personal data is collected.
591-
<a href="https://mcpproxy.app/telemetry" target="_blank" rel="noopener noreferrer" class="link link-hover underline">Learn more</a>
592-
</span>
593-
<button
594-
class="btn btn-ghost btn-xs"
595-
data-test="wizard-telemetry-notice-dismiss"
596-
@click="dismissTelemetryNotice"
597-
>Dismiss</button>
598-
</p>
584+
<TelemetryBanner variant="inline" />
599585
</section>
600586
</div>
601587

@@ -705,6 +691,7 @@ import ManualServerForm from '@/components/ManualServerForm.vue'
705691
import ImportServers from '@/components/ImportServers.vue'
706692
import ClientConnectList from '@/components/ClientConnectList.vue'
707693
import ReviewQueueList from '@/components/ReviewQueueList.vue'
694+
import TelemetryBanner from '@/components/TelemetryBanner.vue'
708695
import { useDialogOpen } from '@/composables/useDialogOpen'
709696
import { skipReasonLabel } from '@/utils/importSkipReason'
710697
import { serversStepView, awaitingReviewSentence } from '@/utils/onboardingServersStep'
@@ -799,18 +786,6 @@ const loadingImportSources = ref(false)
799786
const recentActivity = ref<ActivityRecord[]>([])
800787
const loadingActivity = ref(false)
801788
802-
// Spec 109-b FR-044: the telemetry notice's one-line form for the wizard's
803-
// final step, sharing TelemetryBanner.vue's dismissal state (the store's
804-
// `telemetryNoticeDismissed` ref, backed by one localStorage key) so acting
805-
// on either surface silences both immediately — the banner and this wizard
806-
// are mounted together on Dashboard.vue for the whole session, so a
807-
// component-local copy read only at mount would miss the other surface's
808-
// dismissal until a full reload.
809-
const telemetryNoticeDismissed = computed(() => onboarding.telemetryNoticeDismissed)
810-
function dismissTelemetryNotice() {
811-
onboarding.dismissTelemetryNotice()
812-
}
813-
814789
// Verify tab — second milestone (UX audit F13). Lifetime flag from the
815790
// Spec 044 activation bucket, read off `GET /api/v1/status`, which already
816791
// serves the whole block to an admin caller. The activity log cannot answer

‎frontend/src/components/TelemetryBanner.vue‎

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,53 @@
11
<template>
2+
<!-- Inline variant: the wizard's Verify-step one-liner (Spec 109-b FR-044). -->
3+
<p
4+
v-if="variant === 'inline' && visible"
5+
class="mt-4 border-t border-base-300 pt-3 text-[11px] opacity-60 flex items-center gap-2"
6+
data-test="wizard-telemetry-notice"
7+
:data-mode="mode"
8+
>
9+
<span class="flex-1">
10+
<template v-if="mode === 'off_env' && state">
11+
<span data-test="telemetry-off-env">{{ telemetryOffLine(state) }}</span>
12+
</template>
13+
<template v-else>
14+
MCPProxy sends anonymous usage statistics to help improve the product. No personal data is collected.
15+
</template>
16+
<a href="https://mcpproxy.app/telemetry" target="_blank" rel="noopener noreferrer" class="link link-hover underline">Learn more</a>
17+
</span>
18+
<button
19+
class="btn btn-ghost btn-xs"
20+
data-test="wizard-telemetry-notice-dismiss"
21+
@click="dismiss"
22+
>Dismiss</button>
23+
</p>
24+
225
<!-- UX audit F14: a daisyUI alert is a GRID that flows in columns, so at 390px
326
the message shared the row with the action buttons and rendered as a
427
~10-character column ~440px tall — over half the viewport, before any
528
content. Below `sm` the alert stacks (`alert-vertical`) and the text child
629
gets `min-w-0` so it may shrink inside its track. -->
7-
<div v-if="visible" class="alert alert-vertical sm:alert-horizontal alert-info" data-test="telemetry-banner">
30+
<div
31+
v-else-if="variant === 'banner' && visible"
32+
class="alert alert-vertical sm:alert-horizontal alert-info"
33+
data-test="telemetry-banner"
34+
:data-mode="mode"
35+
>
836
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
937
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
1038
</svg>
11-
<div class="flex-1 min-w-0">
39+
<!-- Environment opt-out (Spec 109 FR-044a): say it is off and why. The
40+
setting is locked, so no disclosure line and no Manage link. -->
41+
<div v-if="mode === 'off_env' && state" class="flex-1 min-w-0">
42+
<span data-test="telemetry-off-env">{{ telemetryOffLine(state) }}</span>
43+
<a
44+
href="https://mcpproxy.app/telemetry"
45+
target="_blank"
46+
rel="noopener noreferrer"
47+
class="link link-hover underline"
48+
> Learn more</a>
49+
</div>
50+
<div v-else class="flex-1 min-w-0">
1251
<span>MCPProxy sends anonymous usage statistics to help improve the product. No personal data is collected. </span>
1352
<a
1453
href="https://mcpproxy.app/telemetry"
@@ -23,6 +62,7 @@
2362
</div>
2463
<div class="flex items-center gap-2 shrink-0">
2564
<RouterLink
65+
v-if="mode !== 'off_env'"
2666
to="/settings?focus=telemetry.enabled"
2767
class="btn btn-sm btn-ghost"
2868
data-test="telemetry-banner-settings-link"
@@ -40,23 +80,40 @@
4080
</template>
4181

4282
<script setup lang="ts">
43-
import { computed } from 'vue'
83+
import { computed, onMounted } from 'vue'
4484
import { RouterLink } from 'vue-router'
4585
import { useOnboardingStore } from '@/stores/onboarding'
86+
import { telemetryNoticeMode, telemetryOffLine } from '@/utils/telemetryState'
87+
88+
const props = withDefaults(defineProps<{ variant?: 'banner' | 'inline' }>(), { variant: 'banner' })
4689
4790
const onboarding = useOnboardingStore()
4891
49-
// Spec 109-b FR-044: the notice must not render while the wizard is open —
50-
// it already offers its own one-line version in the Verify step (see
51-
// OnboardingWizard.vue), and showing both would say the same thing twice
52-
// while the wizard sits on top of everything else. Dismissal is the store's
53-
// shared `telemetryNoticeDismissed` ref (backed by one localStorage key), not
54-
// an independent per-component copy, so acting on either surface hides both
55-
// immediately even though this banner and the wizard stay mounted together
56-
// on Dashboard.vue and neither ever remounts.
57-
const visible = computed(() => !onboarding.telemetryNoticeDismissed && !onboarding.wizardOpen)
92+
const state = computed(() => onboarding.telemetryState)
93+
const mode = computed(() => telemetryNoticeMode(state.value))
94+
95+
// Spec 109-b FR-044: the page banner must not render while the wizard is open —
96+
// the wizard offers this same notice inline in its Verify step (variant
97+
// "inline"), and showing both would say the same thing twice while the wizard
98+
// sits on top of everything else. Dismissal is the store's shared
99+
// `telemetryNoticeDismissed` ref (backed by one localStorage key), not an
100+
// independent per-component copy, so acting on either surface hides both
101+
// immediately even though the banner and the wizard stay mounted together on
102+
// Dashboard.vue and neither ever remounts.
103+
//
104+
// Spec 109 FR-044a: a user who turned telemetry off in their own config is not
105+
// nagged (`hidden`); an environment opt-out is stated, not disclosed.
106+
const visible = computed(() => {
107+
if (onboarding.telemetryNoticeDismissed) return false
108+
if (mode.value === 'hidden') return false
109+
return props.variant === 'inline' ? true : !onboarding.wizardOpen
110+
})
58111
59112
function dismiss() {
60113
onboarding.dismissTelemetryNotice()
61114
}
115+
116+
onMounted(() => {
117+
void onboarding.loadTelemetryState()
118+
})
62119
</script>

‎frontend/src/components/settings/SettingField.vue‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@
2727
</span>
2828
</div>
2929
<p v-if="field.help" class="text-xs text-base-content/60 mt-0.5">{{ field.help }}</p>
30+
<!-- Spec 109 FR-044a: the effective value is forced from outside the
31+
config file (e.g. an environment opt-out); say why and how to unlock. -->
32+
<p v-if="lock" class="text-xs text-warning mt-0.5" :data-test="`setting-locked-${field.key}`">{{ lock.reason }}</p>
3033
</div>
3134

3235
<!-- Control -->
@@ -36,7 +39,8 @@
3639
v-if="field.control === 'toggle'"
3740
type="checkbox"
3841
class="toggle toggle-primary"
39-
:checked="!!modelValue"
42+
:checked="lock ? !!lock.value : !!modelValue"
43+
:disabled="!!lock"
4044
:data-test="`setting-toggle-${field.key}`"
4145
@change="emitVal(($event.target as HTMLInputElement).checked)"
4246
/>
@@ -46,6 +50,7 @@
4650
v-else-if="field.control === 'select'"
4751
class="select select-bordered select-sm min-w-[12rem]"
4852
:value="modelValue ?? ''"
53+
:disabled="!!lock"
4954
:data-test="`setting-select-${field.key}`"
5055
@change="emitVal(($event.target as HTMLSelectElement).value)"
5156
>
@@ -62,6 +67,7 @@
6267
:min="field.min"
6368
:max="field.max"
6469
:step="field.step"
70+
:disabled="!!lock"
6571
:data-test="`setting-number-${field.key}`"
6672
@input="onNumber(($event.target as HTMLInputElement).value)"
6773
/>
@@ -129,6 +135,7 @@
129135
:class="{ 'input-error': validationError }"
130136
:value="modelValue ?? ''"
131137
:placeholder="field.placeholder"
138+
:disabled="!!lock"
132139
:data-test="`setting-text-${field.key}`"
133140
@input="emitText(($event.target as HTMLInputElement).value)"
134141
/>
@@ -178,7 +185,14 @@
178185
import { ref, computed } from 'vue'
179186
import { docsUrl, validateField, type SettingField } from '@/views/settings/fields'
180187
181-
const props = defineProps<{ field: SettingField; modelValue: any; dirty?: boolean }>()
188+
const props = defineProps<{
189+
field: SettingField
190+
modelValue: any
191+
dirty?: boolean
192+
// Spec 109 FR-044a: when set the control is disabled, shows `value`, and the
193+
// reason is rendered under the help text. emitVal becomes a no-op.
194+
lock?: { reason: string; value?: unknown } | null
195+
}>()
182196
const emit = defineEmits<{ (e: 'update:modelValue', v: any): void }>()
183197
184198
const showSecret = ref(false)
@@ -221,6 +235,7 @@ function confirmRegenerate() {
221235
const validationError = computed(() => (props.dirty ? validateField(props.field, props.modelValue) : null))
222236
223237
function emitVal(v: any) {
238+
if (props.lock) return
224239
emit('update:modelValue', v)
225240
}
226241

0 commit comments

Comments
 (0)