proto(woo): per-browser Woo creds via X-Woo-* request headers
Prototype routing so 2–3 owners can share one store URL with different WP application passwords. Device creds live in localStorage; server uses request override → Settings fallback and never writes overrides into the Settings row. Inference stays on shared Settings. See PROTOTYPE.md for UAT with two browsers and known LAN forge risk.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
# Prototype: per-user Woo credentials
|
||||
|
||||
Branch: `proto-per-user-woo-creds` (cut from `overnight-whole-app`).
|
||||
|
||||
## Goal
|
||||
|
||||
2–3 owners can share one SlabUploader install and one Woo store URL, each using
|
||||
their own WordPress user + application password. Inference URL/key stay global.
|
||||
No full auth, ACL, or publish mutex in this prototype.
|
||||
|
||||
## Credential scheme
|
||||
|
||||
| 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 |
|
||||
|
||||
Woo-touching calls that attach headers:
|
||||
|
||||
- `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).
|
||||
|
||||
## 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).
|
||||
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).
|
||||
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**.
|
||||
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.
|
||||
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).
|
||||
|
||||
## Risks left (prototype)
|
||||
|
||||
- **LAN can forge headers** — there is no app login; any client on the network can
|
||||
send arbitrary `X-Woo-*` values. Treat as trusted LAN only.
|
||||
- Device passwords sit in **localStorage** (XSS-readable). Acceptable for this
|
||||
prototype only.
|
||||
- No slab ownership ACL / taxonomy mutex / publish CAS — operators must not edit
|
||||
the same slab at once.
|
||||
- Taxonomy cache remains shared; a sync from either user refreshes the same rows.
|
||||
+12
-11
@@ -213,22 +213,23 @@ from .woo import (
|
||||
WooCredentialsMissing,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
client_for_request,
|
||||
settings_woo_creds,
|
||||
)
|
||||
|
||||
|
||||
def _woo_credentials() -> tuple[str, str | None, str | None]:
|
||||
conn = get_connection()
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
user_row = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
pass_row = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (user_row[0] if user_row else None), (pass_row[0] if pass_row else None)
|
||||
"""Shop-default Woo creds (Settings). Used by health probe only."""
|
||||
return settings_woo_creds(get_connection())
|
||||
|
||||
|
||||
@app.post("/api/v1/settings/test-woo")
|
||||
async def test_woo() -> dict[str, Any]:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
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).
|
||||
"""
|
||||
try:
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
client = client_for_request(get_connection(), request.headers)
|
||||
except WooCredentialsMissing as e:
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -260,10 +261,10 @@ async def get_taxonomy() -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/api/v1/admin/taxonomy/sync")
|
||||
async def post_taxonomy_sync() -> dict[str, Any]:
|
||||
base, username, enc_password = _woo_credentials()
|
||||
async def post_taxonomy_sync(request: Request) -> dict[str, Any]:
|
||||
"""Refresh taxonomy cache. Honors X-Woo-* override headers for this request."""
|
||||
try:
|
||||
client = WooClient.from_settings(base, username, enc_password)
|
||||
client = client_for_request(get_connection(), request.headers)
|
||||
except WooCredentialsMissing as e:
|
||||
return JSONResponse(status_code=502, content=_error("woo_unavailable", str(e)))
|
||||
try:
|
||||
|
||||
+8
-10
@@ -12,7 +12,7 @@ import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .db import get_connection
|
||||
@@ -23,6 +23,8 @@ from .woo import (
|
||||
WooCredentialsMissing,
|
||||
WooError,
|
||||
WooForbiddenError,
|
||||
client_for_request,
|
||||
settings_woo_creds,
|
||||
)
|
||||
from .review import compute_ready, _slab_dict
|
||||
|
||||
@@ -139,7 +141,7 @@ def _classify(e: Exception) -> tuple[str, str, bool]:
|
||||
|
||||
|
||||
@router.post("/{slab_id}/publish")
|
||||
async def publish(slab_id: str) -> dict:
|
||||
async def publish(slab_id: str, request: Request) -> dict:
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
||||
if row is None:
|
||||
@@ -150,9 +152,9 @@ async def publish(slab_id: str) -> 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.
|
||||
base, user, enc = _woo_creds(conn)
|
||||
# Per-request Woo creds: X-Woo-* headers → Settings fallback (never persisted).
|
||||
try:
|
||||
client = WooClient.from_settings(base, user, enc)
|
||||
client = client_for_request(conn, request.headers)
|
||||
except WooCredentialsMissing as e:
|
||||
return JSONResponse(status_code=422, content=_error("woo_unconfigured", str(e)))
|
||||
try:
|
||||
@@ -221,12 +223,8 @@ async def publish(slab_id: str) -> dict:
|
||||
|
||||
|
||||
def _woo_creds(conn) -> tuple[str, str | None, str | None]:
|
||||
import os
|
||||
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (u[0] if u else None), (p[0] if p else None)
|
||||
"""Shop-default Woo creds (Settings). Prefer client_for_request for HTTP paths."""
|
||||
return settings_woo_creds(conn)
|
||||
|
||||
|
||||
def _create_status(conn) -> str:
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Prototype: per-request X-Woo-* headers build distinct WooClients without clobbering Settings."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
import backend.db as db_mod
|
||||
from backend.crypto import encrypt, decrypt
|
||||
from backend.woo import (
|
||||
WooClient,
|
||||
client_for_request,
|
||||
request_woo_override,
|
||||
settings_woo_creds,
|
||||
HEADER_WOO_USERNAME,
|
||||
HEADER_WOO_APP_PASSWORD,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_dir():
|
||||
tmp = tempfile.mkdtemp(prefix="per_user_woo_")
|
||||
os.environ["SLAB_DB_PATH"] = os.path.join(tmp, "t.db")
|
||||
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()
|
||||
db_mod._reset_for_tests()
|
||||
conn = db_mod.init_db()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_wp_username','shop-default',NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES ('woo_app_password',NULL,?)",
|
||||
(encrypt("shop-secret"),),
|
||||
)
|
||||
conn.commit()
|
||||
yield conn
|
||||
db_mod._reset_for_tests()
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def _auth_pair(client: WooClient) -> tuple[str, str]:
|
||||
raw = base64.b64decode(client._auth).decode("utf-8")
|
||||
user, password = raw.split(":", 1)
|
||||
return user, password
|
||||
|
||||
|
||||
def test_override_headers_produce_distinct_auth_without_clobbering_settings(db_dir):
|
||||
conn = db_dir
|
||||
before = settings_woo_creds(conn)
|
||||
assert before[1] == "shop-default"
|
||||
assert decrypt(before[2]) == "shop-secret"
|
||||
|
||||
h_a = Headers({HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "alice-app-pw"})
|
||||
h_b = Headers({HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "bob-app-pw"})
|
||||
|
||||
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):
|
||||
conn = db_dir
|
||||
# Username only — incomplete override
|
||||
assert request_woo_override(Headers({HEADER_WOO_USERNAME: "alice"})) is None
|
||||
client = client_for_request(conn, Headers({HEADER_WOO_USERNAME: "alice"}))
|
||||
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."""
|
||||
from fastapi.testclient import TestClient
|
||||
from backend import app as app_mod
|
||||
|
||||
seen: list[str] = []
|
||||
|
||||
def fake_sync(client, conn):
|
||||
seen.append(_auth_pair(client)[0])
|
||||
return {"taxonomy_anchor": "x", "taxonomy_last_sync_at": "2026-01-01T00:00:00"}
|
||||
|
||||
monkeypatch.setattr(app_mod.taxonomy_mod, "sync", fake_sync)
|
||||
tc = TestClient(app_mod.app)
|
||||
r1 = tc.post(
|
||||
"/api/v1/settings/test-woo",
|
||||
headers={HEADER_WOO_USERNAME: "alice", HEADER_WOO_APP_PASSWORD: "pw-a"},
|
||||
)
|
||||
r2 = tc.post(
|
||||
"/api/v1/settings/test-woo",
|
||||
headers={HEADER_WOO_USERNAME: "bob", HEADER_WOO_APP_PASSWORD: "pw-b"},
|
||||
)
|
||||
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 settings_woo_creds(db_dir)[1] == "shop-default"
|
||||
+57
-3
@@ -1,8 +1,12 @@
|
||||
"""WooCommerce REST v3 client (stdlib urllib). Basic-auth, HTTPS-only, paginated.
|
||||
|
||||
Creds come from Settings (wp_username plain, app_password AES-GCM). Missing creds
|
||||
raise WooCredentialsMissing (a fail-closed condition the caller maps to ok:false),
|
||||
distinct from WooAuthError (a 401 from the store).
|
||||
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).
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
@@ -177,3 +181,53 @@ class WooClient:
|
||||
if code not in (200, 201):
|
||||
raise WooServerError(f"media upload returned {code}")
|
||||
return int(json.loads(resp)["id"])
|
||||
|
||||
|
||||
# --- Per-request Woo credential override (prototype) ---
|
||||
# Scheme: browser sends plaintext WP Application Password credentials on
|
||||
# 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).
|
||||
|
||||
HEADER_WOO_USERNAME = "X-Woo-Username"
|
||||
HEADER_WOO_APP_PASSWORD = "X-Woo-App-Password"
|
||||
|
||||
|
||||
def settings_woo_creds(conn) -> tuple[str, str | None, str | None]:
|
||||
"""Shop-default Woo creds from env + Settings (encrypted password)."""
|
||||
import os
|
||||
|
||||
base = os.environ.get("WOO_BASE_URL", "")
|
||||
u = conn.execute("SELECT value FROM settings WHERE key='woo_wp_username'").fetchone()
|
||||
p = conn.execute("SELECT value_enc FROM settings WHERE key='woo_app_password'").fetchone()
|
||||
return base, (u[0] if u else None), (p[0] if p else None)
|
||||
|
||||
|
||||
def request_woo_override(headers) -> tuple[str, str] | None:
|
||||
"""Return (username, app_password) if both override headers are present and non-blank."""
|
||||
if headers is None:
|
||||
return None
|
||||
# Starlette Headers are case-insensitive
|
||||
user = (headers.get(HEADER_WOO_USERNAME) or "").strip()
|
||||
password = (headers.get(HEADER_WOO_APP_PASSWORD) or "").strip()
|
||||
if user and password:
|
||||
return user, password
|
||||
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.
|
||||
|
||||
Never mutates Settings. Inference stays on shared Settings (caller responsibility).
|
||||
"""
|
||||
base, settings_user, settings_enc = settings_woo_creds(conn)
|
||||
override = request_woo_override(headers)
|
||||
if override is not None:
|
||||
user, password = override
|
||||
if request is None:
|
||||
request = _default_request
|
||||
return WooClient(user, password, base, request)
|
||||
return WooClient.from_settings(base, settings_user, settings_enc, request=request)
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
// publish.py POST /api/v1/slabs/{id}/publish
|
||||
// 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
|
||||
// Woo-backed calls attach X-Woo-Username / X-Woo-App-Password from localStorage when set.
|
||||
// taxonomy.py GET/POST /api/v1/admin/taxonomy[.../sync]
|
||||
// pricing.py CRUD /api/v1/admin/pricing[/{id}]
|
||||
//
|
||||
// Every non-2xx is surfaced as an ApiError carrying the backend `code` and a plain
|
||||
// owner-facing `message` (QUALITY-120: raw codes/stack traces never reach the owner).
|
||||
|
||||
import { withWooHeaders } from './wooDeviceCreds';
|
||||
|
||||
export interface Slab {
|
||||
id: string;
|
||||
sku: string;
|
||||
@@ -338,6 +341,7 @@ export interface PublishResult {
|
||||
export function publishSlab(slabId: string): Promise<PublishResult> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/publish`, {
|
||||
method: 'POST',
|
||||
headers: withWooHeaders(),
|
||||
}).then((r) => parse<PublishResult>(r));
|
||||
}
|
||||
|
||||
@@ -448,7 +452,10 @@ export function syncTaxonomy(): Promise<{
|
||||
taxonomy_anchor: string;
|
||||
taxonomy_last_sync_at: string;
|
||||
}> {
|
||||
return fetch('/api/v1/admin/taxonomy/sync', { method: 'POST' }).then((r) => parse(r));
|
||||
return fetch('/api/v1/admin/taxonomy/sync', {
|
||||
method: 'POST',
|
||||
headers: withWooHeaders(),
|
||||
}).then((r) => parse(r));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -542,9 +549,10 @@ export interface TestInferenceResult {
|
||||
}
|
||||
|
||||
export function testWoo(): Promise<TestWooResult> {
|
||||
return fetch('/api/v1/settings/test-woo', { method: 'POST' }).then((r) =>
|
||||
parse<TestWooResult>(r),
|
||||
);
|
||||
return fetch('/api/v1/settings/test-woo', {
|
||||
method: 'POST',
|
||||
headers: withWooHeaders(),
|
||||
}).then((r) => parse<TestWooResult>(r));
|
||||
}
|
||||
|
||||
export function testInference(): Promise<TestInferenceResult> {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Browser-local Woo credentials (prototype per-user routing).
|
||||
*
|
||||
* Stored in localStorage on this device only — never written to server Settings
|
||||
* via the device-creds path. Attached as X-Woo-Username / X-Woo-App-Password on
|
||||
* Woo-touching API calls. See PROTOTYPE.md.
|
||||
*/
|
||||
|
||||
const LS_USER = 'slabuploader.woo.device.username';
|
||||
const LS_PASS = 'slabuploader.woo.device.appPassword';
|
||||
|
||||
export const HEADER_WOO_USERNAME = 'X-Woo-Username';
|
||||
export const HEADER_WOO_APP_PASSWORD = 'X-Woo-App-Password';
|
||||
|
||||
export interface DeviceWooCreds {
|
||||
username: string;
|
||||
appPassword: string;
|
||||
}
|
||||
|
||||
function safeGet(key: string): string {
|
||||
try {
|
||||
return (localStorage.getItem(key) || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function safeSet(key: string, value: string): void {
|
||||
try {
|
||||
if (value) localStorage.setItem(key, value);
|
||||
else localStorage.removeItem(key);
|
||||
} catch {
|
||||
/* private mode / quota — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getDeviceWooCreds(): DeviceWooCreds {
|
||||
return {
|
||||
username: safeGet(LS_USER),
|
||||
appPassword: safeGet(LS_PASS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist this browser's Woo login. Empty strings clear the slot. */
|
||||
export function setDeviceWooCreds(creds: DeviceWooCreds): void {
|
||||
safeSet(LS_USER, (creds.username || '').trim());
|
||||
safeSet(LS_PASS, (creds.appPassword || '').trim());
|
||||
}
|
||||
|
||||
export function clearDeviceWooCreds(): void {
|
||||
safeSet(LS_USER, '');
|
||||
safeSet(LS_PASS, '');
|
||||
}
|
||||
|
||||
/** Headers to merge into Woo-backed fetch calls. Empty object when unset. */
|
||||
export function wooOverrideHeaders(): Record<string, string> {
|
||||
const { username, appPassword } = getDeviceWooCreds();
|
||||
if (!username || !appPassword) return {};
|
||||
return {
|
||||
[HEADER_WOO_USERNAME]: username,
|
||||
[HEADER_WOO_APP_PASSWORD]: appPassword,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge Content-Type / caller headers with device Woo override when present. */
|
||||
export function withWooHeaders(extra?: HeadersInit): HeadersInit {
|
||||
const woo = wooOverrideHeaders();
|
||||
if (!extra) return woo;
|
||||
if (extra instanceof Headers) {
|
||||
const h = new Headers(extra);
|
||||
for (const [k, v] of Object.entries(woo)) h.set(k, v);
|
||||
return h;
|
||||
}
|
||||
if (Array.isArray(extra)) {
|
||||
return [...extra, ...Object.entries(woo)];
|
||||
}
|
||||
return { ...extra, ...woo };
|
||||
}
|
||||
@@ -2,6 +2,12 @@
|
||||
import { decodeName } from '../decodeName';
|
||||
import { onMount } from 'svelte';
|
||||
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||
import {
|
||||
getDeviceWooCreds,
|
||||
setDeviceWooCreds,
|
||||
clearDeviceWooCreds,
|
||||
withWooHeaders,
|
||||
} from '../../api/wooDeviceCreds';
|
||||
|
||||
interface SecretState { configured: boolean }
|
||||
// Masked mode: object {configured}. Unmasked UAT mode (SLAB_UAT_UNMASK_SECRETS): plain string.
|
||||
@@ -42,6 +48,10 @@
|
||||
// local edit buffers (write-only for secrets)
|
||||
let form: any = {};
|
||||
|
||||
// This device only — localStorage; never sent via Settings PUT.
|
||||
let deviceWoo = { username: '', appPassword: '' };
|
||||
let deviceMsg = '';
|
||||
|
||||
let rateRows: RateRow[] = [];
|
||||
let ratesLoading = false;
|
||||
let ratesSaving = false;
|
||||
@@ -206,6 +216,7 @@
|
||||
message = 'Failed to load settings';
|
||||
}
|
||||
loading = false;
|
||||
deviceWoo = getDeviceWooCreds();
|
||||
void loadRates();
|
||||
}
|
||||
|
||||
@@ -253,10 +264,30 @@
|
||||
let wooTest: TestResult | null = null;
|
||||
let inferenceTest: TestResult | null = null;
|
||||
|
||||
function saveDeviceWoo() {
|
||||
setDeviceWooCreds({
|
||||
username: deviceWoo.username,
|
||||
appPassword: deviceWoo.appPassword,
|
||||
});
|
||||
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.';
|
||||
}
|
||||
|
||||
function clearDeviceWoo() {
|
||||
clearDeviceWooCreds();
|
||||
deviceWoo = { username: '', appPassword: '' };
|
||||
deviceMsg = 'Cleared — Woo calls will use shop-default Settings credentials.';
|
||||
}
|
||||
|
||||
async function testWoo() {
|
||||
wooTest = { ok: false, detail: 'Working…', busy: true };
|
||||
try {
|
||||
const res = await fetch('/api/v1/settings/test-woo', { method: 'POST' });
|
||||
const res = await fetch('/api/v1/settings/test-woo', {
|
||||
method: 'POST',
|
||||
headers: withWooHeaders(),
|
||||
});
|
||||
const body = await res.json();
|
||||
wooTest = { ok: !!body.ok, detail: body.detail || (body.ok ? 'Connected & synced' : 'failed') };
|
||||
if (wooTest.ok) await loadRates();
|
||||
@@ -291,15 +322,52 @@
|
||||
<input type="text" value={settings.woo_base_url} disabled />
|
||||
</div>
|
||||
|
||||
<div class="section" data-testid="device-woo-creds">
|
||||
<h3>This device’s Woo credentials</h3>
|
||||
<p class="hint">
|
||||
Per-browser WP user + application password (localStorage). Sent as
|
||||
<code>X-Woo-Username</code> / <code>X-Woo-App-Password</code> on publish,
|
||||
taxonomy sync, and Test Woo — never written into shared server Settings.
|
||||
Leave blank to use the shop-default below.
|
||||
</p>
|
||||
<div>
|
||||
<label>Device WP Username</label>
|
||||
<input bind:value={deviceWoo.username} data-testid="device-woo-username" autocomplete="username" />
|
||||
</div>
|
||||
<div>
|
||||
<label>Device App Password</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={deviceWoo.appPassword}
|
||||
data-testid="device-woo-app-password"
|
||||
autocomplete="current-password"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button type="button" on:click={saveDeviceWoo} data-testid="save-device-woo">Save on this device</button>
|
||||
<button type="button" class="ghost" on:click={clearDeviceWoo} data-testid="clear-device-woo">Clear</button>
|
||||
</div>
|
||||
{#if deviceMsg}<p class="msg">{deviceMsg}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>Shop-default Woo (server Settings)</h3>
|
||||
<p class="hint">
|
||||
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.
|
||||
</p>
|
||||
<div>
|
||||
<label>WP Username</label>
|
||||
<label>Shop-default WP Username</label>
|
||||
<input bind:value={form.woo_wp_username} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Woo App Password {secretConfigured(settings.woo_app_password) ? '(configured)' : '(not set)'}</label>
|
||||
<label>Shop-default Woo App Password {secretConfigured(settings.woo_app_password) ? '(configured)' : '(not set)'}</label>
|
||||
<input bind:value={form.woo_app_password} placeholder={isUnmasked(settings.woo_app_password) ? '' : '••••••••'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Inference Base URL {secretConfigured(settings.inference_base_url) ? '(configured)' : '(not set)'}</label>
|
||||
@@ -347,6 +415,7 @@
|
||||
|
||||
<div class="section">
|
||||
<h3>WooCommerce</h3>
|
||||
<p class="hint">Uses this device’s Woo credentials when set; otherwise shop-default Settings.</p>
|
||||
<button on:click={testWoo} disabled={saving || wooTest?.busy}>Test WooCommerce connection</button>
|
||||
{#if wooTest}
|
||||
<p class="msg" class:ok={wooTest.ok} class:err={!wooTest.ok}>{wooTest.detail}</p>
|
||||
@@ -426,4 +495,8 @@
|
||||
.rate-head { color: #aaa; font-size: 0.85rem; }
|
||||
.species-name { margin: 0; color: #eee; }
|
||||
.rate-row input { margin: 0; }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.row button { margin-top: 8px; }
|
||||
button.ghost { background: transparent; color: #ccc; border: 1px solid #555; }
|
||||
code { font-size: 0.85em; color: #9cf; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user