From 224689a5bcce61b87ce9f670649569bc2e023f00 Mon Sep 17 00:00:00 2001 From: ruv Date: Tue, 26 May 2026 15:36:44 -0400 Subject: [PATCH] feat(homecore-ui iter 6): Settings probe-before-persist token validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUD increment 6/6 — closes the sprint. Bearer-token editor now probes /api/config with the new value BEFORE writing it to localStorage, so a typo'd or revoked token can't lock the UI out of the backend. Three actions: - Test token probe /api/config, no localStorage write - Probe & Save probe; write only on 2xx - Clear remove from localStorage Inline probe result with sigils: ✓ token accepted (40 ms) — server v0.1.0-alpha.0 ✗ HTTP 401: unauthorized ⋯ probing /api/config… `currently stored:` line shows masked + length: `dev-…ken (9 chars)` so the operator can see what's persisted without exposing the secret. Empty input → red border + disabled Test/Save buttons. Bad probes do NOT persist (this is the whole point — never write a token that the backend rejects). frontend/src/pages/Settings.ts — full rewrite (~190 LOC, +110 vs previous version). No new dependencies. Browser-verified end-to-end: - Backend section: Home / 0.1.0-alpha.0 / RUNNING / components OK - Test token: probe ✓, 40 ms, version reported - Empty input: buttons disabled + red border - Probe & Save: persists to localStorage, toast shown, `currently stored:` updates to masked new token - Clear: localStorage null, `currently stored: (empty)` - 0 unexpected console errors Note: a clean reload lands on Dashboard (the SPA router has no URL-encoded view yet). The token persistence itself survives reload correctly; route persistence is a small follow-up if you want direct URLs like /?view=settings. CRUD sprint summary (6/6 runtime-validated): iter 1 Add Entity e7215a16e iter 2 Edit Entity 89190b6c2 iter 3 Delete + DELETE route c0bb6f4fc iter 4 Live validation polish 3f5a7411d iter 5 Call Service 99c78f512 iter 6 Settings probe-before-persist (this) Co-Authored-By: claude-flow --- frontend/src/pages/Settings.ts | 156 ++++++++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/Settings.ts b/frontend/src/pages/Settings.ts index 7d56a45d..678c6e15 100644 --- a/frontend/src/pages/Settings.ts +++ b/frontend/src/pages/Settings.ts @@ -1,5 +1,13 @@ /** - * Settings page — backend config + bearer-token editor (localStorage). + * Settings page — backend config + bearer-token editor with + * probe-before-persist validation. + * + * The save flow probes `/api/config` with the new token BEFORE writing + * it to localStorage. If the probe fails (401 wrong token, network + * error, etc.) the bad token is NOT persisted and the operator sees + * an inline error. This avoids the foot-gun where saving a typo'd + * token would lock the UI out of the backend until the operator + * cleared localStorage by hand. */ import { LitElement, html, css } from 'lit'; @@ -8,15 +16,29 @@ import { customElement, state } from 'lit/decorators.js'; import { HomecoreClient } from '../api/client.js'; import type { ApiConfig } from '../api/types.js'; +const TOKEN_LS_KEY = 'homecore.token'; + function resolveToken(): string { if (typeof localStorage !== 'undefined') { - const stored = localStorage.getItem('homecore.token'); + const stored = localStorage.getItem(TOKEN_LS_KEY); if (stored) return stored; } const qs = new URL(window.location.href).searchParams.get('token'); return qs ?? 'dev-token'; } +function maskToken(t: string): string { + if (!t) return '(empty)'; + if (t.length <= 8) return '•'.repeat(t.length); + return t.slice(0, 4) + '…' + t.slice(-3) + ' (' + t.length + ' chars)'; +} + +type ProbeResult = + | { kind: 'idle' } + | { kind: 'probing' } + | { kind: 'ok'; ms: number; serverVersion: string } + | { kind: 'err'; status?: number; msg: string }; + @customElement('hc-settings') export class SettingsPage extends LitElement { static styles = css` @@ -26,50 +48,133 @@ export class SettingsPage extends LitElement { h2 { font-size: 14px; font-weight: 600; margin: 0 0 12px 0; color: var(--hc-primary, #19d4e5); } dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; margin: 0; font-size: 13px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); } dt { color: var(--hc-text-muted, #7b899d); } - dd { margin: 0; } + dd { margin: 0; word-break: break-all; } label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--hc-text-muted, #7b899d); } - input { width: 100%; box-sizing: border-box; padding: 8px 12px; background: hsl(220 25% 14%); border: 1px solid var(--hc-border, #2a323e); border-radius: 6px; color: var(--hc-text, #e6eaee); font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); font-size: 13px; } - button { margin-top: 10px; padding: 8px 16px; background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border: none; border-radius: 6px; font-weight: 600; font-size: 13px; cursor: pointer; font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); } - button:hover { background: hsl(185 80% 55%); } + input { + width: 100%; box-sizing: border-box; + padding: 8px 12px; + background: hsl(220 25% 14%); + border: 1px solid var(--hc-border, #2a323e); + border-radius: 6px; + color: var(--hc-text, #e6eaee); + font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); + font-size: 13px; + } + input:focus { outline: 2px solid hsl(185 80% 50% / 0.5); border-color: var(--hc-primary, #19d4e5); } + input.invalid { border-color: hsl(0 60% 50%); } + .actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; } + button { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--hc-border, #2a323e); + background: hsl(220 25% 14%); + color: var(--hc-text, #e6eaee); + font-family: var(--hc-font-sans, 'Outfit', system-ui, sans-serif); + font-size: 13px; + cursor: pointer; + } + button:hover { background: hsl(220 20% 18%); } + button.primary { background: var(--hc-primary, #19d4e5); color: var(--hc-primary-fg, #0b0e13); border-color: var(--hc-primary, #19d4e5); font-weight: 600; } + button.primary:hover { background: hsl(185 80% 55%); } + button[disabled] { background: hsl(220 15% 20%); color: var(--hc-text-muted, #7b899d); cursor: not-allowed; } + .hint { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 6px; } + .field-status { font-size: 12px; margin-top: 6px; display: flex; align-items: center; gap: 6px; } + .field-status.ok { color: hsl(150 60% 55%); } + .field-status.err { color: hsl(0 70% 70%); } + .field-status.probing { color: var(--hc-text-muted, #7b899d); } .toast { font-size: 12px; color: var(--hc-primary, #19d4e5); margin-top: 8px; } - .err { padding: 16px; border: 1px dashed #b35a5a; border-radius: 8px; color: #f0c0c0; font-size: 13px; } + .err { padding: 12px; border: 1px solid #b35a5a; border-radius: 6px; color: #f0c0c0; background: hsl(0 35% 12%); font-size: 13px; margin-top: 8px; } + .saved-meta { font-size: 11px; color: var(--hc-text-muted, #7b899d); margin-top: 4px; font-family: var(--hc-font-mono, 'JetBrains Mono', monospace); } `; @state() private config: ApiConfig | null = null; - @state() private error: string | null = null; + @state() private configErr: string | null = null; @state() private token = resolveToken(); + @state() private storedToken = resolveToken(); + @state() private probe: ProbeResult = { kind: 'idle' }; @state() private savedAt = 0; private client = new HomecoreClient({ token: resolveToken() }); connectedCallback(): void { super.connectedCallback(); - void this.refresh(); + void this.refreshConfig(); } - private async refresh(): Promise { + private async refreshConfig(): Promise { try { this.config = await this.client.getConfig(); - this.error = null; + this.configErr = null; } catch (e) { - this.error = e instanceof Error ? e.message : String(e); + this.configErr = e instanceof Error ? e.message : String(e); } } - private saveToken() { - localStorage.setItem('homecore.token', this.token); + /** Hit /api/config with the given token; return success or 4xx/5xx kind. */ + private async _probe(token: string): Promise { + if (!token.trim()) return { kind: 'err', msg: 'token must not be empty' }; + const started = performance.now(); + try { + const r = await fetch('/api/config', { + headers: { 'Authorization': `Bearer ${token}` }, + }); + if (!r.ok) { + return { kind: 'err', status: r.status, msg: r.statusText || `HTTP ${r.status}` }; + } + const cfg = await r.json() as ApiConfig; + return { kind: 'ok', ms: Math.round(performance.now() - started), serverVersion: cfg.version }; + } catch (e) { + return { kind: 'err', msg: e instanceof Error ? e.message : String(e) }; + } + } + + private async _testToken() { + this.probe = { kind: 'probing' }; + this.probe = await this._probe(this.token); + } + + private async _saveToken() { + const result = await this._probe(this.token); + this.probe = result; + if (result.kind !== 'ok') return; // refuse to persist a bad token + localStorage.setItem(TOKEN_LS_KEY, this.token); + this.storedToken = this.token; this.savedAt = Date.now(); + // Rebuild the client with the new token + refresh the config readout. this.client = new HomecoreClient({ token: this.token }); - void this.refresh(); + await this.refreshConfig(); + } + + private _clearToken() { + localStorage.removeItem(TOKEN_LS_KEY); + this.storedToken = ''; + this.token = ''; + this.probe = { kind: 'idle' }; + this.savedAt = 0; + } + + private _renderProbe() { + switch (this.probe.kind) { + case 'idle': + return html`
click Test token to probe /api/config with the value above
`; + case 'probing': + return html`
⋯ probing /api/config…
`; + case 'ok': + return html`
✓ token accepted (${this.probe.ms} ms) — server v${this.probe.serverVersion}
`; + case 'err': + return html`
✗ ${this.probe.status ? `HTTP ${this.probe.status}: ` : ''}${this.probe.msg}
`; + } } render() { + const isEmpty = !this.token.trim(); + const inputClass = isEmpty || this.probe.kind === 'err' ? 'invalid' : ''; return html`

Settings

backend

- ${this.error - ? html`
unreachable — ${this.error}
` + ${this.configErr + ? html`
unreachable — ${this.configErr}
` : this.config ? html`
location
${this.config.location_name}
@@ -81,11 +186,20 @@ export class SettingsPage extends LitElement {

auth — bearer token

- + (this.token = (e.target as HTMLInputElement).value)} /> - - ${this.savedAt > 0 ? html`
saved at ${new Date(this.savedAt).toLocaleTimeString()}
` : ''} + class=${inputClass} + @input=${(e: Event) => { this.token = (e.target as HTMLInputElement).value; this.probe = { kind: 'idle' }; }} /> +
currently stored: ${maskToken(this.storedToken)}
+ ${this._renderProbe()} +
+ + + +
+ ${this.savedAt > 0 + ? html`
✓ saved at ${new Date(this.savedAt).toLocaleTimeString()} — backend config refreshed with new token
` + : ''}
`; }