From 2f9b8ebd02d70732483dba77ddf6c45f80bcfe84 Mon Sep 17 00:00:00 2001 From: grokbot_ty Date: Sat, 26 Sep 2026 19:20:16 -0700 Subject: [PATCH] proto(woo): blocking setup gate; require X-Woo-* on Woo HTTP routes Full-screen WooSetupGate on App mount when this browser lacks device WP user + app password. Save + Test Woo dismisses only after a successful test. Publish/test-woo auth failures reopen the gate. Server: client_for_request requires X-Woo-* on publish, taxonomy sync, and test-woo (401 woo_device_creds_required); health probe keeps Settings. Optional SLAB_WOO_ALLOW_SETTINGS_FALLBACK for legacy tests. See PROTOTYPE.md. --- PROTOTYPE.md | 41 ++- backend/app.py | 17 +- backend/publish.py | 17 +- backend/tests/test_exit_inference_off.py | 1 + backend/tests/test_integration_woo_mock.py | 1 + backend/tests/test_per_user_woo_creds.py | 87 ++++- backend/tests/test_publish.py | 1 + backend/woo.py | 50 ++- frontend/src/App.svelte | 20 +- frontend/src/api/slabs.ts | 2 +- frontend/src/api/wooDeviceCreds.test.ts | 38 +++ frontend/src/api/wooDeviceCreds.ts | 6 + frontend/src/api/wooGate.ts | 22 ++ frontend/src/create/publish/Publish.svelte | 3 + frontend/src/create/settings/Settings.svelte | 39 ++- .../src/create/settings/WooSetupGate.svelte | 313 ++++++++++++++++++ 16 files changed, 600 insertions(+), 58 deletions(-) create mode 100644 frontend/src/api/wooDeviceCreds.test.ts create mode 100644 frontend/src/api/wooGate.ts create mode 100644 frontend/src/create/settings/WooSetupGate.svelte diff --git a/PROTOTYPE.md b/PROTOTYPE.md index d2d57f9..03911ab 100644 --- a/PROTOTYPE.md +++ b/PROTOTYPE.md @@ -13,44 +13,51 @@ No full auth, ACL, or publish mutex in this prototype. | Layer | What | | --- | --- | | Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` | -| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls | -| Server | `client_for_request(conn, headers)` → override if both headers non-blank, else Settings singleton | -| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — **never** updated by the device-creds path | +| Gate | Full-screen `WooSetupGate` on App mount when this browser lacks device creds | +| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls (**required**) | +| Server | `client_for_request(conn, headers)` → override only; **no Settings fallback** on those routes | +| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — used by **health probe** only; **never** updated by the device-creds / gate path | -Woo-touching calls that attach headers: +Woo-touching calls that **require** headers (401 `woo_device_creds_required` if missing): - `POST /api/v1/slabs/{id}/publish` - `POST /api/v1/admin/taxonomy/sync` - `POST /api/v1/settings/test-woo` Inference / Call 2 / Settings PUT for inference keys are unchanged (shared Settings). +Optional test escape hatch: `SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1` (prefer sending headers in tests). + +## Setup gate + +1. On App mount, if this browser has no device username + app password → blocking overlay. +2. Fields: device WP user + app password (required); Woo base URL display-only from Settings; + inference URL/key show configured / not set (masked) and may be entered → PUT Settings + for inference only. +3. Primary CTA: **Save device creds & Test Woo**. Gate dismisses only after Test Woo succeeds. +4. Returning browser with device creds → skip gate. +5. Publish (or Test Woo) Woo auth failure / missing headers → reopen gate. ## Flow (request → WooClient) 1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`). 2. `withWooHeaders()` merges `X-Woo-*` onto the fetch. 3. Backend `client_for_request` builds a **new** `WooClient` for that request only - (Basic auth from override plaintext, or decrypt Settings password). + (Basic auth from override plaintext). Missing headers → `WooDeviceCredsRequired`. 4. Publish / taxonomy / media use that client instance — no process-global “current user”. ## UAT with two browsers -1. Build/run this branch locally (do **not** deploy over partner UAT :10890; never :10880). +1. Deploy this branch to the **proto** stack only (`slab-proto-woo-creds` on + `:10892` / `:10893`). Do **not** touch overnight `:10890`; never `:10880`. 2. In WordPress, create two application passwords for two WP users on the same store. -3. Browser A (or profile A): Settings → **This device’s Woo credentials** → enter - user A + app password → **Save on this device** → **Test WooCommerce connection**. +3. Browser A (or profile A): setup gate → enter user A + app password → + **Save device creds & Test Woo** → gate dismisses. 4. Browser B: same for user B (separate localStorage). -5. Confirm Test Woo succeeds for both, and that **Save Changes** on shop-default - fields was not used to store the other user’s password. +5. Confirm Test Woo succeeds for both, and that device passwords were not written + into shop-default Settings. 6. Publish different slabs from each browser (do not work the same slab concurrently). -7. Optional: clear device creds and confirm fallback to shop-default Settings. - -## Intentional fallback - -- Missing or partial `X-Woo-*` headers → shop-default Settings credentials. -- Device localStorage empty → same fallback. -- Health probe (`woo_reachable`) always uses shop-default Settings (no request). +7. Optional: clear device creds in Settings → reload → gate reappears. ## Risks left (prototype) diff --git a/backend/app.py b/backend/app.py index 0e8cb5f..04ecb85 100644 --- a/backend/app.py +++ b/backend/app.py @@ -211,6 +211,7 @@ from .woo import ( WooAuthError, WooConnectionError, WooCredentialsMissing, + WooDeviceCredsRequired, WooError, WooForbiddenError, client_for_request, @@ -225,11 +226,16 @@ def _woo_credentials() -> tuple[str, str | None, str | None]: @app.post("/api/v1/settings/test-woo") async def test_woo(request: Request) -> dict[str, Any]: - """Probe Woo + sync taxonomy. Optional X-Woo-Username / X-Woo-App-Password - override shop-default Settings for this request only (never persisted). + """Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password + (device creds); never reads shop-default Settings for this route. """ try: client = client_for_request(get_connection(), request.headers) + except WooDeviceCredsRequired as e: + return JSONResponse( + status_code=401, + content=_error("woo_device_creds_required", str(e)), + ) except WooCredentialsMissing as e: return { "ok": False, @@ -262,9 +268,14 @@ async def get_taxonomy() -> dict[str, Any]: @app.post("/api/v1/admin/taxonomy/sync") async def post_taxonomy_sync(request: Request) -> dict[str, Any]: - """Refresh taxonomy cache. Honors X-Woo-* override headers for this request.""" + """Refresh taxonomy cache. Requires X-Woo-* device headers (no Settings fallback).""" try: client = client_for_request(get_connection(), request.headers) + except WooDeviceCredsRequired as e: + return JSONResponse( + status_code=401, + content=_error("woo_device_creds_required", str(e)), + ) except WooCredentialsMissing as e: return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e))) try: diff --git a/backend/publish.py b/backend/publish.py index 01b5a8b..e807053 100644 --- a/backend/publish.py +++ b/backend/publish.py @@ -21,6 +21,7 @@ from .woo import ( WooClient, WooConnectionError, WooCredentialsMissing, + WooDeviceCredsRequired, WooError, WooForbiddenError, client_for_request, @@ -124,6 +125,12 @@ def _attr_id_from_synced(attrs: dict, slug: str, name: str) -> int | None: def _classify(e: Exception) -> tuple[str, str, bool]: """Return (code, plain message, recoverable). PUBLISH-36/37/38.""" + if isinstance(e, WooDeviceCredsRequired): + return ( + "woo_device_creds_required", + "Enter this device's WordPress user and application password.", + False, + ) if isinstance(e, WooCredentialsMissing): return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False if isinstance(e, WooAuthError): @@ -152,9 +159,17 @@ async def publish(slab_id: str, request: Request) -> dict: if not ready: return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing})) # 2. SKU lookup (PUBLISH-15) — stop if it exists in the store. - # Per-request Woo creds: X-Woo-* headers → Settings fallback (never persisted). + # Per-request Woo creds: require X-Woo-* device headers (no Settings fallback). try: client = client_for_request(conn, request.headers) + except WooDeviceCredsRequired as e: + return JSONResponse( + status_code=401, + content=_error( + "woo_device_creds_required", + "Enter this device's WordPress user and application password.", + ), + ) except WooCredentialsMissing as e: return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e))) try: diff --git a/backend/tests/test_exit_inference_off.py b/backend/tests/test_exit_inference_off.py index 3d82a08..29e6996 100644 --- a/backend/tests/test_exit_inference_off.py +++ b/backend/tests/test_exit_inference_off.py @@ -21,6 +21,7 @@ def env(monkeypatch): os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers db_mod._reset_for_tests() from backend.crypto import encrypt diff --git a/backend/tests/test_integration_woo_mock.py b/backend/tests/test_integration_woo_mock.py index 62bad66..7918b99 100644 --- a/backend/tests/test_integration_woo_mock.py +++ b/backend/tests/test_integration_woo_mock.py @@ -59,6 +59,7 @@ def env(monkeypatch): os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers db_mod._reset_for_tests() from backend.crypto import encrypt diff --git a/backend/tests/test_per_user_woo_creds.py b/backend/tests/test_per_user_woo_creds.py index ba0ec56..2b2a02b 100644 --- a/backend/tests/test_per_user_woo_creds.py +++ b/backend/tests/test_per_user_woo_creds.py @@ -1,4 +1,4 @@ -"""Prototype: per-request X-Woo-* headers build distinct WooClients without clobbering Settings.""" +"""Prototype: X-Woo-* headers required on HTTP Woo routes; never clobber Settings.""" import base64 import os @@ -12,6 +12,7 @@ import backend.db as db_mod from backend.crypto import encrypt, decrypt from backend.woo import ( WooClient, + WooDeviceCredsRequired, client_for_request, request_woo_override, settings_woo_creds, @@ -27,6 +28,7 @@ def db_dir(): os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None) db_mod._reset_for_tests() conn = db_mod.init_db() conn.execute( @@ -59,33 +61,38 @@ def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_d client_a = client_for_request(conn, h_a) client_b = client_for_request(conn, h_b) - client_fallback = client_for_request(conn, Headers({})) assert _auth_pair(client_a) == ("alice", "alice-app-pw") assert _auth_pair(client_b) == ("bob", "bob-app-pw") - assert _auth_pair(client_fallback) == ("shop-default", "shop-secret") assert client_a._auth != client_b._auth after = settings_woo_creds(conn) assert after == before assert decrypt(after[2]) == "shop-secret" - # Explicit: Settings row values unchanged row_u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()[0] row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0] assert row_u == "shop-default" assert decrypt(row_p) == "shop-secret" -def test_partial_headers_fall_back_to_settings(db_dir): +def test_missing_headers_raise_device_creds_required(db_dir): conn = db_dir - # Username only — incomplete override + with pytest.raises(WooDeviceCredsRequired): + client_for_request(conn, Headers({})) + with pytest.raises(WooDeviceCredsRequired): + client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"})) assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None - client = client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"})) + + +def test_env_escape_hatch_allows_settings_fallback(db_dir, monkeypatch): + conn = db_dir + monkeypatch.setenv("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "1") + client = client_for_request(conn, Headers({})) assert _auth_pair(client) == ("shop-default", "shop-secret") -def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch): - """API: POST /settings/test-woo with X-Woo-* builds client for that user.""" +def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch): + """API: POST /settings/test-woo with X-Woo-* builds client; missing → 401.""" from fastapi.testclient import TestClient from backend import app as app_mod @@ -108,6 +115,64 @@ def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch): r3 = tc.post("/api/v1/settings/test-woo") assert r1.status_code == 200 and r1.json()["ok"] is True assert r2.status_code == 200 and r2.json()["ok"] is True - assert r3.status_code == 200 and r3.json()["ok"] is True - assert seen == ["alice", "bob", "shop-default"] + assert r3.status_code == 401 + assert r3.json()["error"]["code"] == "woo_device_creds_required" + assert seen == ["alice", "bob"] assert settings_woo_creds(db_dir)[1] == "shop-default" + + +def test_taxonomy_sync_requires_headers(db_dir, monkeypatch): + from fastapi.testclient import TestClient + from backend import app as app_mod + + monkeypatch.setattr( + app_mod.taxonomy_mod, + "sync", + lambda client, conn: { + "taxonomy_anchor": "x", + "taxonomy_last_sync_at": "2026-01-01T00:00:00", + }, + ) + tc = TestClient(app_mod.app) + missing = tc.post("/api/v1/admin/taxonomy/sync") + assert missing.status_code == 401 + assert missing.json()["error"]["code"] == "woo_device_creds_required" + ok = tc.post( + "/api/v1/admin/taxonomy/sync", + headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw"}, + ) + assert ok.status_code == 200 and ok.json()["ok"] is True + + +def test_publish_requires_headers(db_dir): + """POST publish without X-Woo-* → 401 woo_device_creds_required.""" + from fastapi.testclient import TestClient + from backend import app as app_mod + import json + + conn = db_dir + conn.execute( + "INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (1,'Walnut','walnut',NULL,100)" + ) + conn.execute( + "INSERT INTO woo_categories(id,name,slug,parent_id,woo_id) VALUES (2,'Live edge','le',NULL,101)" + ) + conn.execute( + "INSERT INTO woo_attributes(id,name,slug,woo_id) VALUES (8,'Edge Type','edge-type',200)" + ) + conn.execute( + "INSERT INTO woo_attribute_terms(id,attribute_id,name,slug,woo_id) VALUES (1,8,'Flat','flat',300)" + ) + conn.execute( + """INSERT INTO slabs(id,sku,length_in,thickness_in,sqft,bdft,width_min_in,width_max_in,width_avg_in, + species_id,wood_category_ids,edge_type_term_id,figure_term_ids,grade_term_id,thickness_term_id, + moisture_term_id,fig_tag_ids,feat_tag_ids,price,price_source,title,short_description,description, + content_source,status,server_rev,client_rev,created_at,updated_at) VALUES + ('s1','SLAB-01',114,1.75,30,18.4,18,24.3,24.3,1,'[2]',1,'[1]',1,1,1,'[]','[]',184, + 'recommendation','T','S','D','manual','ready',1,1,'t','t')""" + ) + conn.commit() + tc = TestClient(app_mod.app) + r = tc.post("/api/v1/slabs/s1/publish") + assert r.status_code == 401, r.text + assert r.json()["error"]["code"] == "woo_device_creds_required" diff --git a/backend/tests/test_publish.py b/backend/tests/test_publish.py index daee066..d012cb4 100644 --- a/backend/tests/test_publish.py +++ b/backend/tests/test_publish.py @@ -36,6 +36,7 @@ def env(monkeypatch): os.environ["SLAB_IMAGES_DIR"] = tmp os.environ["WOO_BASE_URL"] = "https://store.example.com" os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode() + os.environ["SLAB_WOO_ALLOW_SETTINGS_FALLBACK"] = "1" # legacy tests; prefer X-Woo-* headers db_mod._reset_for_tests() from backend.crypto import encrypt diff --git a/backend/woo.py b/backend/woo.py index d81a998..a407925 100644 --- a/backend/woo.py +++ b/backend/woo.py @@ -1,12 +1,11 @@ """WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated. -Default creds come from Settings (wp_username plain, app_password AES-GCM). -Missing creds raise WooCredentialsMissing (fail-closed), distinct from -WooAuthError (401 from the store). +Shop-default creds live in Settings (wp_username plain, app_password AES-GCM) and +are used by the health probe only. HTTP Woo routes (publish / taxonomy sync / +test-woo) require X-Woo-Username + X-Woo-App-Password on the request — no +Settings fallback. Missing headers raise WooDeviceCredsRequired. See PROTOTYPE.md. -Prototype per-user override: callers may pass X-Woo-Username + X-Woo-App-Password -request headers into client_for_request(); those build a per-request WooClient -and are never written into Settings. See PROTOTYPE.md. +Optional escape hatch for legacy tests: SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1. """ import base64 @@ -30,6 +29,10 @@ class WooCredentialsMissing(WooError): """Woo base URL or credentials are not configured (fail-closed).""" +class WooDeviceCredsRequired(WooCredentialsMissing): + """X-Woo-Username + X-Woo-App-Password required; Settings fallback disabled.""" + + class WooAuthError(WooError): """The store rejected the credentials (401).""" @@ -188,9 +191,9 @@ class WooClient: # Woo-touching API calls via: # X-Woo-Username # X-Woo-App-Password -# Server uses those for THIS request's WooClient only; never writes them into -# the Settings row. If either header is missing/blank, fall back to the -# shop-default singleton (woo_wp_username + encrypted woo_app_password). +# HTTP Woo routes REQUIRE both headers (no Settings fallback). Health probe +# still uses shop-default Settings via WooClient.from_settings / settings_woo_creds. +# Never write override headers into the Settings row. HEADER_WOO_USERNAME = "X-Woo-Username" HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password" @@ -218,10 +221,29 @@ def request_woo_override(headers) -> tuple[str, str] | None: return None -def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> WooClient: - """Build a WooClient for one HTTP request: override headers → Settings fallback. +def _allow_settings_fallback() -> bool: + """Legacy-test escape hatch. Production HTTP Woo routes leave this unset.""" + import os - Never mutates Settings. Inference stays on shared Settings (caller responsibility). + return os.environ.get("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", "").strip().lower() in ( + "1", + "true", + "yes", + ) + + +def client_for_request( + conn, + headers=None, + request: Callable[..., tuple[int, dict, bytes]] | None = None, + *, + require_override: bool = True, +) -> WooClient: + """Build a WooClient for one HTTP Woo request. + + Requires X-Woo-* headers by default (no Settings fallback). Set + require_override=False or SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1 only for + health-adjacent / legacy-test paths. Never mutates Settings. """ base, settings_user, settings_enc = settings_woo_creds(conn) override = request_woo_override(headers) @@ -230,4 +252,8 @@ def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dic if request is None: request = _default_request return WooClient(user, password, base, request) + if require_override and not _allow_settings_fallback(): + raise WooDeviceCredsRequired( + "X-Woo-Username and X-Woo-App-Password are required on this device" + ) return WooClient.from_settings(base, settings_user, settings_enc, request=request) diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 0d10c6a..cc70e5f 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -11,12 +11,15 @@ import MaskPanel from './capture/MaskPanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte'; import Settings from './create/settings/Settings.svelte'; + import WooSetupGate from './create/settings/WooSetupGate.svelte'; import Review from './create/review/Review.svelte'; import Publish from './create/publish/Publish.svelte'; import { flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture, } from './create/flow'; - import { createSlab, uploadPhoto, getSettings, ApiError } from './api/slabs'; + import { createSlab, uploadPhoto, ApiError } from './api/slabs'; + import { hasDeviceWooCreds } from './api/wooDeviceCreds'; + import { wooGateForcedOpen, clearWooGateForce } from './api/wooGate'; import { clearDraft } from './draft/idb'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; @@ -26,9 +29,12 @@ let busy = false; let pngUrl: string | null = null; let showSettings = false; + // Blocking Woo setup until this browser has device creds (or reopen after 401). + let showWooGate = typeof window !== 'undefined' ? !hasDeviceWooCreds() : true; let curFlow: import('./create/flow').Flow = { phase: 'capture' }; $: curFlow = $flow; + $: if ($wooGateForcedOpen) showWooGate = true; let saving = false; let saveError = ''; @@ -38,7 +44,13 @@ }); const unsubBusy = session.busy.subscribe((b) => (busy = b)); + function onWooGateReady(): void { + clearWooGateForce(); + showWooGate = false; + } + onMount(() => { + showWooGate = !hasDeviceWooCreds() || $wooGateForcedOpen; // e2e boot guard: `?noload` skips the resume probe so a test can seed the // device draft into IndexedDB after the origin exists, then reload without // ?noload to trigger the Resume prompt deterministically. @@ -146,7 +158,10 @@

