mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
6cb01ff975
commit
fe963a1219
9 files changed
+840
-517
No files matched your search
@@ -75,6 +75,7 @@
|
|||||||
"src/ui/stores/context.js",
|
"src/ui/stores/context.js",
|
||||||
"src/ui/stores/cockpit.js",
|
"src/ui/stores/cockpit.js",
|
||||||
"src/ui/stores/locale.js",
|
"src/ui/stores/locale.js",
|
||||||
|
"src/ui/stores/providerSettings.js",
|
||||||
"src/ui/stores/location.js",
|
"src/ui/stores/location.js",
|
||||||
"src/ui/stores/streetLevel.js",
|
"src/ui/stores/streetLevel.js",
|
||||||
"src/ui/rail/RailChips.vue",
|
"src/ui/rail/RailChips.vue",
|
||||||
@@ -1002,6 +1003,7 @@
|
|||||||
"src/ui/stores/context.js",
|
"src/ui/stores/context.js",
|
||||||
"src/ui/stores/cockpit.js",
|
"src/ui/stores/cockpit.js",
|
||||||
"src/ui/stores/locale.js",
|
"src/ui/stores/locale.js",
|
||||||
|
"src/ui/stores/providerSettings.js",
|
||||||
"src/ui/stores/location.js",
|
"src/ui/stores/location.js",
|
||||||
"src/ui/stores/display.js"
|
"src/ui/stores/display.js"
|
||||||
],
|
],
|
||||||
@@ -2980,6 +2982,7 @@
|
|||||||
"src/ui/stores/context.js",
|
"src/ui/stores/context.js",
|
||||||
"src/ui/stores/cockpit.js",
|
"src/ui/stores/cockpit.js",
|
||||||
"src/ui/stores/locale.js",
|
"src/ui/stores/locale.js",
|
||||||
|
"src/ui/stores/providerSettings.js",
|
||||||
"src/ui/stores/location.js",
|
"src/ui/stores/location.js",
|
||||||
"src/ui/stores/display.js"
|
"src/ui/stores/display.js"
|
||||||
],
|
],
|
||||||
|
|||||||
+244
-358
@@ -1,4 +1,6 @@
|
|||||||
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
|
||||||
|
import { useProviderSettingsStore } from './ui/stores/providerSettings.js';
|
||||||
|
import { applicationPinia } from './ui/vueRuntime.js';
|
||||||
import {
|
import {
|
||||||
readStoredCloudVoiceAuthMode,
|
readStoredCloudVoiceAuthMode,
|
||||||
writeStoredCloudVoiceAuthMode,
|
writeStoredCloudVoiceAuthMode,
|
||||||
@@ -18,6 +20,10 @@ import {
|
|||||||
* The surface self-destructs where it cannot work: a prod build (no endpoint)
|
* The surface self-destructs where it cannot work: a prod build (no endpoint)
|
||||||
* or a LAN visitor (loopback-only endpoint) fails the status fetch, and both
|
* or a LAN visitor (loopback-only endpoint) fails the status fetch, and both
|
||||||
* the chip and the dialog are removed outright.
|
* the chip and the dialog are removed outright.
|
||||||
|
*
|
||||||
|
* ProviderSettingsView renders it: initKeySetup() keeps the requests, the
|
||||||
|
* field values and the dialog's keyboard, and publishes what is shown to the
|
||||||
|
* gev-provider-settings store.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
|
import { t, msg, tn, registerUnsavedWork } from './i18n/index.js';
|
||||||
@@ -217,156 +223,26 @@ export function stripKeylessBasemapFromHash(hash) {
|
|||||||
|
|
||||||
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
|
const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' });
|
||||||
|
|
||||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
/** One key row as the view renders it, from the status payload's own text. */
|
||||||
const mode = readStoredCloudVoiceAuthMode();
|
function presentRow(key) {
|
||||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
return {
|
||||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
id: key.id,
|
||||||
state.textContent =
|
title: key.title,
|
||||||
mode === 'oauth'
|
set: Boolean(key.set),
|
||||||
? t('VOICE AUTH · CHATGPT OAUTH')
|
managed: key.managed || null,
|
||||||
: t('VOICE AUTH · API KEY');
|
external: key.managed === 'external',
|
||||||
toggle.textContent =
|
tierDot: TIER_DOTS[key.tier] || '',
|
||||||
mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH');
|
tierTitle:
|
||||||
toggle.title =
|
key.tier === 'metered'
|
||||||
mode === 'oauth'
|
? t('Metered — a billing-enabled account')
|
||||||
? t('Use OPENAI_API_KEY for the next cloud voice session')
|
: key.tier === 'paid'
|
||||||
: t(
|
? t('Optional paid token — public access works without one')
|
||||||
'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session',
|
: t('Free key — register, paste, done'),
|
||||||
);
|
clientExposed: Boolean(key.clientExposed),
|
||||||
// Keep the control focusable while the delegated handler blocks repeats.
|
getUrl: key.getUrl,
|
||||||
toggle.setAttribute('aria-disabled', String(busy));
|
unlocks: t(key.unlocks),
|
||||||
}
|
envVars: [...(key.envVars || [])],
|
||||||
|
|
||||||
/** Build one key row. All content is our own registry text, set via textContent. */
|
|
||||||
function buildRow(documentRef, key) {
|
|
||||||
const row = documentRef.createElement('section');
|
|
||||||
row.className = 'key-setup-row';
|
|
||||||
row.dataset.keyId = key.id;
|
|
||||||
row.dataset.set = String(Boolean(key.set));
|
|
||||||
if (key.managed) row.dataset.managed = key.managed;
|
|
||||||
const external = key.managed === 'external';
|
|
||||||
|
|
||||||
const head = documentRef.createElement('div');
|
|
||||||
head.className = 'key-setup-row-head';
|
|
||||||
const led = documentRef.createElement('span');
|
|
||||||
led.className = 'key-setup-led';
|
|
||||||
led.setAttribute('aria-hidden', 'true');
|
|
||||||
const title = documentRef.createElement('strong');
|
|
||||||
title.textContent = key.title; // i18n-ignore: provider product name
|
|
||||||
const tier = documentRef.createElement('span');
|
|
||||||
tier.className = 'key-setup-tier';
|
|
||||||
tier.textContent = TIER_DOTS[key.tier] || '';
|
|
||||||
tier.title =
|
|
||||||
key.tier === 'metered'
|
|
||||||
? t('Metered — a billing-enabled account')
|
|
||||||
: key.tier === 'paid'
|
|
||||||
? t('Optional paid token — public access works without one')
|
|
||||||
: t('Free key — register, paste, done');
|
|
||||||
head.append(led, title, tier);
|
|
||||||
if (key.clientExposed) {
|
|
||||||
const exposed = documentRef.createElement('span');
|
|
||||||
exposed.className = 'key-setup-exposed';
|
|
||||||
exposed.textContent = t('browser-side');
|
|
||||||
exposed.title = t(
|
|
||||||
'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)',
|
|
||||||
);
|
|
||||||
head.append(exposed);
|
|
||||||
}
|
|
||||||
if (external) {
|
|
||||||
// Externally supplied credentials (shell env, Keychain, a launcher) are
|
|
||||||
// facts this panel reports, never values it rewrites or deletes.
|
|
||||||
const badge = documentRef.createElement('span');
|
|
||||||
badge.className = 'key-setup-external';
|
|
||||||
badge.textContent = t('configured externally');
|
|
||||||
badge.title = t(
|
|
||||||
'Supplied by your environment, Keychain, or launcher — change it where it was set',
|
|
||||||
);
|
|
||||||
head.append(badge);
|
|
||||||
}
|
|
||||||
const get = documentRef.createElement('a');
|
|
||||||
get.className = 'key-setup-get';
|
|
||||||
get.href = key.getUrl;
|
|
||||||
get.target = '_blank';
|
|
||||||
get.rel = 'noopener noreferrer';
|
|
||||||
get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗');
|
|
||||||
head.append(get);
|
|
||||||
|
|
||||||
const unlocks = documentRef.createElement('p');
|
|
||||||
unlocks.className = 'key-setup-unlocks';
|
|
||||||
unlocks.textContent = t(key.unlocks);
|
|
||||||
|
|
||||||
row.append(head, unlocks);
|
|
||||||
if (!external) {
|
|
||||||
const fields = documentRef.createElement('div');
|
|
||||||
fields.className = 'key-setup-fields';
|
|
||||||
for (const envVar of key.envVars) {
|
|
||||||
const input = documentRef.createElement('input');
|
|
||||||
// Passwords-style so a pasted key never shows on a shared or recorded
|
|
||||||
// screen — this app gets screen-recorded a lot.
|
|
||||||
input.type = 'password';
|
|
||||||
input.autocomplete = 'off';
|
|
||||||
input.spellcheck = false;
|
|
||||||
input.dataset.envVar = envVar;
|
|
||||||
input.setAttribute('aria-label', envVar);
|
|
||||||
input.placeholder = key.set
|
|
||||||
? t('{envVar} saved — paste to replace', { envVar })
|
|
||||||
: t('paste {envVar}', { envVar });
|
|
||||||
fields.append(input);
|
|
||||||
}
|
|
||||||
if (key.managed === 'file') {
|
|
||||||
const remove = documentRef.createElement('button');
|
|
||||||
remove.type = 'button';
|
|
||||||
remove.className = 'key-setup-remove';
|
|
||||||
remove.dataset.keySetupRemove = JSON.stringify(key.envVars);
|
|
||||||
remove.textContent = t('REMOVE');
|
|
||||||
remove.title = t("Remove {title} from this app's saved keys", {
|
|
||||||
title: key.title,
|
|
||||||
});
|
|
||||||
fields.append(remove);
|
|
||||||
}
|
|
||||||
row.append(fields);
|
|
||||||
}
|
|
||||||
if (key.id === 'openai') {
|
|
||||||
const auth = documentRef.createElement('div');
|
|
||||||
auth.className = 'key-setup-cloud-auth';
|
|
||||||
const state = documentRef.createElement('span');
|
|
||||||
state.className = 'key-setup-cloud-auth-state';
|
|
||||||
const toggle = documentRef.createElement('button');
|
|
||||||
toggle.type = 'button';
|
|
||||||
toggle.className = 'key-setup-cloud-auth-toggle';
|
|
||||||
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
|
||||||
auth.append(state, toggle);
|
|
||||||
updateCloudVoiceAuthControl(auth);
|
|
||||||
row.append(auth);
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Move the existing setup action with the theme, retaining its listeners:
|
|
||||||
* Cyber puts it in the top toolbar, every other theme keeps it first in its
|
|
||||||
* markup home (the bottom-right corner row, beside the language picker).
|
|
||||||
*/
|
|
||||||
export function bindKeySetupPlacement(documentRef, chip) {
|
|
||||||
const theme = documentRef?.documentElement;
|
|
||||||
const toolbar = documentRef?.getElementById?.('top-center-actions');
|
|
||||||
const Observer = documentRef?.defaultView?.MutationObserver;
|
|
||||||
const home = chip?.parentNode;
|
|
||||||
if (!theme || !toolbar || !Observer || !home) return () => {};
|
|
||||||
const sync = () => {
|
|
||||||
if (theme.dataset.uiTheme === 'cyber') {
|
|
||||||
if (chip.parentNode !== toolbar) toolbar.append(chip);
|
|
||||||
} else if (chip.parentNode !== home) {
|
|
||||||
home.prepend(chip);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
const observer = new Observer(sync);
|
|
||||||
observer.observe(theme, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ['data-ui-theme'],
|
|
||||||
});
|
|
||||||
sync();
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
|
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
|
||||||
@@ -374,91 +250,78 @@ export async function initKeySetup({
|
|||||||
documentRef = globalThis.document,
|
documentRef = globalThis.document,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
signal,
|
signal,
|
||||||
|
store = useProviderSettingsStore(applicationPinia()),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const chip = documentRef?.getElementById?.('key-setup-chip');
|
|
||||||
const root = documentRef?.getElementById?.('key-setup');
|
const root = documentRef?.getElementById?.('key-setup');
|
||||||
if (!chip || !root || root.dataset.initialized === 'true') return null;
|
if (!root || store.controls) return null;
|
||||||
root.dataset.initialized = 'true';
|
|
||||||
const lifetime = new AbortController();
|
const lifetime = new AbortController();
|
||||||
|
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let disposeControls = () => {};
|
// Pasted keys live only here until Save.
|
||||||
let disposePlacement = () => {};
|
const fields = {};
|
||||||
// A pasted key lives only in its field until Save: switching language (a
|
const state = {
|
||||||
// reload) asks first while any field holds one.
|
available: null,
|
||||||
|
ready: false,
|
||||||
|
chip: null,
|
||||||
|
dialog: { hidden: true, visible: false },
|
||||||
|
rows: [],
|
||||||
|
// Bumped by a save: the rows render afresh, so no field keeps a key.
|
||||||
|
generation: 0,
|
||||||
|
fields,
|
||||||
|
status: null,
|
||||||
|
// Counts messages, so the live region announces a repeated one again.
|
||||||
|
statusSeq: 0,
|
||||||
|
saving: null,
|
||||||
|
auth: { mode: readStoredCloudVoiceAuthMode(), busy: false },
|
||||||
|
};
|
||||||
|
const publish = () => {
|
||||||
|
if (store.controls !== controls) return;
|
||||||
|
store.publish({
|
||||||
|
...state,
|
||||||
|
chip: state.chip && { ...state.chip },
|
||||||
|
dialog: { ...state.dialog },
|
||||||
|
fields: { ...fields },
|
||||||
|
auth: { ...state.auth },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// Switching language (a reload) asks first while any field holds a key.
|
||||||
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
const unregisterUnsavedWork = registerUnsavedWork(() =>
|
||||||
[...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) =>
|
Object.values(fields).some((value) => String(value ?? '').trim())
|
||||||
String(input.value ?? '').trim(),
|
|
||||||
)
|
|
||||||
? msg('unsaved provider keys')
|
? msg('unsaved provider keys')
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
delete root.dataset.ready;
|
|
||||||
unregisterUnsavedWork();
|
unregisterUnsavedWork();
|
||||||
lifetime.abort();
|
lifetime.abort();
|
||||||
signal?.removeEventListener('abort', destroy);
|
signal?.removeEventListener('abort', destroy);
|
||||||
disposeControls();
|
open = false;
|
||||||
disposePlacement();
|
keyboard.destroy();
|
||||||
chip.remove();
|
state.available = false;
|
||||||
root.remove();
|
state.ready = false;
|
||||||
|
publish();
|
||||||
|
if (store.controls === controls) store.connect(null);
|
||||||
};
|
};
|
||||||
if (signal?.aborted) {
|
|
||||||
destroy();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
signal?.addEventListener('abort', destroy, { once: true });
|
|
||||||
const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis);
|
|
||||||
|
|
||||||
let status = null;
|
let status = null;
|
||||||
try {
|
|
||||||
const response = await doFetch('/api/setup/status', {
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: lifetime.signal,
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(String(response.status));
|
|
||||||
status = await response.json();
|
|
||||||
if (disposed) return null;
|
|
||||||
} catch {
|
|
||||||
// Prod build or non-loopback visitor: the surface cannot function, so it
|
|
||||||
// does not exist. (The README covers .env for headless/self-host setups.)
|
|
||||||
destroy();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowsHost = root.querySelector('[data-key-setup-rows]');
|
|
||||||
disposePlacement = bindKeySetupPlacement(documentRef, chip);
|
|
||||||
const applyButton = root.querySelector('[data-key-setup-apply]');
|
|
||||||
const closeButton = root.querySelector('[data-key-setup-close]');
|
|
||||||
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
|
|
||||||
const statusLine = root.querySelector('[data-key-setup-status]');
|
|
||||||
const defaultStatusText = statusLine?.textContent || '';
|
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let oauthBusy = false;
|
|
||||||
let open = false;
|
let open = false;
|
||||||
|
|
||||||
const syncCloudVoiceAuth = () => {
|
|
||||||
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
|
|
||||||
updateCloudVoiceAuthControl(auth, oauthBusy);
|
|
||||||
};
|
|
||||||
|
|
||||||
const render = (nextStatus) => {
|
const render = (nextStatus) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
status = nextStatus;
|
status = nextStatus;
|
||||||
chipLabel.textContent = keySetupChipLabel(status);
|
const label = keySetupChipLabel(status);
|
||||||
chip.title = t('Project keys: {status}', {
|
|
||||||
status: keySetupChipLabel(status),
|
|
||||||
});
|
|
||||||
chip.setAttribute('aria-label', chip.title);
|
|
||||||
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
// Fully powered is the owner's clean screen: the chip retires. The dialog
|
||||||
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
// stays reachable this session (and via ?setup=1) to swap or verify keys.
|
||||||
chip.hidden = status.setCount >= status.total;
|
state.chip = {
|
||||||
if (!rowsHost) return;
|
label,
|
||||||
rowsHost.textContent = '';
|
title: t('Project keys: {status}', { status: label }),
|
||||||
for (const key of status.keys || [])
|
hidden: status.setCount >= status.total,
|
||||||
rowsHost.append(buildRow(documentRef, key));
|
};
|
||||||
syncCloudVoiceAuth();
|
state.rows = (status.keys || []).map(presentRow);
|
||||||
|
state.auth = { ...state.auth, mode: readStoredCloudVoiceAuthMode() };
|
||||||
|
publish();
|
||||||
};
|
};
|
||||||
|
|
||||||
const visible = () =>
|
const visible = () =>
|
||||||
@@ -474,32 +337,48 @@ export async function initKeySetup({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openDialog = () => {
|
const openDialog = () => {
|
||||||
if (disposed || open) return;
|
if (disposed || open || !state.ready) return;
|
||||||
open = true;
|
open = true;
|
||||||
keyboard.activate();
|
keyboard.activate();
|
||||||
root.hidden = false;
|
state.dialog = { hidden: false, visible: false };
|
||||||
|
publish();
|
||||||
|
// The frame runs after Vue shows the dialog, so the fade starts from it.
|
||||||
globalThis.requestAnimationFrame?.(() => {
|
globalThis.requestAnimationFrame?.(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
root.classList.add('visible');
|
state.dialog = { hidden: false, visible: true };
|
||||||
root.querySelector('input')?.focus?.({ preventScroll: true });
|
publish();
|
||||||
|
// The fields are already rendered; focus after Vue applies the class.
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
if (open) root.querySelector('input')?.focus?.({ preventScroll: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hideIfClosed = () => {
|
||||||
|
if (open || disposed || state.dialog.hidden) return;
|
||||||
|
state.dialog = { hidden: true, visible: false };
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
open = false;
|
open = false;
|
||||||
root.classList.remove('visible');
|
state.dialog = { hidden: false, visible: false };
|
||||||
const hide = () => {
|
state.status = null;
|
||||||
if (!open) root.hidden = true;
|
publish();
|
||||||
};
|
globalThis.setTimeout?.(hideIfClosed, 400);
|
||||||
root.addEventListener('transitionend', hide, { once: true });
|
|
||||||
globalThis.setTimeout?.(hide, 400);
|
|
||||||
if (statusLine) statusLine.textContent = defaultStatusText;
|
|
||||||
keyboard.deactivate({ restoreFocus: true });
|
keyboard.deactivate({ restoreFocus: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
const say = (text) => {
|
const say = (text) => {
|
||||||
if (statusLine) statusLine.textContent = text;
|
state.status = text;
|
||||||
|
state.statusSeq += 1;
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAuth = (busyNow = state.auth.busy) => {
|
||||||
|
state.auth = { mode: readStoredCloudVoiceAuthMode(), busy: busyNow };
|
||||||
|
publish();
|
||||||
};
|
};
|
||||||
|
|
||||||
const storeLabel = () =>
|
const storeLabel = () =>
|
||||||
@@ -515,7 +394,7 @@ export async function initKeySetup({
|
|||||||
(key) => key.id === 'google-maps',
|
(key) => key.id === 'google-maps',
|
||||||
)?.set;
|
)?.set;
|
||||||
busy = true;
|
busy = true;
|
||||||
applyButton?.setAttribute('aria-disabled', 'true');
|
state.saving = true;
|
||||||
say(t('Saving…'));
|
say(t('Saving…'));
|
||||||
try {
|
try {
|
||||||
const response = await doFetch('/api/setup/keys', {
|
const response = await doFetch('/api/setup/keys', {
|
||||||
@@ -530,8 +409,8 @@ export async function initKeySetup({
|
|||||||
say(saveRefusalText(payload, response.status));
|
say(saveRefusalText(payload, response.status));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const input of root.querySelectorAll('input[data-env-var]'))
|
for (const envVar of Object.keys(fields)) delete fields[envVar];
|
||||||
input.value = '';
|
state.generation += 1;
|
||||||
render(payload.status);
|
render(payload.status);
|
||||||
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) {
|
||||||
const strip = () => {
|
const strip = () => {
|
||||||
@@ -566,18 +445,15 @@ export async function initKeySetup({
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
applyButton?.setAttribute('aria-disabled', 'false');
|
state.saving = false;
|
||||||
|
publish();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onApply = async () => {
|
const apply = async () => {
|
||||||
if (disposed || busy) return;
|
if (disposed || busy) return;
|
||||||
const inputs = [...root.querySelectorAll('input[data-env-var]')];
|
|
||||||
const updates = collectKeyUpdates(
|
const updates = collectKeyUpdates(
|
||||||
inputs.map((input) => ({
|
Object.entries(fields).map(([envVar, value]) => ({ envVar, value })),
|
||||||
envVar: input.dataset.envVar,
|
|
||||||
value: input.value,
|
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
if (!Object.keys(updates).length) {
|
if (!Object.keys(updates).length) {
|
||||||
say(t('Paste at least one key first.'));
|
say(t('Paste at least one key first.'));
|
||||||
@@ -589,125 +465,8 @@ export async function initKeySetup({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
chip.addEventListener('click', openDialog);
|
const remove = (envVars) => {
|
||||||
closeButton?.addEventListener('click', close);
|
if (disposed || busy) return;
|
||||||
applyButton?.addEventListener('click', onApply);
|
|
||||||
root.dataset.ready = 'true';
|
|
||||||
// Remove buttons are rendered per row; delegate so re-renders stay wired.
|
|
||||||
rowsHost?.addEventListener('click', (event) => {
|
|
||||||
const authButton = event.target?.closest?.(
|
|
||||||
'[data-cloud-voice-auth-toggle]',
|
|
||||||
);
|
|
||||||
if (authButton && !disposed) {
|
|
||||||
if (oauthBusy) return;
|
|
||||||
void (async () => {
|
|
||||||
const current = readStoredCloudVoiceAuthMode();
|
|
||||||
if (current === 'oauth') {
|
|
||||||
writeStoredCloudVoiceAuthMode('api-key');
|
|
||||||
syncCloudVoiceAuth();
|
|
||||||
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
oauthBusy = true;
|
|
||||||
syncCloudVoiceAuth();
|
|
||||||
say(t('Checking local ChatGPT OAuth sign-in…'));
|
|
||||||
try {
|
|
||||||
const response = await doFetch('/api/realtime/oauth-status', {
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: lifetime.signal,
|
|
||||||
});
|
|
||||||
const payload = await response.json().catch(() => ({}));
|
|
||||||
if (lifetime.signal.aborted) return;
|
|
||||||
if (!response.ok)
|
|
||||||
throw oauthCheckError(
|
|
||||||
payload.error,
|
|
||||||
t('Could not check ChatGPT sign-in.'),
|
|
||||||
);
|
|
||||||
if (payload.available) {
|
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
|
||||||
syncCloudVoiceAuth();
|
|
||||||
say(
|
|
||||||
t(
|
|
||||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
say(
|
|
||||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
|
||||||
? t('ChatGPT sign-in expired. Opening sign-in again…')
|
|
||||||
: t('Opening ChatGPT sign-in in your browser…'),
|
|
||||||
);
|
|
||||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
|
||||||
method: 'POST',
|
|
||||||
cache: 'no-store',
|
|
||||||
signal: lifetime.signal,
|
|
||||||
});
|
|
||||||
const loginPayload = await loginResponse.json().catch(() => ({}));
|
|
||||||
if (lifetime.signal.aborted) return;
|
|
||||||
if (!loginResponse.ok) {
|
|
||||||
say(
|
|
||||||
loginPayload.error ||
|
|
||||||
payload.error ||
|
|
||||||
t('Could not start ChatGPT sign-in on this machine.'),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (loginPayload.available) {
|
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
|
||||||
syncCloudVoiceAuth();
|
|
||||||
say(
|
|
||||||
t(
|
|
||||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
say(
|
|
||||||
t(
|
|
||||||
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const available = await waitForChatGptOAuth({
|
|
||||||
fetchImpl: doFetch,
|
|
||||||
signal: lifetime.signal,
|
|
||||||
});
|
|
||||||
if (lifetime.signal.aborted) return;
|
|
||||||
if (!available) {
|
|
||||||
say(
|
|
||||||
t('ChatGPT sign-in timed out. Click {button} to try again.', {
|
|
||||||
button: t('USE CHATGPT OAUTH'),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
|
||||||
syncCloudVoiceAuth();
|
|
||||||
say(
|
|
||||||
t(
|
|
||||||
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
if (lifetime.signal.aborted) return;
|
|
||||||
say(oauthCheckFailureText(error));
|
|
||||||
} finally {
|
|
||||||
oauthBusy = false;
|
|
||||||
if (!disposed) syncCloudVoiceAuth();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const button = event.target?.closest?.('[data-key-setup-remove]');
|
|
||||||
if (disposed || !button || busy) return;
|
|
||||||
let envVars = [];
|
|
||||||
try {
|
|
||||||
envVars = JSON.parse(button.dataset.keySetupRemove || '[]');
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(envVars) || !envVars.length) return;
|
if (!Array.isArray(envVars) || !envVars.length) return;
|
||||||
// Removal is destructive and — behind a framing defense that should already
|
// Removal is destructive and — behind a framing defense that should already
|
||||||
// stop it — a clickjack target. A confirm turns a single aligned click into
|
// stop it — a clickjack target. A confirm turns a single aligned click into
|
||||||
@@ -720,8 +479,142 @@ export async function initKeySetup({
|
|||||||
Object.fromEntries(envVars.map((name) => [name, null])),
|
Object.fromEntries(envVars.map((name) => [name, null])),
|
||||||
msg('Removed from {store}. Restarting — this page reloads itself.'),
|
msg('Removed from {store}. Restarting — this page reloads itself.'),
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const toggleAuth = async () => {
|
||||||
|
if (disposed || state.auth.busy) return;
|
||||||
|
const current = readStoredCloudVoiceAuthMode();
|
||||||
|
if (current === 'oauth') {
|
||||||
|
writeStoredCloudVoiceAuthMode('api-key');
|
||||||
|
syncAuth();
|
||||||
|
say(t('Cloud voice will use OPENAI_API_KEY on the next session.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncAuth(true);
|
||||||
|
say(t('Checking local ChatGPT OAuth sign-in…'));
|
||||||
|
try {
|
||||||
|
const response = await doFetch('/api/realtime/oauth-status', {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
if (!response.ok)
|
||||||
|
throw oauthCheckError(
|
||||||
|
payload.error,
|
||||||
|
t('Could not check ChatGPT sign-in.'),
|
||||||
|
);
|
||||||
|
if (payload.available) {
|
||||||
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
|
syncAuth();
|
||||||
|
say(
|
||||||
|
t(
|
||||||
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
say(
|
||||||
|
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||||
|
? t('ChatGPT sign-in expired. Opening sign-in again…')
|
||||||
|
: t('Opening ChatGPT sign-in in your browser…'),
|
||||||
|
);
|
||||||
|
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||||
|
method: 'POST',
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
const loginPayload = await loginResponse.json().catch(() => ({}));
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
say(
|
||||||
|
loginPayload.error ||
|
||||||
|
payload.error ||
|
||||||
|
t('Could not start ChatGPT sign-in on this machine.'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (loginPayload.available) {
|
||||||
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
|
syncAuth();
|
||||||
|
say(
|
||||||
|
t(
|
||||||
|
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
say(
|
||||||
|
t('Finish ChatGPT sign-in in the browser. Waiting for it to complete…'),
|
||||||
|
);
|
||||||
|
const available = await waitForChatGptOAuth({
|
||||||
|
fetchImpl: doFetch,
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
if (!available) {
|
||||||
|
say(
|
||||||
|
t('ChatGPT sign-in timed out. Click {button} to try again.', {
|
||||||
|
button: t('USE CHATGPT OAUTH'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
|
syncAuth();
|
||||||
|
say(
|
||||||
|
t(
|
||||||
|
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
|
say(oauthCheckFailureText(error));
|
||||||
|
} finally {
|
||||||
|
if (!disposed) syncAuth(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controls = {
|
||||||
|
open: openDialog,
|
||||||
|
close,
|
||||||
|
apply,
|
||||||
|
remove,
|
||||||
|
toggleAuth: () => void toggleAuth(),
|
||||||
|
setField(envVar, value) {
|
||||||
|
fields[envVar] = String(value ?? '');
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
transitionEnd: hideIfClosed,
|
||||||
|
};
|
||||||
|
store.connect(controls);
|
||||||
|
publish();
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
destroy();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
signal?.addEventListener('abort', destroy, { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await doFetch('/api/setup/status', {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: lifetime.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(String(response.status));
|
||||||
|
status = await response.json();
|
||||||
|
if (disposed) return null;
|
||||||
|
} catch {
|
||||||
|
// Prod build or non-loopback visitor: the surface cannot function, so it
|
||||||
|
// does not exist. (The README covers .env for headless/self-host setups.)
|
||||||
|
destroy();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.available = true;
|
||||||
|
state.ready = true;
|
||||||
render(status);
|
render(status);
|
||||||
|
|
||||||
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
// Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the
|
||||||
@@ -736,12 +629,5 @@ export async function initKeySetup({
|
|||||||
// An unparsable location never blocks init.
|
// An unparsable location never blocks init.
|
||||||
}
|
}
|
||||||
|
|
||||||
disposeControls = () => {
|
|
||||||
open = false;
|
|
||||||
keyboard.destroy();
|
|
||||||
chip.removeEventListener('click', openDialog);
|
|
||||||
closeButton?.removeEventListener('click', close);
|
|
||||||
applyButton?.removeEventListener('click', onApply);
|
|
||||||
};
|
|
||||||
return { open: openDialog, close, render, destroy };
|
return { open: openDialog, close, render, destroy };
|
||||||
}
|
}
|
||||||
+17
-74
@@ -4,61 +4,14 @@ import {
|
|||||||
collectKeyUpdates,
|
collectKeyUpdates,
|
||||||
keySetupChipLabel,
|
keySetupChipLabel,
|
||||||
stripKeylessBasemapFromHash,
|
stripKeylessBasemapFromHash,
|
||||||
bindKeySetupPlacement,
|
|
||||||
waitForChatGptOAuth,
|
waitForChatGptOAuth,
|
||||||
initKeySetup,
|
initKeySetup,
|
||||||
} from './keySetup.js';
|
} from './keySetup.js';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
import { describeUnsavedWork } from './i18n/index.js';
|
import { describeUnsavedWork } from './i18n/index.js';
|
||||||
|
import { useProviderSettingsStore } from './ui/stores/providerSettings.js';
|
||||||
|
|
||||||
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
|
const freshStore = () => useProviderSettingsStore(createPinia());
|
||||||
const home = {
|
|
||||||
prepend: (node) => {
|
|
||||||
node.parentNode = home;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const chip = { parentNode: home, hidden: true };
|
|
||||||
let moves = 0;
|
|
||||||
const toolbar = {
|
|
||||||
append: (node) => {
|
|
||||||
moves++;
|
|
||||||
node.parentNode = toolbar;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const theme = { dataset: { uiTheme: 'tactical' } };
|
|
||||||
let notify,
|
|
||||||
disconnected = false;
|
|
||||||
class Observer {
|
|
||||||
constructor(callback) {
|
|
||||||
notify = callback;
|
|
||||||
}
|
|
||||||
observe(target, options) {
|
|
||||||
assert.equal(target, theme);
|
|
||||||
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
|
|
||||||
}
|
|
||||||
disconnect() {
|
|
||||||
disconnected = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const dispose = bindKeySetupPlacement(
|
|
||||||
{
|
|
||||||
documentElement: theme,
|
|
||||||
getElementById: () => toolbar,
|
|
||||||
defaultView: { MutationObserver: Observer },
|
|
||||||
},
|
|
||||||
chip,
|
|
||||||
);
|
|
||||||
assert.equal(chip.parentNode, home);
|
|
||||||
for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) {
|
|
||||||
theme.dataset.uiTheme = variant;
|
|
||||||
notify();
|
|
||||||
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
|
|
||||||
notify();
|
|
||||||
}
|
|
||||||
assert.equal(moves, 2, 'identical theme sync does not reparent again');
|
|
||||||
assert.equal(chip.hidden, true, 'placement cannot reveal a retired button');
|
|
||||||
dispose();
|
|
||||||
assert.equal(disconnected, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the chip counts what is missing, and retires the count at zero', () => {
|
test('the chip counts what is missing, and retires the count at zero', () => {
|
||||||
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
|
||||||
@@ -153,52 +106,42 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('aborting pending setup removes its surface and ignores a late response', async () => {
|
test('aborting pending setup removes its surface and ignores a late response', async () => {
|
||||||
const { initKeySetup } = await import('./keySetup.js');
|
const store = freshStore();
|
||||||
const removed = [];
|
const root = { dataset: {} };
|
||||||
const chip = { remove: () => removed.push('chip') };
|
|
||||||
const root = { dataset: {}, remove: () => removed.push('root') };
|
|
||||||
let resolveResponse;
|
let resolveResponse;
|
||||||
let requestSignal;
|
let requestSignal;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const pending = initKeySetup({
|
const pending = initKeySetup({
|
||||||
documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root },
|
documentRef: { getElementById: (id) => (id === 'key-setup' ? root : null) },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
store,
|
||||||
fetchImpl: (_url, { signal }) => {
|
fetchImpl: (_url, { signal }) => {
|
||||||
requestSignal = signal;
|
requestSignal = signal;
|
||||||
return new Promise((resolve) => { resolveResponse = resolve; });
|
return new Promise((resolve) => { resolveResponse = resolve; });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assert.equal(store.controls !== null, true);
|
||||||
controller.abort();
|
controller.abort();
|
||||||
assert.equal(requestSignal.aborted, true);
|
assert.equal(requestSignal.aborted, true);
|
||||||
assert.deepEqual(removed, ['chip', 'root']);
|
assert.equal(store.view.available, false);
|
||||||
|
assert.equal(store.controls, null);
|
||||||
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
resolveResponse({ ok: true, json: async () => ({ keys: [] }) });
|
||||||
assert.equal(await pending, null);
|
assert.equal(await pending, null);
|
||||||
|
assert.equal(store.view.available, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
|
test('a pasted, unsaved key makes the language switch ask first until teardown', async () => {
|
||||||
const node = () => ({
|
const store = freshStore();
|
||||||
dataset: {},
|
const root = { dataset: {} };
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
setAttribute() {},
|
|
||||||
querySelector: () => null,
|
|
||||||
remove() {},
|
|
||||||
});
|
|
||||||
const chip = node();
|
|
||||||
const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' };
|
|
||||||
const root = {
|
|
||||||
...node(),
|
|
||||||
querySelectorAll: (selector) =>
|
|
||||||
selector === 'input[data-env-var]' ? [input] : [],
|
|
||||||
};
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const setup = await initKeySetup({
|
const setup = await initKeySetup({
|
||||||
documentRef: {
|
documentRef: {
|
||||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
getElementById: (id) => (id === 'key-setup' ? root : null),
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
},
|
},
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
store,
|
||||||
fetchImpl: async () => ({
|
fetchImpl: async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ keys: [], setCount: 0, total: 1 }),
|
json: async () => ({ keys: [], setCount: 0, total: 1 }),
|
||||||
@@ -206,9 +149,9 @@ test('a pasted, unsaved key makes the language switch ask first until teardown',
|
|||||||
});
|
});
|
||||||
assert.ok(setup);
|
assert.ok(setup);
|
||||||
assert.deepEqual(describeUnsavedWork(), []);
|
assert.deepEqual(describeUnsavedWork(), []);
|
||||||
input.value = ' ';
|
store.controls.setField('GOOGLE_MAPS_API_KEY', ' ');
|
||||||
assert.deepEqual(describeUnsavedWork(), []);
|
assert.deepEqual(describeUnsavedWork(), []);
|
||||||
input.value = 'AIza-pasted';
|
store.controls.setField('GOOGLE_MAPS_API_KEY', 'AIza-pasted');
|
||||||
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
|
assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']);
|
||||||
// The field still holds the key, so only teardown dropping the check clears it.
|
// The field still holds the key, so only teardown dropping the check clears it.
|
||||||
controller.abort();
|
controller.abort();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from '../../server/providers/space/launch-library.js';
|
} from '../../server/providers/space/launch-library.js';
|
||||||
import { keySetupStatus } from '../keySetupCore.mjs';
|
import { keySetupStatus } from '../keySetupCore.mjs';
|
||||||
import { initKeySetup } from '../keySetup.js';
|
import { initKeySetup } from '../keySetup.js';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import { useProviderSettingsStore } from '../ui/stores/providerSettings.js';
|
||||||
|
|
||||||
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
|
test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => {
|
||||||
const first = new Date('2026-10-17T16:20:00.001Z');
|
const first = new Date('2026-10-17T16:20:00.001Z');
|
||||||
@@ -57,60 +59,31 @@ test('LL2 headers identify the app and preserve optional server-only authorizati
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
|
test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => {
|
||||||
const created = [];
|
const store = useProviderSettingsStore(createPinia());
|
||||||
const node = () => {
|
|
||||||
const value = {
|
|
||||||
dataset: {},
|
|
||||||
children: [],
|
|
||||||
append(...children) {
|
|
||||||
this.children.push(...children);
|
|
||||||
},
|
|
||||||
querySelector() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
querySelectorAll() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
addEventListener() {},
|
|
||||||
removeEventListener() {},
|
|
||||||
setAttribute() {},
|
|
||||||
remove() {},
|
|
||||||
};
|
|
||||||
created.push(value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
const rows = node();
|
|
||||||
const root = node();
|
|
||||||
root.querySelector = (selector) =>
|
|
||||||
selector === '[data-key-setup-rows]' ? rows : null;
|
|
||||||
const chip = node();
|
|
||||||
const status = keySetupStatus({});
|
const status = keySetupStatus({});
|
||||||
status.keys = status.keys.filter((key) =>
|
status.keys = status.keys.filter((key) =>
|
||||||
['launch-library', 'mapillary'].includes(key.id),
|
['launch-library', 'mapillary'].includes(key.id),
|
||||||
);
|
);
|
||||||
const setup = await initKeySetup({
|
const setup = await initKeySetup({
|
||||||
documentRef: {
|
documentRef: {
|
||||||
getElementById: (id) => (id === 'key-setup-chip' ? chip : root),
|
getElementById: (id) => (id === 'key-setup' ? { dataset: {} } : null),
|
||||||
createElement: node,
|
|
||||||
addEventListener() {},
|
addEventListener() {},
|
||||||
removeEventListener() {},
|
removeEventListener() {},
|
||||||
},
|
},
|
||||||
|
store,
|
||||||
fetchImpl: async () => ({ ok: true, json: async () => status }),
|
fetchImpl: async () => ({ ok: true, json: async () => status }),
|
||||||
});
|
});
|
||||||
assert.ok(setup);
|
assert.ok(setup);
|
||||||
try {
|
try {
|
||||||
const ll2 = rows.children.find(
|
const row = (id) => store.view.rows.find((key) => key.id === id);
|
||||||
(row) => row.dataset.keyId === 'launch-library',
|
const ll2 = row('launch-library');
|
||||||
|
assert.match(ll2.tierTitle, /optional paid token/i);
|
||||||
|
assert.match(ll2.unlocks, /leave blank/i);
|
||||||
|
assert.match(ll2.unlocks, /no key/i);
|
||||||
|
assert.equal(
|
||||||
|
row('mapillary').tierTitle,
|
||||||
|
'Free key — register, paste, done',
|
||||||
);
|
);
|
||||||
const free = rows.children.find((row) => row.dataset.keyId === 'mapillary');
|
|
||||||
const tier = (row) =>
|
|
||||||
row.children[0].children.find(
|
|
||||||
(child) => child.className === 'key-setup-tier',
|
|
||||||
);
|
|
||||||
assert.match(tier(ll2).title, /optional paid token/i);
|
|
||||||
assert.match(ll2.children[1].textContent, /leave blank/i);
|
|
||||||
assert.match(ll2.children[1].textContent, /no key/i);
|
|
||||||
assert.equal(tier(free).title, 'Free key — register, paste, done');
|
|
||||||
const serialized = JSON.stringify(status);
|
const serialized = JSON.stringify(status);
|
||||||
assert.ok(!serialized.includes('owned-fixture-token'));
|
assert.ok(!serialized.includes('owned-fixture-token'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,40 +1,112 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { storeToRefs } from 'pinia';
|
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 { useLocaleStore } from '../stores/locale.js';
|
||||||
|
import { useProviderSettingsStore } from '../stores/providerSettings.js';
|
||||||
import LocalePicker from './panels/LocalePicker.vue';
|
import LocalePicker from './panels/LocalePicker.vue';
|
||||||
|
|
||||||
const { picker } = storeToRefs(useLocaleStore());
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
|
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js reports
|
||||||
both the chip and this dialog outright when /api/setup/status is not
|
it unavailable, removing both the chip and this dialog, when
|
||||||
there to answer (prod builds, LAN visitors). Rows are rendered from
|
/api/setup/status is not there to answer (prod builds, LAN visitors).
|
||||||
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
|
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
|
<!-- Bottom-right HUD row: the POWER UP chip (while keys are missing) and
|
||||||
the language picker, once the shell publishes it. -->
|
the language picker, once the shell publishes it. -->
|
||||||
<div id="corner-actions">
|
<div id="corner-actions">
|
||||||
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
|
<Teleport v-if="available" :to="cyber ? '#top-center-actions' : 'body'" :disabled="!cyber">
|
||||||
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
<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 data-key-setup-chip-label data-i18n>POWER UP</span>
|
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
|
||||||
</button>
|
<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" />
|
<LocalePicker v-if="picker" :key="picker.current" :current="picker.current" :on-select="picker.onSelect" />
|
||||||
</div>
|
</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>
|
<div class="key-setup-scanline" aria-hidden="true"></div>
|
||||||
<header class="key-setup-header">
|
<header class="key-setup-header">
|
||||||
<span class="key-setup-kicker" data-i18n>GROUND STATION · PROVIDER SETTINGS</span>
|
<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>
|
<span class="material-symbols-outlined" aria-hidden="true">close</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<h2 id="key-setup-title" data-i18n>Power up the globe</h2>
|
<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>
|
<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">
|
<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>
|
<span class="key-setup-hint" data-i18n>ESC to close</span>
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
+12
-15
@@ -9,6 +9,8 @@ import { resolveDeicticArgs } from './deixis.js';
|
|||||||
export { readLayerLifecycleSummary } from './layerSummary.js';
|
export { readLayerLifecycleSummary } from './layerSummary.js';
|
||||||
import { defaultGeospatial } from '../search/defaults.js';
|
import { defaultGeospatial } from '../search/defaults.js';
|
||||||
import { msg } from '../i18n/index.js';
|
import { msg } from '../i18n/index.js';
|
||||||
|
import { useProviderSettingsStore } from '../ui/stores/providerSettings.js';
|
||||||
|
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { CITY_POIS } from '../cityPois.js';
|
import { CITY_POIS } from '../cityPois.js';
|
||||||
import { greatCircleKm } from '../geoDistance.js';
|
import { greatCircleKm } from '../geoDistance.js';
|
||||||
@@ -772,10 +774,9 @@ export function createGevActionRunner({
|
|||||||
cancelled: true,
|
cancelled: true,
|
||||||
};
|
};
|
||||||
const doc = globalThis.document;
|
const doc = globalThis.document;
|
||||||
const panel = doc?.getElementById?.('key-setup');
|
// The setup owner (src/keySetup.js) publishes the dialog and takes
|
||||||
const button = open
|
// its open and close requests.
|
||||||
? doc?.getElementById?.('key-setup-chip')
|
const settings = useProviderSettingsStore(applicationPinia());
|
||||||
: panel?.querySelector?.('[data-key-setup-close]');
|
|
||||||
const concealed =
|
const concealed =
|
||||||
open &&
|
open &&
|
||||||
[
|
[
|
||||||
@@ -784,11 +785,7 @@ export function createGevActionRunner({
|
|||||||
'cockpit-mode',
|
'cockpit-mode',
|
||||||
'scene-playback-mode',
|
'scene-playback-mode',
|
||||||
].some((mode) => doc?.body?.classList?.contains(mode));
|
].some((mode) => doc?.body?.classList?.contains(mode));
|
||||||
if (
|
if (concealed || settings.view?.ready !== true || !settings.controls) {
|
||||||
concealed ||
|
|
||||||
panel?.dataset?.ready !== 'true' ||
|
|
||||||
typeof button?.click !== 'function'
|
|
||||||
) {
|
|
||||||
const error = concealed
|
const error = concealed
|
||||||
? 'Provider Settings is hidden in the current view. Leave that view before opening it.'
|
? 'Provider Settings is hidden in the current view. Leave that view before opening it.'
|
||||||
: 'Provider Settings is unavailable in this runtime.';
|
: 'Provider Settings is unavailable in this runtime.';
|
||||||
@@ -802,11 +799,11 @@ export function createGevActionRunner({
|
|||||||
say: error,
|
say: error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
button.click();
|
if (open) settings.controls.open();
|
||||||
// Opening unhides immediately; closing removes visible before its fade ends.
|
else settings.controls.close();
|
||||||
const applied = open
|
// Opening unhides at once; closing drops visible before its fade ends.
|
||||||
? !panel.hidden
|
const dialog = settings.view.dialog;
|
||||||
: !panel.classList.contains('visible');
|
const applied = open ? !dialog.hidden : !dialog.visible;
|
||||||
const say = applied
|
const say = applied
|
||||||
? `Provider Settings is ${open ? 'open' : 'closed'}.`
|
? `Provider Settings is ${open ? 'open' : 'closed'}.`
|
||||||
: 'Provider Settings did not change.';
|
: 'Provider Settings did not change.';
|
||||||
@@ -815,7 +812,7 @@ export function createGevActionRunner({
|
|||||||
action: name,
|
action: name,
|
||||||
panelId,
|
panelId,
|
||||||
applied,
|
applied,
|
||||||
open: applied ? open : !panel.hidden,
|
open: applied ? open : !dialog.hidden,
|
||||||
say,
|
say,
|
||||||
...(!applied ? { error: say } : {}),
|
...(!applied ? { error: say } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -681,19 +681,12 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
|
|||||||
f.run('set_panel_open', { panelId: 'provider-settings', open }, options);
|
f.run('set_panel_open', { panelId: 'provider-settings', open }, options);
|
||||||
assert.equal((await request(true)).unavailable, true);
|
assert.equal((await request(true)).unavailable, true);
|
||||||
const doc = globalThis.document;
|
const doc = globalThis.document;
|
||||||
const chip = doc.createElement('button');
|
// The setup owner needs only its dialog element; the view is not mounted,
|
||||||
chip.id = 'key-setup-chip';
|
// so the published dialog state stands in for it.
|
||||||
const panel = doc.createElement('aside');
|
const panel = doc.createElement('aside');
|
||||||
panel.id = 'key-setup';
|
panel.id = 'key-setup';
|
||||||
panel.hidden = true;
|
doc.body.append(panel);
|
||||||
const close = doc.createElement('button');
|
const dialog = () => settings.view?.dialog;
|
||||||
panel.appendChild(close);
|
|
||||||
doc.body.append(chip, panel);
|
|
||||||
const query = panel.querySelector.bind(panel);
|
|
||||||
panel.querySelector = (selector) =>
|
|
||||||
selector === '[data-key-setup-close]' ? close : query(selector);
|
|
||||||
chip.click = () => chip.fire('click');
|
|
||||||
close.click = () => close.fire('click');
|
|
||||||
let frames = 0;
|
let frames = 0;
|
||||||
const previousFrame = globalThis.requestAnimationFrame;
|
const previousFrame = globalThis.requestAnimationFrame;
|
||||||
globalThis.requestAnimationFrame = (callback) => {
|
globalThis.requestAnimationFrame = (callback) => {
|
||||||
@@ -705,6 +698,10 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
|
|||||||
else globalThis.requestAnimationFrame = previousFrame;
|
else globalThis.requestAnimationFrame = previousFrame;
|
||||||
});
|
});
|
||||||
const { initKeySetup } = await import('../../keySetup.js');
|
const { initKeySetup } = await import('../../keySetup.js');
|
||||||
|
const { useProviderSettingsStore } =
|
||||||
|
await import('../../ui/stores/providerSettings.js');
|
||||||
|
const { applicationPinia } = await import('../../ui/vueRuntime.js');
|
||||||
|
const settings = useProviderSettingsStore(applicationPinia());
|
||||||
let finishStatus;
|
let finishStatus;
|
||||||
const pending = initKeySetup({
|
const pending = initKeySetup({
|
||||||
documentRef: doc,
|
documentRef: doc,
|
||||||
@@ -727,7 +724,7 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
|
|||||||
doc.body.classList.add('cockpit-mode');
|
doc.body.classList.add('cockpit-mode');
|
||||||
assert.equal((await request(true)).unavailable, true);
|
assert.equal((await request(true)).unavailable, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
panel.hidden,
|
dialog().hidden,
|
||||||
true,
|
true,
|
||||||
'a concealed view cannot schedule a later surprise reveal',
|
'a concealed view cannot schedule a later surprise reveal',
|
||||||
);
|
);
|
||||||
@@ -737,16 +734,16 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o
|
|||||||
await assert.rejects(() => request(true, { signal: abort.signal }), {
|
await assert.rejects(() => request(true, { signal: abort.signal }), {
|
||||||
name: 'AbortError',
|
name: 'AbortError',
|
||||||
});
|
});
|
||||||
assert.equal(panel.hidden, true);
|
assert.equal(dialog().hidden, true);
|
||||||
assert.equal((await request(true)).ok, true);
|
assert.equal((await request(true)).ok, true);
|
||||||
assert.equal(panel.hidden, false);
|
assert.equal(dialog().hidden, false);
|
||||||
assert.equal(panel.classList.contains('visible'), true);
|
assert.equal(dialog().visible, true);
|
||||||
assert.equal((await request(true)).ok, true);
|
assert.equal((await request(true)).ok, true);
|
||||||
assert.equal(frames, 1, 'repeated open reuses the dialog');
|
assert.equal(frames, 1, 'repeated open reuses the dialog');
|
||||||
assert.equal((await request(false)).ok, true);
|
assert.equal((await request(false)).ok, true);
|
||||||
assert.equal(panel.classList.contains('visible'), false);
|
assert.equal(dialog().visible, false);
|
||||||
t.mock.timers.tick(400);
|
t.mock.timers.tick(400);
|
||||||
assert.equal(panel.hidden, true);
|
assert.equal(dialog().hidden, true);
|
||||||
assert.equal((await request(false)).ok, true);
|
assert.equal((await request(false)).ok, true);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
f.state.visual.length,
|
f.state.visual.length,
|
||||||
|
|||||||
Reference in new issue
Block a user