Files
SlabUploader/frontend/src/create/settings/WooSetupGate.svelte
T
grokbot_ty fca32396c0 fix(review): Create title no silent fail; gate enables content LLM
Create title used to look enabled while review echoed a pre-typed title when
species/primary were missing from taxonomy (no error). Persist wood cats before
the deterministic build, return title_built / title_build_missing from review,
and surface a Create-title nudge like Create desc.

WooSetupGate now turns on content_llm_enabled when inference URL+key are set
(Call 2 / Create desc). Call 1 stays out. Create-desc gate also requires
inference URL+key so empty Settings show a clear missing list.
2026-09-26 22:50:15 -07:00

321 lines
10 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import {
getDeviceWooCreds,
setDeviceWooCreds,
withWooHeaders,
} from '../../api/wooDeviceCreds';
import { getSettings, updateSettings, type SettingsView } from '../../api/slabs';
/** Called only after Save + Test Woo succeeds. */
export let onReady: () => void;
type Secret = { configured: boolean } | string;
const isUnmasked = (s: Secret) => typeof s === 'string';
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
const secretConfigured = (s: Secret | undefined | null): boolean => {
if (s == null) return false;
return isUnmasked(s) ? (s as string).length > 0 : !!(s as { configured: boolean }).configured;
};
const inferenceUrlConfigured = (): boolean =>
settings ? secretConfigured(settings.inference_base_url as Secret) : false;
const inferenceKeyConfigured = (): boolean =>
settings ? secretConfigured(settings.inference_api_key as Secret) : false;
const inferenceUrlMaskedKeep = (): boolean =>
!!(settings && !isUnmasked(settings.inference_base_url as Secret) && inferenceUrlConfigured());
const inferenceKeyMaskedKeep = (): boolean =>
!!(settings && !isUnmasked(settings.inference_api_key as Secret) && inferenceKeyConfigured());
let loading = true;
let loadError = '';
let settings: SettingsView | null = null;
let username = '';
let appPassword = '';
let inferenceUrl = '';
let inferenceKey = '';
let busy = false;
let status = '';
let statusOk = false;
onMount(() => {
const existing = getDeviceWooCreds();
username = existing.username;
appPassword = existing.appPassword;
void loadSettings();
});
async function loadSettings() {
loading = true;
loadError = '';
try {
settings = await getSettings();
// Prefill inference only when unmasked (UAT); otherwise leave blank write-only.
inferenceUrl = secretValue(settings.inference_base_url as Secret);
inferenceKey = secretValue(settings.inference_api_key as Secret);
} catch {
loadError = 'Could not load settings. Check the connection and try again.';
} finally {
loading = false;
}
}
async function saveAndTest() {
const user = username.trim();
const pass = appPassword.trim();
if (!user || !pass) {
status = 'Enter this device’s WordPress user and application password.';
statusOk = false;
return;
}
busy = true;
status = 'Saving…';
statusOk = false;
try {
// Inference only — never write device Woo into Settings.
// When URL+key are (or become) configured, turn on content_llm_enabled so
// Create desc / Call 2 works. Call 1 stays out (do not set inference_enabled).
const inferPatch: Record<string, string | boolean> = {};
if (inferenceUrl.trim()) inferPatch.inference_base_url = inferenceUrl.trim();
if (inferenceKey.trim()) inferPatch.inference_api_key = inferenceKey.trim();
const willHaveUrl = !!(inferenceUrl.trim() || inferenceUrlConfigured());
const willHaveKey = !!(inferenceKey.trim() || inferenceKeyConfigured());
if (willHaveUrl && willHaveKey) {
inferPatch.content_llm_enabled = true;
}
if (Object.keys(inferPatch).length) {
settings = await updateSettings(inferPatch);
inferenceUrl = secretValue(settings.inference_base_url as Secret);
inferenceKey = secretValue(settings.inference_api_key as Secret);
}
setDeviceWooCreds({ username: user, appPassword: pass });
username = user;
appPassword = pass;
status = 'Testing WooCommerce…';
const res = await fetch('/api/v1/settings/test-woo', {
method: 'POST',
headers: withWooHeaders(),
});
let body: any = null;
try {
body = await res.json();
} catch {
body = null;
}
if (res.status === 401 || (body && body.error?.code === 'woo_device_creds_required')) {
status = body?.error?.message || 'Device Woo credentials required.';
statusOk = false;
return;
}
if (!res.ok) {
status = body?.error?.message || body?.detail || `Test failed (${res.status})`;
statusOk = false;
return;
}
if (body && body.ok) {
status = body.detail || 'Connected & synced';
statusOk = true;
onReady();
return;
}
status = (body && body.detail) || 'Store rejected the credentials. Check user and app password.';
statusOk = false;
} catch {
status = 'Network error — try again.';
statusOk = false;
} finally {
busy = false;
}
}
</script>
<div class="gate" role="dialog" aria-modal="true" aria-labelledby="woo-gate-title" data-testid="woo-setup-gate">
<div class="card">
<h1 id="woo-gate-title">Set up WooCommerce for this device</h1>
<p class="lede">
Each browser uses its own WordPress user and application password. They stay on this
device only — not in shared server Settings. Inference stays shared for the shop.
</p>
{#if loading}
<p class="busy">Loading…</p>
{:else}
{#if loadError}
<p class="err" role="alert">{loadError}</p>
<button type="button" class="ghost" on:click={() => void loadSettings()}>Retry</button>
{:else}
<div class="field">
<label for="woo-base">Woo store URL</label>
<input id="woo-base" type="text" value={settings?.woo_base_url || ''} disabled data-testid="woo-gate-base-url" />
<p class="hint">From server env — display only.</p>
</div>
<div class="field">
<label for="woo-user">Device WordPress username</label>
<input
id="woo-user"
type="text"
bind:value={username}
autocomplete="username"
data-testid="woo-gate-username"
required
/>
</div>
<div class="field">
<label for="woo-pass">Device application password</label>
<input
id="woo-pass"
type="password"
bind:value={appPassword}
autocomplete="current-password"
placeholder="••••••••"
data-testid="woo-gate-app-password"
required
/>
</div>
<div class="section">
<h2>Inference (shared)</h2>
<div class="field">
<label for="inf-url">
Inference base URL
{#if inferenceUrlConfigured()}
<span class="tag">configured</span>
{:else}
<span class="tag miss">not set</span>
{/if}
</label>
<input
id="inf-url"
type="text"
bind:value={inferenceUrl}
placeholder={inferenceUrlMaskedKeep() ? '•••••••• (leave blank to keep)' : 'https://…'}
data-testid="woo-gate-inference-url"
/>
</div>
<div class="field">
<label for="inf-key">
Inference API key
{#if inferenceKeyConfigured()}
<span class="tag">configured</span>
{:else}
<span class="tag miss">not set</span>
{/if}
</label>
<input
id="inf-key"
type="password"
bind:value={inferenceKey}
placeholder={inferenceKeyMaskedKeep() ? '•••••••• (leave blank to keep)' : ''}
data-testid="woo-gate-inference-key"
/>
</div>
<p class="hint">Only filled values are saved to shared Settings. When both are set, Content LLM (Create desc / Call 2) turns on. Device Woo is never written there.</p>
</div>
<button
type="button"
class="primary"
disabled={busy || !username.trim() || !appPassword.trim()}
data-testid="woo-gate-save-test"
on:click={() => void saveAndTest()}
>
{busy ? 'Working…' : 'Save device creds & Test Woo'}
</button>
{#if status}
<p class="msg" class:ok={statusOk} class:err={!statusOk} role="status" data-testid="woo-gate-status">{status}</p>
{/if}
{/if}
{/if}
</div>
</div>
<style>
.gate {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(8, 8, 8, 0.92);
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
padding: 24px 16px 48px;
box-sizing: border-box;
}
.card {
width: 100%;
max-width: 520px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 20px 18px 24px;
color: #eee;
box-sizing: border-box;
}
h1 { font-size: 1.35rem; margin: 0 0 8px; }
h2 { font-size: 1.05rem; margin: 0 0 10px; color: #ddd; }
.lede { color: #aaa; font-size: 0.9rem; margin: 0 0 18px; line-height: 1.4; }
.section {
margin: 18px 0 8px;
padding-top: 14px;
border-top: 1px solid #333;
}
.field { margin-bottom: 12px; }
label { display: block; margin: 0 0 4px; font-size: 0.9rem; color: #ccc; }
input {
width: 100%;
box-sizing: border-box;
padding: 10px 10px;
border-radius: 3px;
border: 1px solid #444;
background: #121212;
color: #eee;
font-size: 1rem;
}
input:disabled { color: #888; background: #1c1c1c; }
.hint { margin: 4px 0 0; color: #888; font-size: 0.8rem; }
.tag {
display: inline-block;
margin-left: 6px;
font-size: 0.7rem;
padding: 1px 6px;
border-radius: 2px;
background: #2a3a2a;
color: #7fd37f;
font-weight: 600;
}
.tag.miss { background: #3a2a2a; color: #e8a; }
.primary {
width: 100%;
margin-top: 12px;
padding: 14px;
font-size: 1rem;
border-radius: 3px;
border: 0;
background: #00e5ff;
color: #002;
font-weight: 700;
cursor: pointer;
}
.primary:disabled { background: #333; color: #777; cursor: not-allowed; }
.ghost {
margin-top: 8px;
padding: 10px 14px;
border-radius: 3px;
border: 1px solid #444;
background: transparent;
color: #ccc;
font-weight: 600;
cursor: pointer;
}
.msg { margin-top: 12px; font-weight: 600; }
.msg.ok { color: #7fd37f; }
.msg.err, .err { color: #ffb4a2; }
.busy { color: #aaa; }
</style>