Missing: {gate}

{:else} -
+ {#if showWooGate} + + {/if} +
{#if curFlow.phase === 'review'} (); + +const fakeLS = { + getItem(k: string) { + return mem.has(k) ? mem.get(k)! : null; + }, + setItem(k: string, v: string) { + mem.set(k, String(v)); + }, + removeItem(k: string) { + mem.delete(k); + }, +}; + +afterEach(() => { + mem.clear(); +}); + +test('hasDeviceWooCreds is false until both fields are set', () => { + (globalThis as any).localStorage = fakeLS; + assert.equal(hasDeviceWooCreds(), false); + setDeviceWooCreds({ username: 'alice', appPassword: '' }); + assert.equal(hasDeviceWooCreds(), false); + setDeviceWooCreds({ username: 'alice', appPassword: 'pw' }); + assert.equal(hasDeviceWooCreds(), true); + assert.deepEqual(getDeviceWooCreds(), { username: 'alice', appPassword: 'pw' }); + clearDeviceWooCreds(); + assert.equal(hasDeviceWooCreds(), false); +}); diff --git a/frontend/src/api/wooDeviceCreds.ts b/frontend/src/api/wooDeviceCreds.ts index a883346..c0c8fb0 100644 --- a/frontend/src/api/wooDeviceCreds.ts +++ b/frontend/src/api/wooDeviceCreds.ts @@ -76,3 +76,9 @@ export function withWooHeaders(extra?: HeadersInit): HeadersInit { } return { ...extra, ...woo }; } + +/** True when this browser has both device Woo username and app password. */ +export function hasDeviceWooCreds(): boolean { + const { username, appPassword } = getDeviceWooCreds(); + return Boolean(username && appPassword); +} diff --git a/frontend/src/api/wooGate.ts b/frontend/src/api/wooGate.ts new file mode 100644 index 0000000..d0470cd --- /dev/null +++ b/frontend/src/api/wooGate.ts @@ -0,0 +1,22 @@ +/** + * Blocking Woo setup gate visibility. + * App shows the overlay when device creds are missing or this store is forced open + * (e.g. publish/test-woo returned woo_auth / woo_device_creds_required). + */ +import { writable } from 'svelte/store'; + +export const wooGateForcedOpen = writable(false); + +export function reopenWooGate(): void { + wooGateForcedOpen.set(true); +} + +export function clearWooGateForce(): void { + wooGateForcedOpen.set(false); +} + +/** True for ApiError shapes that mean "device Woo login needed again". */ +export function isWooAuthFailure(status: number, code: string | null): boolean { + if (status === 401) return true; + return code === 'woo_auth' || code === 'woo_device_creds_required'; +} diff --git a/frontend/src/create/publish/Publish.svelte b/frontend/src/create/publish/Publish.svelte index f86e65d..cdded5d 100644 --- a/frontend/src/create/publish/Publish.svelte +++ b/frontend/src/create/publish/Publish.svelte @@ -4,6 +4,7 @@ getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError, type Slab, type Taxonomy, type PhotoRow, } from '../../api/slabs'; + import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate'; import PublishPreview from './PublishPreview.svelte'; import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel'; @@ -58,6 +59,8 @@ .catch((e) => { if (e instanceof ApiError) { st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) }; + // Device Woo rejected or missing → reopen blocking setup gate. + if (isWooAuthFailure(e.status, e.code)) reopenWooGate(); } else { st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } }; } diff --git a/frontend/src/create/settings/Settings.svelte b/frontend/src/create/settings/Settings.svelte index 70dcca2..c53e567 100644 --- a/frontend/src/create/settings/Settings.svelte +++ b/frontend/src/create/settings/Settings.svelte @@ -8,6 +8,7 @@ clearDeviceWooCreds, withWooHeaders, } from '../../api/wooDeviceCreds'; + import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate'; interface SecretState { configured: boolean } // Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string. @@ -272,13 +273,13 @@ deviceWoo = getDeviceWooCreds(); deviceMsg = deviceWoo.username && deviceWoo.appPassword ? 'Saved on this device (localStorage). Not written to server Settings.' - : 'Cleared — Woo calls will use shop-default Settings credentials.'; + : 'Cleared — publish / Test Woo need device credentials again.'; } function clearDeviceWoo() { clearDeviceWooCreds(); deviceWoo = { username: '', appPassword: '' }; - deviceMsg = 'Cleared — Woo calls will use shop-default Settings credentials.'; + deviceMsg = 'Cleared — publish / Test Woo need device credentials again.'; } async function testWoo() { @@ -288,7 +289,18 @@ method: 'POST', headers: withWooHeaders(), }); - const body = await res.json(); + const body = await res.json().catch(() => ({})); + if (isWooAuthFailure(res.status, body?.error?.code ?? null) || body?.ok === false) { + const detail = + body?.error?.message || + body?.detail || + (body?.ok === false ? 'failed' : 'Device Woo credentials required'); + wooTest = { ok: false, detail }; + if (isWooAuthFailure(res.status, body?.error?.code ?? null) || /auth rejected/i.test(detail)) { + reopenWooGate(); + } + return; + } wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') }; if (wooTest.ok) await loadRates(); } catch { @@ -325,11 +337,17 @@

This device’s Woo credentials

- Per-browser WP user + application password (localStorage). Sent as - X-Woo-Username / X-Woo-App-Password on publish, - taxonomy sync, and Test Woo — never written into shared server Settings. - Leave blank to use the shop-default below. + Per-browser WP user + application password (localStorage). Required on + publish, taxonomy sync, and Test Woo (sent as + X-Woo-Username / X-Woo-App-Password) — never + written into shared server Settings. A first-run gate collects these if + this browser has none yet.

+ {#if !(deviceWoo.username && deviceWoo.appPassword)} +

+ This device has no Woo credentials yet. Save them here or reload to open the setup gate. +

+ {/if}
@@ -354,9 +372,8 @@

Shop-default Woo (server Settings)

- Shared fallback for this install when this device has no local credentials. - Saving here updates the encrypted Settings row — do not put other users’ - passwords here. + Shop-default row (health probe / legacy). Publish and Test Woo use this + device’s credentials only — do not put other users’ passwords here.

@@ -415,7 +432,7 @@

WooCommerce

-

Uses this device’s Woo credentials when set; otherwise shop-default Settings.

+

Uses this device’s Woo credentials (required). Shop-default Settings are not used on Test Woo.

{#if wooTest}

{wooTest.detail}

diff --git a/frontend/src/create/settings/WooSetupGate.svelte b/frontend/src/create/settings/WooSetupGate.svelte new file mode 100644 index 0000000..7f5b1a8 --- /dev/null +++ b/frontend/src/create/settings/WooSetupGate.svelte @@ -0,0 +1,313 @@ + + + + +