feat(ui): render Provider Settings from a store

initKeySetup keeps the setup requests, the ChatGPT OAuth flow, the pasted
field values and the dialog's keyboard, and publishes the POWER UP chip,
the dialog, its key rows and the status line to the gev-provider-settings
store. ProviderSettingsView renders them and sends clicks and typing to
the connected owner; in Cyber the chip is teleported into the top
toolbar. A successful save renders the rows afresh so no field keeps a
key, and the status line announces a repeated message again. The voice
action opens and closes the dialog through the same controls.
This commit is contained in:
Sameh Khamis committed 2026-10-31 18:22:20 -07:00
1 parent 6cb01ff975
commit fe963a1219
9 files changed
+840 -517

No files matched your search

+421
View File
@@ -0,0 +1,421 @@
// src/ui/providerSettings.test.mjs — Provider Settings (the dev-only key
// setup) renders what src/keySetup.js presents, and its controls reach the
// owner, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, defineComponent, h, nextTick } from 'vue';
import { initKeySetup } from '../keySetup.js';
import { CLOUD_VOICE_AUTH_STORAGE_KEY } from '../voice/cloudVoiceAuth.js';
import { useProviderSettingsStore } from './stores/providerSettings.js';
import ProviderSettingsView from './views/ProviderSettingsView.vue';
const STATUS = Object.freeze({
total: 3,
setCount: 1,
store: 'dotenv',
keys: [
{
id: 'google-maps',
title: 'Google Maps',
tier: 'metered',
set: false,
managed: null,
clientExposed: true,
getUrl: 'https://example.test/google',
unlocks: 'Photorealistic 3D tiles',
envVars: ['GOOGLE_MAPS_API_KEY'],
},
{
id: 'openai',
title: 'OpenAI',
tier: 'paid',
set: true,
managed: 'file',
getUrl: 'https://example.test/openai',
unlocks: 'Cloud voice',
envVars: ['OPENAI_API_KEY'],
},
{
id: 'firms',
title: 'NASA FIRMS',
tier: 'free',
set: true,
managed: 'external',
getUrl: 'https://example.test/firms',
unlocks: 'Fire detections',
envVars: ['FIRMS_MAP_KEY'],
},
],
});
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () =>
new Promise((resolve) => requestAnimationFrame(() => resolve()));
async function fixture(t, { status = STATUS, respond = null } = {}) {
document.body.replaceChildren();
delete document.documentElement.dataset.uiTheme;
const toolbar = document.body.appendChild(document.createElement('div'));
toolbar.id = 'top-center-actions';
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(
defineComponent(() => () => h(ProviderSettingsView)),
).use(pinia);
app.mount(host);
const store = useProviderSettingsStore(pinia);
const requests = [];
const fetchImpl = async (url, init = {}) => {
requests.push({ url, body: init.body ? JSON.parse(init.body) : null });
if (url === '/api/setup/status') {
if (!status) return { ok: false, status: 404, json: async () => ({}) };
return { ok: true, json: async () => status };
}
return respond(url, init);
};
const controller = new AbortController();
const setup = await initKeySetup({
documentRef: document,
fetchImpl,
signal: controller.signal,
store,
});
await nextTick();
t.after(() => {
controller.abort();
app.unmount();
});
const $ = (selector) => document.querySelector(selector);
const text = (selector) => $(selector)?.textContent.trim();
return { $, text, store, setup, requests, toolbar };
}
test('an unavailable setup endpoint removes the chip and the dialog', async (t) => {
const { $ } = await fixture(t, { status: null });
assert.equal($('#key-setup-chip'), null);
assert.equal($('#key-setup'), null);
assert.ok($('#corner-actions'));
});
test('the chip counts what is missing and the rows render from the status', async (t) => {
const { $, text } = await fixture(t);
const chip = $('#key-setup-chip');
assert.equal(chip.hidden, false);
assert.equal(
text('[data-key-setup-chip-label]'),
'POWER UP · 2 KEYS WAITING',
);
assert.equal(chip.title, 'Project keys: POWER UP · 2 KEYS WAITING');
assert.equal(chip.getAttribute('aria-label'), chip.title);
assert.equal($('#key-setup').dataset.ready, 'true');
assert.equal($('#key-setup').hidden, true);
const google = $('[data-key-id="google-maps"]');
assert.equal(google.dataset.set, 'false');
assert.equal(google.hasAttribute('data-managed'), false);
assert.equal(google.querySelector('strong').textContent, 'Google Maps');
assert.equal(google.querySelector('.key-setup-tier').textContent, '🔴');
assert.equal(
google.querySelector('.key-setup-tier').title,
'Metered — a billing-enabled account',
);
assert.equal(
google.querySelector('.key-setup-exposed').textContent,
'browser-side',
);
assert.equal(google.querySelector('.key-setup-get').textContent, 'GET KEY ↗');
assert.equal(
google.querySelector('.key-setup-get').getAttribute('rel'),
'noopener noreferrer',
);
const field = google.querySelector(
'input[data-env-var="GOOGLE_MAPS_API_KEY"]',
);
assert.equal(field.type, 'password');
assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY');
assert.equal(google.querySelector('.key-setup-remove'), null);
assert.equal(google.querySelector('.key-setup-cloud-auth'), null);
const openai = $('[data-key-id="openai"]');
assert.equal(openai.dataset.managed, 'file');
assert.equal(openai.querySelector('.key-setup-get').textContent, 'MANAGE ↗');
assert.equal(
openai.querySelector('input').placeholder,
'OPENAI_API_KEY saved — paste to replace',
);
const remove = openai.querySelector('.key-setup-remove');
assert.equal(remove.textContent, 'REMOVE');
assert.equal(remove.title, "Remove OpenAI from this app's saved keys");
assert.equal(remove.dataset.keySetupRemove, '["OPENAI_API_KEY"]');
assert.equal(
text('[data-key-id="openai"] .key-setup-cloud-auth-state'),
'VOICE AUTH · API KEY',
);
const firms = $('[data-key-id="firms"]');
assert.equal(
firms.querySelector('.key-setup-external').textContent,
'configured externally',
);
assert.equal(firms.querySelector('.key-setup-fields'), null);
});
test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => {
const { $, text } = await fixture(t);
const chip = $('#key-setup-chip');
chip.focus();
chip.click();
await nextTick();
const dialog = $('#key-setup');
assert.equal(dialog.hidden, false);
assert.equal(dialog.classList.contains('visible'), false);
await frame();
await wait();
assert.equal(dialog.classList.contains('visible'), true);
assert.equal(document.activeElement?.dataset.envVar, 'GOOGLE_MAPS_API_KEY');
$('[data-key-setup-apply]').click();
await nextTick();
assert.equal(
text('[data-key-setup-status]'),
'Paste at least one key first.',
);
$('[data-key-setup-close]').click();
await nextTick();
assert.equal(dialog.classList.contains('visible'), false);
assert.equal(dialog.hidden, false);
assert.equal(
text('[data-key-setup-status]'),
'The Google Maps key buys the photorealistic planet — everything else stacks on top.',
);
dialog.dispatchEvent(new Event('transitionend', { bubbles: true }));
await nextTick();
assert.equal(dialog.hidden, true);
assert.equal(document.activeElement === chip, true);
});
test('saving posts the pasted keys, clears the fields and shows the result', async (t) => {
let finish;
const { $, text, requests } = await fixture(t, {
respond: () =>
new Promise((resolve) => {
finish = resolve;
}),
});
const field = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]');
field.value = ' AIza-test ';
field.dispatchEvent(new Event('input', { bubbles: true }));
const apply = $('[data-key-setup-apply]');
assert.equal(apply.hasAttribute('aria-disabled'), false);
apply.click();
await nextTick();
assert.equal(apply.getAttribute('aria-disabled'), 'true');
assert.equal(text('[data-key-setup-status]'), 'Saving…');
assert.deepEqual(requests.at(-1), {
url: '/api/setup/keys',
body: { GOOGLE_MAPS_API_KEY: 'AIza-test' },
});
finish({
ok: true,
json: async () => ({
ok: true,
saved: ['GOOGLE_MAPS_API_KEY'],
status: {
...STATUS,
setCount: 3,
keys: STATUS.keys.map((key) => ({ ...key, set: true })),
},
}),
});
await wait();
await nextTick();
assert.equal(apply.getAttribute('aria-disabled'), 'false');
assert.equal(
text('[data-key-setup-status]'),
'Saved to your local .env. Restarting — this page reloads itself.',
);
assert.equal($('input[data-env-var="GOOGLE_MAPS_API_KEY"]').value, '');
assert.equal(
$('input[data-env-var="GOOGLE_MAPS_API_KEY"]').placeholder,
'GOOGLE_MAPS_API_KEY saved — paste to replace',
);
// Fully powered retires the chip.
assert.equal($('#key-setup-chip').hidden, true);
});
test('removing a saved key asks first and posts nulls', async (t) => {
const { $, requests } = await fixture(t, {
respond: async () => ({
ok: true,
json: async () => ({ ok: true, saved: [], status: STATUS }),
}),
});
const original = globalThis.confirm;
const asked = [];
let answer = false;
globalThis.confirm = (message) => {
asked.push(message);
return answer;
};
t.after(() => {
globalThis.confirm = original;
});
$('.key-setup-remove').click();
assert.deepEqual(asked, ['Remove this key from your saved configuration?']);
assert.equal(requests.length, 1);
answer = true;
$('.key-setup-remove').click();
await wait();
assert.deepEqual(requests.at(-1), {
url: '/api/setup/keys',
body: { OPENAI_API_KEY: null },
});
});
test('the cloud voice toggle switches back to the API key and says so', async (t) => {
localStorage.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, 'oauth');
t.after(() => localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY));
const { $, text } = await fixture(t);
const toggle = $('[data-cloud-voice-auth-toggle]');
assert.equal(
text('.key-setup-cloud-auth-state'),
'VOICE AUTH · CHATGPT OAUTH',
);
assert.equal(toggle.textContent, 'USE API KEY');
toggle.click();
await nextTick();
assert.equal(localStorage.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY), 'api-key');
assert.equal(text('.key-setup-cloud-auth-state'), 'VOICE AUTH · API KEY');
assert.equal(toggle.textContent, 'USE CHATGPT OAUTH');
assert.equal(toggle.getAttribute('aria-disabled'), 'false');
assert.equal(
text('[data-key-setup-status]'),
'Cloud voice will use OPENAI_API_KEY on the next session.',
);
});
test('while the OAuth check runs, the toggle is busy and repeat presses are ignored', async (t) => {
let finish;
const calls = [];
const { $, text } = await fixture(t, {
respond: (url) => {
calls.push(url);
return new Promise((resolve) => {
finish = resolve;
});
},
});
const toggle = $('[data-cloud-voice-auth-toggle]');
toggle.click();
await nextTick();
assert.equal(toggle.getAttribute('aria-disabled'), 'true');
assert.equal(
text('[data-key-setup-status]'),
'Checking local ChatGPT OAuth sign-in…',
);
toggle.click();
assert.deepEqual(calls, ['/api/realtime/oauth-status']);
finish({ ok: true, json: async () => ({ available: true }) });
await wait();
await nextTick();
assert.equal(toggle.getAttribute('aria-disabled'), 'false');
assert.equal(
text('.key-setup-cloud-auth-state'),
'VOICE AUTH · CHATGPT OAUTH',
);
localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY);
});
test('Cyber moves the chip into the top toolbar and other themes return it to the corner', async (t) => {
const { $, toolbar } = await fixture(t);
const corner = $('#corner-actions');
assert.equal(corner.firstElementChild?.id, 'key-setup-chip');
document.documentElement.dataset.uiTheme = 'cyber';
await wait();
await nextTick();
assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip');
assert.equal(corner.querySelector('#key-setup-chip'), null);
document.documentElement.dataset.uiTheme = 'tactical';
await wait();
await nextTick();
assert.equal(corner.firstElementChild?.id, 'key-setup-chip');
assert.equal(toolbar.querySelector('#key-setup-chip'), null);
delete document.documentElement.dataset.uiTheme;
});
test('a field focused while a save completes does not keep the saved key', async (t) => {
let finish;
const { $ } = await fixture(t, {
respond: () =>
new Promise((resolve) => {
finish = resolve;
}),
});
const field = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]');
field.value = 'AIza-test';
field.dispatchEvent(new Event('input', { bubbles: true }));
$('[data-key-setup-apply]').click();
await nextTick();
field.focus();
finish({
ok: true,
json: async () => ({ ok: true, saved: [], status: STATUS }),
});
await wait();
await nextTick();
const fresh = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]');
assert.equal(fresh.value, '');
assert.equal(fresh.isConnected, true);
});
test('a repeated status message is written again for the live region', async (t) => {
const { $ } = await fixture(t);
const line = $('[data-key-setup-status]');
const writes = [];
const observer = new MutationObserver((records) =>
writes.push(records.length),
);
observer.observe(line, {
childList: true,
characterData: true,
subtree: true,
});
t.after(() => observer.disconnect());
$('[data-key-setup-apply]').click();
await nextTick();
await wait();
const first = writes.length;
assert.ok(first > 0);
$('[data-key-setup-apply]').click();
await nextTick();
await wait();
assert.ok(writes.length > first);
assert.equal(line.textContent, 'Paste at least one key first.');
});
test('the chip does nothing until the setup endpoint has answered', async (t) => {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(ProviderSettingsView).use(pinia);
app.mount(host);
const store = useProviderSettingsStore(pinia);
const controller = new AbortController();
t.after(() => {
controller.abort();
app.unmount();
});
void initKeySetup({
documentRef: document,
fetchImpl: () => new Promise(() => {}),
signal: controller.signal,
store,
});
await nextTick();
store.controls.open();
await nextTick();
assert.equal(document.getElementById('key-setup').hidden, true);
});
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia';
import { markRaw, shallowRef } from 'vue';
/**
* Provider Settings (the dev-only key setup) as its view renders it. The
* setup owner (src/keySetup.js) presents the POWER UP chip, the dialog, its
* key rows, the field values and the status line, and publishes them here;
* the view sends clicks and typing to the connected owner.
*/
export const useProviderSettingsStore = defineStore(
'gev-provider-settings',
() => {
/** The presented setup; null until the owner first publishes. */
const view = shallowRef(null);
/** @param {?object} next A fresh presentation; every publish re-renders. */
function publish(next) {
view.value = next && markRaw(next);
}
/** The setup owner the controls call, or null while none is wired. */
const controls = shallowRef(null);
/** @param {?object} next The owner, or null to disconnect. */
function connect(next) {
controls.value = next && markRaw(next);
}
return { view, publish, controls, connect };
},
);
+85 -13
View File
@@ -1,40 +1,112 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, onBeforeUnmount, onMounted, shallowRef } from 'vue';
import { t } from '../../i18n/index.js';
import { vClassToggle } from '../rail/classToggle.js';
import { vFieldValue } from '../rail/fieldValue.js';
import { useLocaleStore } from '../stores/locale.js';
import { useProviderSettingsStore } from '../stores/providerSettings.js';
import LocalePicker from './panels/LocalePicker.vue';
const { picker } = storeToRefs(useLocaleStore());
// The key setup owner (src/keySetup.js) presents the chip, the dialog and its
// rows; until it does, the chip stays hidden and the dialog closed. When the
// setup endpoint is not there, the owner reports it unavailable and both go.
const { view, controls } = storeToRefs(useProviderSettingsStore());
const available = computed(() => view.value?.available !== false);
const chip = computed(() => view.value?.chip ?? null);
const dialog = computed(() => view.value?.dialog ?? { hidden: true, visible: false });
// Cyber puts the chip in the top toolbar; every other theme keeps it first in
// the corner row, beside the language picker.
const cyber = shallowRef(false);
let themeObserver = null;
onMounted(() => {
const theme = document.documentElement;
const sync = () => {
cyber.value =
theme.dataset.uiTheme === 'cyber' &&
Boolean(document.getElementById('top-center-actions'));
};
themeObserver = new MutationObserver(sync);
themeObserver.observe(theme, { attributes: true, attributeFilter: ['data-ui-theme'] });
sync();
});
onBeforeUnmount(() => themeObserver?.disconnect());
// The status line is a live region: write each message, even a repeated one,
// so a screen reader announces it again.
const vStatusText = {
mounted(el, { value }) {
el.textContent = value.text;
},
updated(el, { value, oldValue }) {
if (value.seq !== oldValue?.seq || value.text !== oldValue?.text)
el.textContent = value.text;
},
};
const statusText = computed(() => ({
text:
view.value?.status ??
t('The Google Maps key buys the photorealistic planet — everything else stacks on top.'),
seq: view.value?.statusSeq ?? 0,
}));
const removeTitle = (title) =>
t("Remove {title} from this app's saved keys", { title });
</script>
<template>
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js reports
it unavailable, removing both the chip and this dialog, when
/api/setup/status is not there to answer (prod builds, LAN visitors).
Rows are rendered from that payload, so the key registry lives in
src/keySetupCore.mjs alone. -->
<!-- Bottom-right HUD row: the POWER UP chip (while keys are missing) and
the language picker, once the shell publishes it. -->
<div id="corner-actions">
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label data-i18n>POWER UP</span>
</button>
<Teleport v-if="available" :to="cyber ? '#top-center-actions' : 'body'" :disabled="!cyber">
<button id="key-setup-chip" type="button" :hidden="!chip || chip.hidden" aria-haspopup="dialog" aria-controls="key-setup" :title="chip?.title" :aria-label="chip?.title" @click="controls?.open()">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label>{{ chip ? chip.label : t('POWER UP') }}</span>
</button>
</Teleport>
<LocalePicker v-if="picker" :key="picker.current" :current="picker.current" :on-select="picker.onSelect" />
</div>
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
<aside v-if="available" id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" :hidden="dialog.hidden" v-class-toggle="{ visible: dialog.visible }" :data-ready="view?.ready ? 'true' : undefined" @transitionend="controls?.transitionEnd()">
<div class="key-setup-scanline" aria-hidden="true"></div>
<header class="key-setup-header">
<span class="key-setup-kicker" data-i18n>GROUND STATION · PROVIDER SETTINGS</span>
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup" data-i18n-aria-label>
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup" data-i18n-aria-label @click="controls?.close()">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<h2 id="key-setup-title" data-i18n>Power up the globe</h2>
<p id="key-setup-description" data-i18n>The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
<div class="key-setup-rows ui-scroll" data-key-setup-rows></div>
<div class="key-setup-rows ui-scroll" data-key-setup-rows>
<section v-for="key in view?.rows ?? []" :key="`${key.id}:${view.generation}`" class="key-setup-row" :data-key-id="key.id" :data-set="String(key.set)" :data-managed="key.managed || undefined">
<div class="key-setup-row-head">
<span class="key-setup-led" aria-hidden="true"></span>
<strong>{{ key.title }}</strong>
<span class="key-setup-tier" :title="key.tierTitle">{{ key.tierDot }}</span>
<span v-if="key.clientExposed" class="key-setup-exposed" :title="t('This key runs in the browser by design — restrict it at the provider (see SECURITY.md)')">{{ t('browser-side') }}</span>
<span v-if="key.external" class="key-setup-external" :title="t('Supplied by your environment, Keychain, or launcher — change it where it was set')">{{ t('configured externally') }}</span>
<a class="key-setup-get" :href="key.getUrl" target="_blank" rel="noopener noreferrer">{{ key.set ? t('MANAGE ↗') : t('GET KEY ↗') }}</a>
</div>
<p class="key-setup-unlocks">{{ key.unlocks }}</p>
<div v-if="!key.external" class="key-setup-fields">
<input v-for="envVar in key.envVars" :key="envVar" type="password" autocomplete="off" spellcheck="false" :data-env-var="envVar" :aria-label="envVar" :placeholder="key.set ? t('{envVar} saved — paste to replace', { envVar }) : t('paste {envVar}', { envVar })" v-field-value="view.fields[envVar] ?? ''" @input="controls?.setField(envVar, $event.target.value)" />
<button v-if="key.managed === 'file'" type="button" class="key-setup-remove" :data-key-setup-remove="JSON.stringify(key.envVars)" :title="removeTitle(key.title)" @click="controls?.remove(key.envVars)">{{ t('REMOVE') }}</button>
</div>
<div v-if="key.id === 'openai'" class="key-setup-cloud-auth">
<span class="key-setup-cloud-auth-state">{{ view.auth.mode === 'oauth' ? t('VOICE AUTH · CHATGPT OAUTH') : t('VOICE AUTH · API KEY') }}</span>
<button type="button" class="key-setup-cloud-auth-toggle" data-cloud-voice-auth-toggle="true" :title="view.auth.mode === 'oauth' ? t('Use OPENAI_API_KEY for the next cloud voice session') : t('Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session')" :aria-disabled="String(view.auth.busy)" @click="controls?.toggleAuth()">{{ view.auth.mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH') }}</button>
</div>
</section>
</div>
<div class="key-setup-footer">
<button type="button" class="key-setup-apply" data-key-setup-apply data-i18n>SAVE KEYS</button>
<button type="button" class="key-setup-apply" data-key-setup-apply :aria-disabled="view?.saving == null ? undefined : String(view.saving)" data-i18n @click="controls?.apply()">SAVE KEYS</button>
<span class="key-setup-hint" data-i18n>ESC to close</span>
</div>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" data-i18n>The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite" v-status-text="statusText"></p>
</aside>
</template>