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.
This commit is contained in:
+24
-17
@@ -13,44 +13,51 @@ No full auth, ACL, or publish mutex in this prototype.
|
|||||||
| Layer | What |
|
| Layer | What |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` |
|
| Browser | `localStorage` keys `slabuploader.woo.device.username` / `…appPassword` |
|
||||||
| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls |
|
| Gate | Full-screen `WooSetupGate` on App mount when this browser lacks device creds |
|
||||||
| Server | `client_for_request(conn, headers)` → override if both headers non-blank, else Settings singleton |
|
| HTTP | Headers `X-Woo-Username` + `X-Woo-App-Password` on Woo-touching calls (**required**) |
|
||||||
| Settings | Shop-default `woo_wp_username` / encrypted `woo_app_password` — **never** updated by the device-creds path |
|
| 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/slabs/{id}/publish`
|
||||||
- `POST /api/v1/admin/taxonomy/sync`
|
- `POST /api/v1/admin/taxonomy/sync`
|
||||||
- `POST /api/v1/settings/test-woo`
|
- `POST /api/v1/settings/test-woo`
|
||||||
|
|
||||||
Inference / Call 2 / Settings PUT for inference keys are unchanged (shared Settings).
|
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)
|
## Flow (request → WooClient)
|
||||||
|
|
||||||
1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`).
|
1. Frontend reads device creds from localStorage (`wooDeviceCreds.ts`).
|
||||||
2. `withWooHeaders()` merges `X-Woo-*` onto the fetch.
|
2. `withWooHeaders()` merges `X-Woo-*` onto the fetch.
|
||||||
3. Backend `client_for_request` builds a **new** `WooClient` for that request only
|
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
|
4. Publish / taxonomy / media use that client instance — no process-global
|
||||||
“current user”.
|
“current user”.
|
||||||
|
|
||||||
## UAT with two browsers
|
## 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.
|
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
|
3. Browser A (or profile A): setup gate → enter user A + app password →
|
||||||
user A + app password → **Save on this device** → **Test WooCommerce connection**.
|
**Save device creds & Test Woo** → gate dismisses.
|
||||||
4. Browser B: same for user B (separate localStorage).
|
4. Browser B: same for user B (separate localStorage).
|
||||||
5. Confirm Test Woo succeeds for both, and that **Save Changes** on shop-default
|
5. Confirm Test Woo succeeds for both, and that device passwords were not written
|
||||||
fields was not used to store the other user’s password.
|
into shop-default Settings.
|
||||||
6. Publish different slabs from each browser (do not work the same slab concurrently).
|
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.
|
7. Optional: clear device creds in Settings → reload → gate reappears.
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|
||||||
## Risks left (prototype)
|
## Risks left (prototype)
|
||||||
|
|
||||||
|
|||||||
+14
-3
@@ -211,6 +211,7 @@ from .woo import (
|
|||||||
WooAuthError,
|
WooAuthError,
|
||||||
WooConnectionError,
|
WooConnectionError,
|
||||||
WooCredentialsMissing,
|
WooCredentialsMissing,
|
||||||
|
WooDeviceCredsRequired,
|
||||||
WooError,
|
WooError,
|
||||||
WooForbiddenError,
|
WooForbiddenError,
|
||||||
client_for_request,
|
client_for_request,
|
||||||
@@ -225,11 +226,16 @@ def _woo_credentials() -> tuple[str, str | None, str | None]:
|
|||||||
|
|
||||||
@app.post("/api/v1/settings/test-woo")
|
@app.post("/api/v1/settings/test-woo")
|
||||||
async def test_woo(request: Request) -> dict[str, Any]:
|
async def test_woo(request: Request) -> dict[str, Any]:
|
||||||
"""Probe Woo + sync taxonomy. Optional X-Woo-Username / X-Woo-App-Password
|
"""Probe Woo + sync taxonomy. Requires X-Woo-Username / X-Woo-App-Password
|
||||||
override shop-default Settings for this request only (never persisted).
|
(device creds); never reads shop-default Settings for this route.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
client = client_for_request(get_connection(), request.headers)
|
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:
|
except WooCredentialsMissing as e:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
@@ -262,9 +268,14 @@ async def get_taxonomy() -> dict[str, Any]:
|
|||||||
|
|
||||||
@app.post("/api/v1/admin/taxonomy/sync")
|
@app.post("/api/v1/admin/taxonomy/sync")
|
||||||
async def post_taxonomy_sync(request: Request) -> dict[str, Any]:
|
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:
|
try:
|
||||||
client = client_for_request(get_connection(), request.headers)
|
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:
|
except WooCredentialsMissing as e:
|
||||||
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
||||||
try:
|
try:
|
||||||
|
|||||||
+16
-1
@@ -21,6 +21,7 @@ from .woo import (
|
|||||||
WooClient,
|
WooClient,
|
||||||
WooConnectionError,
|
WooConnectionError,
|
||||||
WooCredentialsMissing,
|
WooCredentialsMissing,
|
||||||
|
WooDeviceCredsRequired,
|
||||||
WooError,
|
WooError,
|
||||||
WooForbiddenError,
|
WooForbiddenError,
|
||||||
client_for_request,
|
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]:
|
def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||||
"""Return (code, plain message, recoverable). PUBLISH-36/37/38."""
|
"""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):
|
if isinstance(e, WooCredentialsMissing):
|
||||||
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
return "woo_unconfigured", "Woo is not configured. Set the store credentials in Settings.", False
|
||||||
if isinstance(e, WooAuthError):
|
if isinstance(e, WooAuthError):
|
||||||
@@ -152,9 +159,17 @@ async def publish(slab_id: str, request: Request) -> dict:
|
|||||||
if not ready:
|
if not ready:
|
||||||
return JSONResponse(status_code=422, content=_error("unpublishable", "Slab is not ready", {"missing": missing}))
|
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.
|
# 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:
|
try:
|
||||||
client = client_for_request(conn, request.headers)
|
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:
|
except WooCredentialsMissing as e:
|
||||||
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
|
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ def env(monkeypatch):
|
|||||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
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()
|
db_mod._reset_for_tests()
|
||||||
from backend.crypto import encrypt
|
from backend.crypto import encrypt
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ def env(monkeypatch):
|
|||||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
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()
|
db_mod._reset_for_tests()
|
||||||
from backend.crypto import encrypt
|
from backend.crypto import encrypt
|
||||||
|
|
||||||
|
|||||||
@@ -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 base64
|
||||||
import os
|
import os
|
||||||
@@ -12,6 +12,7 @@ import backend.db as db_mod
|
|||||||
from backend.crypto import encrypt, decrypt
|
from backend.crypto import encrypt, decrypt
|
||||||
from backend.woo import (
|
from backend.woo import (
|
||||||
WooClient,
|
WooClient,
|
||||||
|
WooDeviceCredsRequired,
|
||||||
client_for_request,
|
client_for_request,
|
||||||
request_woo_override,
|
request_woo_override,
|
||||||
settings_woo_creds,
|
settings_woo_creds,
|
||||||
@@ -27,6 +28,7 @@ def db_dir():
|
|||||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
||||||
|
os.environ.pop("SLAB_WOO_ALLOW_SETTINGS_FALLBACK", None)
|
||||||
db_mod._reset_for_tests()
|
db_mod._reset_for_tests()
|
||||||
conn = db_mod.init_db()
|
conn = db_mod.init_db()
|
||||||
conn.execute(
|
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_a = client_for_request(conn, h_a)
|
||||||
client_b = client_for_request(conn, h_b)
|
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_a) == ("alice", "alice-app-pw")
|
||||||
assert _auth_pair(client_b) == ("bob", "bob-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
|
assert client_a._auth != client_b._auth
|
||||||
|
|
||||||
after = settings_woo_creds(conn)
|
after = settings_woo_creds(conn)
|
||||||
assert after == before
|
assert after == before
|
||||||
assert decrypt(after[2]) == "shop-secret"
|
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_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]
|
row_p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()[0]
|
||||||
assert row_u == "shop-default"
|
assert row_u == "shop-default"
|
||||||
assert decrypt(row_p) == "shop-secret"
|
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
|
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
|
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")
|
assert _auth_pair(client) == ("shop-default", "shop-secret")
|
||||||
|
|
||||||
|
|
||||||
def test_test_woo_endpoint_uses_request_override(db_dir, monkeypatch):
|
def test_test_woo_endpoint_requires_headers(db_dir, monkeypatch):
|
||||||
"""API: POST /settings/test-woo with X-Woo-* builds client for that user."""
|
"""API: POST /settings/test-woo with X-Woo-* builds client; missing → 401."""
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from backend import app as app_mod
|
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")
|
r3 = tc.post("/api/v1/settings/test-woo")
|
||||||
assert r1.status_code == 200 and r1.json()["ok"] is True
|
assert r1.status_code == 200 and r1.json()["ok"] is True
|
||||||
assert r2.status_code == 200 and r2.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 r3.status_code == 401
|
||||||
assert seen == ["alice", "bob", "shop-default"]
|
assert r3.json()["error"]["code"] == "woo_device_creds_required"
|
||||||
|
assert seen == ["alice", "bob"]
|
||||||
assert settings_woo_creds(db_dir)[1] == "shop-default"
|
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"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def env(monkeypatch):
|
|||||||
os.environ["SLAB_IMAGES_DIR"] = tmp
|
os.environ["SLAB_IMAGES_DIR"] = tmp
|
||||||
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
os.environ["WOO_BASE_URL"] = "https://store.example.com"
|
||||||
os.environ["SLAB_AES_KEY"] = base64.b64encode(b"0123456789abcdef0123456789abcdef").decode()
|
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()
|
db_mod._reset_for_tests()
|
||||||
from backend.crypto import encrypt
|
from backend.crypto import encrypt
|
||||||
|
|
||||||
|
|||||||
+38
-12
@@ -1,12 +1,11 @@
|
|||||||
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
|
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
|
||||||
|
|
||||||
Default creds come from Settings (wp_username plain, app_password AES-GCM).
|
Shop-default creds live in Settings (wp_username plain, app_password AES-GCM) and
|
||||||
Missing creds raise WooCredentialsMissing (fail-closed), distinct from
|
are used by the health probe only. HTTP Woo routes (publish / taxonomy sync /
|
||||||
WooAuthError (401 from the store).
|
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
|
Optional escape hatch for legacy tests: SLAB_WOO_ALLOW_SETTINGS_FALLBACK=1.
|
||||||
request headers into client_for_request(); those build a per-request WooClient
|
|
||||||
and are never written into Settings. See PROTOTYPE.md.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
@@ -30,6 +29,10 @@ class WooCredentialsMissing(WooError):
|
|||||||
"""Woo base URL or credentials are not configured (fail-closed)."""
|
"""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):
|
class WooAuthError(WooError):
|
||||||
"""The store rejected the credentials (401)."""
|
"""The store rejected the credentials (401)."""
|
||||||
|
|
||||||
@@ -188,9 +191,9 @@ class WooClient:
|
|||||||
# Woo-touching API calls via:
|
# Woo-touching API calls via:
|
||||||
# X-Woo-Username
|
# X-Woo-Username
|
||||||
# X-Woo-App-Password
|
# X-Woo-App-Password
|
||||||
# Server uses those for THIS request's WooClient only; never writes them into
|
# HTTP Woo routes REQUIRE both headers (no Settings fallback). Health probe
|
||||||
# the Settings row. If either header is missing/blank, fall back to the
|
# still uses shop-default Settings via WooClient.from_settings / settings_woo_creds.
|
||||||
# shop-default singleton (woo_wp_username + encrypted woo_app_password).
|
# Never write override headers into the Settings row.
|
||||||
|
|
||||||
HEADER_WOO_USERNAME = "X-Woo-Username"
|
HEADER_WOO_USERNAME = "X-Woo-Username"
|
||||||
HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password"
|
HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password"
|
||||||
@@ -218,10 +221,29 @@ def request_woo_override(headers) -> tuple[str, str] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def client_for_request(conn, headers=None, request: Callable[..., tuple[int, dict, bytes]] | None = None) -> WooClient:
|
def _allow_settings_fallback() -> bool:
|
||||||
"""Build a WooClient for one HTTP request: override headers → Settings fallback.
|
"""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)
|
base, settings_user, settings_enc = settings_woo_creds(conn)
|
||||||
override = request_woo_override(headers)
|
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:
|
if request is None:
|
||||||
request = _default_request
|
request = _default_request
|
||||||
return WooClient(user, password, base, 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)
|
return WooClient.from_settings(base, settings_user, settings_enc, request=request)
|
||||||
|
|||||||
+18
-2
@@ -11,12 +11,15 @@
|
|||||||
import MaskPanel from './capture/MaskPanel.svelte';
|
import MaskPanel from './capture/MaskPanel.svelte';
|
||||||
import MeasurePanel from './capture/MeasurePanel.svelte';
|
import MeasurePanel from './capture/MeasurePanel.svelte';
|
||||||
import Settings from './create/settings/Settings.svelte';
|
import Settings from './create/settings/Settings.svelte';
|
||||||
|
import WooSetupGate from './create/settings/WooSetupGate.svelte';
|
||||||
import Review from './create/review/Review.svelte';
|
import Review from './create/review/Review.svelte';
|
||||||
import Publish from './create/publish/Publish.svelte';
|
import Publish from './create/publish/Publish.svelte';
|
||||||
import {
|
import {
|
||||||
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
flow, startSaving, enterReview, enterPublish, markDone, backToCapture, backToReview, type CalibratedCapture,
|
||||||
} from './create/flow';
|
} 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';
|
import { clearDraft } from './draft/idb';
|
||||||
|
|
||||||
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
|
||||||
@@ -26,9 +29,12 @@
|
|||||||
let busy = false;
|
let busy = false;
|
||||||
let pngUrl: string | null = null;
|
let pngUrl: string | null = null;
|
||||||
let showSettings = false;
|
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' };
|
let curFlow: import('./create/flow').Flow = { phase: 'capture' };
|
||||||
$: curFlow = $flow;
|
$: curFlow = $flow;
|
||||||
|
$: if ($wooGateForcedOpen) showWooGate = true;
|
||||||
let saving = false;
|
let saving = false;
|
||||||
let saveError = '';
|
let saveError = '';
|
||||||
|
|
||||||
@@ -38,7 +44,13 @@
|
|||||||
});
|
});
|
||||||
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
const unsubBusy = session.busy.subscribe((b) => (busy = b));
|
||||||
|
|
||||||
|
function onWooGateReady(): void {
|
||||||
|
clearWooGateForce();
|
||||||
|
showWooGate = false;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
showWooGate = !hasDeviceWooCreds() || $wooGateForcedOpen;
|
||||||
// e2e boot guard: `?noload` skips the resume probe so a test can seed the
|
// 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
|
// device draft into IndexedDB after the origin exists, then reload without
|
||||||
// ?noload to trigger the Resume prompt deterministically.
|
// ?noload to trigger the Resume prompt deterministically.
|
||||||
@@ -146,7 +158,10 @@
|
|||||||
<p class="detail">Missing: {gate}</p>
|
<p class="detail">Missing: {gate}</p>
|
||||||
</main>
|
</main>
|
||||||
{:else}
|
{:else}
|
||||||
<main class="app">
|
{#if showWooGate}
|
||||||
|
<WooSetupGate onReady={onWooGateReady} />
|
||||||
|
{/if}
|
||||||
|
<main class="app" class:gated={showWooGate} aria-hidden={showWooGate ? 'true' : undefined}>
|
||||||
{#if curFlow.phase === 'review'}
|
{#if curFlow.phase === 'review'}
|
||||||
<Review
|
<Review
|
||||||
slabId={curFlow.slabId}
|
slabId={curFlow.slabId}
|
||||||
@@ -256,6 +271,7 @@
|
|||||||
}
|
}
|
||||||
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
|
.app, .hardstop { max-width: 720px; margin: 0 auto; min-height: 100vh; box-sizing: border-box; }
|
||||||
.app { padding: 12px 16px 24px; }
|
.app { padding: 12px 16px 24px; }
|
||||||
|
.app.gated { visibility: hidden; pointer-events: none; }
|
||||||
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
.hardstop { padding: 48px 24px; color: #eee; text-align: center; }
|
||||||
.hardstop h1 { font-size: 1.6rem; }
|
.hardstop h1 { font-size: 1.6rem; }
|
||||||
.detail { color: #888; font-size: 0.85rem; }
|
.detail { color: #888; font-size: 0.85rem; }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// publish.py POST /api/v1/slabs/{id}/publish
|
// publish.py POST /api/v1/slabs/{id}/publish
|
||||||
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
|
// inference.py POST /api/v1/slabs/{id}/inference/call1 (DISABLED 410) | call2
|
||||||
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
// settings.py GET/PUT /api/v1/settings ; POST /api/v1/settings/test-woo|test-inference
|
||||||
// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage when set.
|
// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage (required on those routes).
|
||||||
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||||
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { afterEach, test } from 'node:test';
|
||||||
|
import {
|
||||||
|
clearDeviceWooCreds,
|
||||||
|
getDeviceWooCreds,
|
||||||
|
hasDeviceWooCreds,
|
||||||
|
setDeviceWooCreds,
|
||||||
|
} from './wooDeviceCreds';
|
||||||
|
|
||||||
|
const mem = new Map<string, string>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -76,3 +76,9 @@ export function withWooHeaders(extra?: HeadersInit): HeadersInit {
|
|||||||
}
|
}
|
||||||
return { ...extra, ...woo };
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
|
getSlab, getTaxonomy, listPhotos, photoUrl, publishSlab, ApiError,
|
||||||
type Slab, type Taxonomy, type PhotoRow,
|
type Slab, type Taxonomy, type PhotoRow,
|
||||||
} from '../../api/slabs';
|
} from '../../api/slabs';
|
||||||
|
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
|
||||||
import PublishPreview from './PublishPreview.svelte';
|
import PublishPreview from './PublishPreview.svelte';
|
||||||
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
|
import { buildPublishPreviewModel, type PublishPreviewModel } from './publishPreviewModel';
|
||||||
|
|
||||||
@@ -58,6 +59,8 @@
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
st = { phase: 'failure', fail: classify(e.code, e.message, e.detail ?? null) };
|
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 {
|
} else {
|
||||||
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
|
st = { phase: 'failure', fail: { code: 'network', message: 'The store is unreachable right now. You can retry.', recoverable: true, partialMedia: false, duplicateSku: false } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
clearDeviceWooCreds,
|
clearDeviceWooCreds,
|
||||||
withWooHeaders,
|
withWooHeaders,
|
||||||
} from '../../api/wooDeviceCreds';
|
} from '../../api/wooDeviceCreds';
|
||||||
|
import { reopenWooGate, isWooAuthFailure } from '../../api/wooGate';
|
||||||
|
|
||||||
interface SecretState { configured: boolean }
|
interface SecretState { configured: boolean }
|
||||||
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
||||||
@@ -272,13 +273,13 @@
|
|||||||
deviceWoo = getDeviceWooCreds();
|
deviceWoo = getDeviceWooCreds();
|
||||||
deviceMsg = deviceWoo.username && deviceWoo.appPassword
|
deviceMsg = deviceWoo.username && deviceWoo.appPassword
|
||||||
? 'Saved on this device (localStorage). Not written to server Settings.'
|
? '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() {
|
function clearDeviceWoo() {
|
||||||
clearDeviceWooCreds();
|
clearDeviceWooCreds();
|
||||||
deviceWoo = { username: '', appPassword: '' };
|
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() {
|
async function testWoo() {
|
||||||
@@ -288,7 +289,18 @@
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: withWooHeaders(),
|
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') };
|
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
|
||||||
if (wooTest.ok) await loadRates();
|
if (wooTest.ok) await loadRates();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -325,11 +337,17 @@
|
|||||||
<div class="section" data-testid="device-woo-creds">
|
<div class="section" data-testid="device-woo-creds">
|
||||||
<h3>This device’s Woo credentials</h3>
|
<h3>This device’s Woo credentials</h3>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Per-browser WP user + application password (localStorage). Sent as
|
Per-browser WP user + application password (localStorage). Required on
|
||||||
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code> on publish,
|
publish, taxonomy sync, and Test Woo (sent as
|
||||||
taxonomy sync, and Test Woo — never written into shared server Settings.
|
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code>) — never
|
||||||
Leave blank to use the shop-default below.
|
written into shared server Settings. A first-run gate collects these if
|
||||||
|
this browser has none yet.
|
||||||
</p>
|
</p>
|
||||||
|
{#if !(deviceWoo.username && deviceWoo.appPassword)}
|
||||||
|
<p class="msg err" data-testid="device-woo-reminder">
|
||||||
|
This device has no Woo credentials yet. Save them here or reload to open the setup gate.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
<div>
|
<div>
|
||||||
<label>Device WP Username</label>
|
<label>Device WP Username</label>
|
||||||
<input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" />
|
<input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" />
|
||||||
@@ -354,9 +372,8 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>Shop-default Woo (server Settings)</h3>
|
<h3>Shop-default Woo (server Settings)</h3>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Shared fallback for this install when this device has no local credentials.
|
Shop-default row (health probe / legacy). Publish and Test Woo use this
|
||||||
Saving here updates the encrypted Settings row — do not put other users’
|
device’s credentials only — do not put other users’ passwords here.
|
||||||
passwords here.
|
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<label>Shop-default WP Username</label>
|
<label>Shop-default WP Username</label>
|
||||||
@@ -415,7 +432,7 @@
|
|||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>WooCommerce</h3>
|
<h3>WooCommerce</h3>
|
||||||
<p class="hint">Uses this device’s Woo credentials when set; otherwise shop-default Settings.</p>
|
<p class="hint">Uses this device’s Woo credentials (required). Shop-default Settings are not used on Test Woo.</p>
|
||||||
<button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button>
|
<button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button>
|
||||||
{#if wooTest}
|
{#if wooTest}
|
||||||
<p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p>
|
<p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p>
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<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.
|
||||||
|
const inferPatch: Record<string, string> = {};
|
||||||
|
if (inferenceUrl.trim()) inferPatch.inference_base_url = inferenceUrl.trim();
|
||||||
|
if (inferenceKey.trim()) inferPatch.inference_api_key = inferenceKey.trim();
|
||||||
|
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. 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>
|
||||||
Reference in New Issue
Block a user