diff --git a/.github/workflows/ui-checks.yml b/.github/workflows/ui-checks.yml
new file mode 100644
index 0000000..309b47c
--- /dev/null
+++ b/.github/workflows/ui-checks.yml
@@ -0,0 +1,38 @@
+name: Management UI and identity
+on:
+ pull_request:
+ paths:
+ - 'extore/auth.py'
+ - 'extore/static/**'
+ - 'tests/**'
+ - 'scripts/check_management_ui.py'
+ - '.github/workflows/ui-checks.yml'
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ check:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
+ with:
+ persist-credentials: false
+ submodules: recursive
+ - uses: astral-sh/setup-uv@94527f2e458b27549849d47d273a16bec83a01e9
+ with:
+ version: "0.12.20"
+ - run: uv sync --frozen --python 3.12
+ - run: uv run ruff check extore scripts tests examples
+ - run: uv run ruff format --check extore scripts tests examples
+ - run: node --test tests/*.test.cjs
+ - run: uv run pytest -q tests/test_passkeys.py tests/test_passkey_reauth.py tests/test_shop_account_auth.py tests/test_browser_shop_scope.py
+ - run: uv run --with playwright==1.57.0 playwright install --with-deps chromium
+ - run: sudo apt-get install -y --no-install-recommends fonts-noto-cjk
+ - run: uv run --with playwright==1.57.0 python scripts/check_management_ui.py
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9
+ if: always()
+ with:
+ name: management-ui-review
+ path: output/ui-review
+ retention-days: 7
diff --git a/extore/auth.py b/extore/auth.py
index 7efe9c8..3892f4d 100644
--- a/extore/auth.py
+++ b/extore/auth.py
@@ -1,3 +1,4 @@
+import base64
import json
import time
@@ -22,7 +23,15 @@
from .config import COOKIE_SECURE, DATA, ORIGIN, RP_ID
from .db import audit, db, set_setting, setting
from .link_access import revoke_session
-from .security import create_session, digest, fail, rate_limit, session, token
+from .security import (
+ authorize_management,
+ create_session,
+ digest,
+ fail,
+ rate_limit,
+ session,
+ token,
+)
router = APIRouter(prefix="/api/auth")
ph = PasswordHasher()
@@ -148,17 +157,23 @@ def logout(request: Request, response: Response):
def challenge(c, request, response, kind, s=None):
key = token()
+ descriptors = None
if s:
+ rows = c.execute(
+ "SELECT id FROM credentials WHERE shop_id IS ?", (s.get("shop_id"),)
+ ).fetchall()
+ descriptors = [
+ PublicKeyCredentialDescriptor(
+ id=base64.urlsafe_b64decode(r["id"] + "=" * (-len(r["id"]) % 4))
+ )
+ for r in rows
+ ]
+ if s and kind == "register":
if (
s["channel"] == "browser"
and time.time() - (s.get("auth_at") or s["created"]) > 600
):
fail("添加 Passkey 前请重新登录", 401)
- rows = c.execute(
- "SELECT id FROM credentials WHERE shop_id IS ?", (s.get("shop_id"),)
- ).fetchall()
- import base64
-
shop_id = s.get("shop_id")
if shop_id:
from .shops import shop_row
@@ -175,20 +190,19 @@ def challenge(c, request, response, kind, s=None):
rp_name="Extore · 兑所",
user_id=user_id,
user_name=user_name,
- exclude_credentials=[
- PublicKeyCredentialDescriptor(
- id=base64.urlsafe_b64decode(r["id"] + "=" * (-len(r["id"]) % 4))
- )
- for r in rows
- ],
+ exclude_credentials=descriptors,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
else:
+ if s and not descriptors:
+ fail("当前账号没有 Passkey,请使用其他验证方式", 409)
opts = generate_authentication_options(
- rp_id=RP_ID, user_verification=UserVerificationRequirement.REQUIRED
+ rp_id=RP_ID,
+ allow_credentials=descriptors,
+ user_verification=UserVerificationRequirement.REQUIRED,
)
c.execute("DELETE FROM challenges WHERE expires", (time.time(),))
c.execute(
@@ -346,6 +360,79 @@ def login_verify(body: CredentialInput, request: Request, response: Response):
}
+@router.post("/reauth/passkey/options")
+def reauth_options(request: Request, response: Response):
+ s = session(request)
+ if s["channel"] != "browser":
+ fail("请在当前浏览器中确认身份", 403)
+ rate_limit(request, "passkey-reauth", 20, 60)
+ with db() as c:
+ authorize_management(c, s)
+ return challenge(c, request, response, "reauth", s)
+
+
+@router.post("/reauth/passkey/verify")
+def reauth_verify(body: CredentialInput, request: Request, response: Response):
+ s = session(request)
+ if s["channel"] != "browser":
+ fail("请在当前浏览器中确认身份", 403)
+ rate_limit(request, "passkey-reauth-verify", 20, 60)
+ # Commit consumption before validation: a failed assertion is single-use too.
+ with db() as c:
+ authorize_management(c, s)
+ ch = consume_challenge(c, request, "reauth", s)
+ with db() as c:
+ authorize_management(c, s)
+ row = c.execute(
+ "SELECT * FROM credentials WHERE id=? AND shop_id IS ?",
+ (body.credential.get("id", ""), s.get("shop_id")),
+ ).fetchone()
+ if not row:
+ fail("所选 Passkey 不属于当前账号,请重新选择", 401)
+ try:
+ handle = body.credential.get("response", {}).get("userHandle")
+ if handle is not None:
+ expected = (
+ ("extore-shop:" + s["shop_id"]).encode()
+ if s.get("shop_id")
+ else b"extore-owner"
+ )
+ if (
+ base64.b64decode(
+ handle + "=" * (-len(handle) % 4), altchars=b"-_", validate=True
+ )
+ != expected
+ ):
+ raise ValueError("Passkey user handle does not match the session")
+ verified = verify_authentication_response(
+ credential=body.credential,
+ expected_challenge=ch,
+ expected_rp_id=RP_ID,
+ expected_origin=ORIGIN,
+ credential_public_key=row["public_key"],
+ credential_current_sign_count=row["sign_count"],
+ require_user_verification=True,
+ )
+ except Exception:
+ fail("Passkey 校验失败,请使用当前账号的 Passkey", 401)
+ c.execute(
+ "UPDATE credentials SET sign_count=? WHERE id=?",
+ (verified.new_sign_count, row["id"]),
+ )
+ c.execute(
+ "UPDATE sessions SET auth_at=?,auth_method='passkey' WHERE digest=?",
+ (time.time(), s["digest"]),
+ )
+ audit(
+ c,
+ f"shop:{s['shop_id']}" if s.get("shop_id") else "owner",
+ "account.reauthenticate",
+ s["id"],
+ )
+ response.delete_cookie("extore_challenge", path="/api/auth")
+ return {"ok": True}
+
+
@router.get("/passkeys")
def keys(request: Request):
s = session(request)
diff --git a/extore/static/account.js b/extore/static/account.js
index 269d428..db23a1d 100644
--- a/extore/static/account.js
+++ b/extore/static/account.js
@@ -89,7 +89,7 @@
clearTotpSetup();
throw new Error("当前登录账户或店铺已改变,请重新打开后台后再操作");
}
- if (allowFreshSession) { auth = current; onAuth(current); }
+ if (allowFreshSession || verified) { auth = current; onAuth(current); }
return true;
}
function showTotpSetup(result) {
@@ -181,12 +181,18 @@
on("#account-fresh-passkey", "click", async () => {
if (!current()) return;
if (typeof passkey !== "function") throw new Error("Passkey 模块未加载,请刷新页面");
- const verified = await passkey(false, null, { signal: controller.signal, isCurrent: current });
- if (!current() || !await checkScope(true, verified) || !current()) return;
+ const verified = await passkey(false, null, {
+ signal: controller.signal, isCurrent: current, reauthenticate: true,
+ expectedScope: auth.shop_id || (rootScope(auth) ? "platform" : undefined),
+ expectedSessionId: auth.session_id,
+ });
+ if (!current() || !await checkScope(false, verified) || !current()) return;
target.innerHTML = "";
await handler();
});
on("#account-fresh-cancel", "click", () => { if (!current()) return; options.onCancel?.(); target.innerHTML = ""; });
+ target.scrollIntoView?.({ block: "nearest" });
+ $(shopScope(auth) ? "#fresh-password" : "#account-fresh-passkey")?.focus();
}
const completed = (message) => { page(tr("操作已完成", "Done"), ``); };
diff --git a/extore/static/app.js b/extore/static/app.js
index 2606505..2da0e31 100644
--- a/extore/static/app.js
+++ b/extore/static/app.js
@@ -287,7 +287,7 @@ function managementAuthority(auth = authStatus) {
return JSON.stringify([auth.role, auth.shop_id, auth.superadmin === true, auth.link_id || auth.staff_id || null]);
}
function managementHeaders(path, options = {}, headers = {}) {
- const bound = /^\/(?:admin|manage|platform|shop)(?:\/|\?|$)/.test(path) || /^\/auth\/(?:passkeys|register\/(?:options|verify)|password\/change|totp|reauth\/password|logout)(?:\/|\?|$)/.test(path);
+ const bound = /^\/(?:admin|manage|platform|shop)(?:\/|\?|$)/.test(path) || /^\/auth\/(?:passkeys|register\/(?:options|verify)|password\/change|totp|reauth\/(?:password|passkey\/(?:options|verify))|logout)(?:\/|\?|$)/.test(path);
if (bound) {
const { expectedScope: scope, expectedSessionId: sessionId } = managementOptions(options);
if (typeof scope === "string") headers["X-Extore-Shop-Scope"] = scope;
@@ -573,6 +573,15 @@ $("#brand").addEventListener("click", () => {
});
function syncPreferenceControls() {
$("#theme").value = preferences.settings.theme;
+ $("#accent").value = preferences.settings.accent || "green";
+ $("#appearance-label").textContent = tr("外观", "Appearance");
+ $("#theme-label").textContent = tr("颜色主题", "Color theme");
+ $("#accent-label").textContent = tr("强调色", "Accent color");
+ $("#language-label").textContent = tr("语言", "Language");
+ $("#appearance-note").textContent = tr("自动保存到此浏览器。", "Saved in this browser.");
+ $("#accent").setAttribute("aria-label", tr("强调色", "Accent color"));
+ for (const [value, cn, en] of [["green", "松绿", "Pine"], ["blue", "海蓝", "Ocean"], ["violet", "紫罗兰", "Violet"], ["rose", "玫瑰", "Rose"], ["amber", "琥珀", "Amber"], ["graphite", "石墨", "Graphite"]])
+ $(`#accent option[value=${value}]`).textContent = tr(cn, en);
$("#language").value = preferences.settings.language;
$("#theme").setAttribute("aria-label", tr("主题", "Theme"));
$("#language").setAttribute("aria-label", tr("语言", "Language"));
@@ -598,6 +607,14 @@ $("#language").addEventListener("change", () =>
$("#theme").addEventListener("change", () =>
preferences.setTheme($("#theme").value),
);
+$("#accent").addEventListener("change", () => preferences.setAccent($("#accent").value));
+$("#appearance").addEventListener("keydown", (event) => {
+ if (event.key === "Escape") { $("#appearance").open = false; $("#appearance summary").focus(); }
+});
+document.addEventListener?.("click", (event) => {
+ const appearance = $("#appearance");
+ if (appearance.open && !appearance.contains(event.target)) appearance.open = false;
+});
window.addEventListener("popstate", start);
window.addEventListener("hashchange", () => {
if (location.pathname === "/cli/owner") start();
@@ -1384,8 +1401,9 @@ async function passkey(register = false, name = null, options = {}) {
options = managementOptions(options);
const generation = routeLoadId, pathname = location.pathname;
const active = () => (options.isCurrent ? options.isCurrent() : generation === routeLoadId && pathname === location.pathname);
+ const ceremony = register ? "register" : options.reauthenticate ? "reauth/passkey" : "login";
const publicOptions = await api(
- "/auth/" + (register ? "register" : "login") + "/options",
+ "/auth/" + ceremony + "/options",
{}, "POST", options,
);
if (!active()) throw new DOMException("Account page changed", "AbortError");
@@ -1409,7 +1427,7 @@ async function passkey(register = false, name = null, options = {}) {
? encode(c.response.userHandle)
: null;
}
- await api("/auth/" + (register ? "register" : "login") + "/verify", {
+ await api("/auth/" + ceremony + "/verify", {
credential: {
id: c.id,
rawId: encode(c.rawId),
@@ -1420,7 +1438,7 @@ async function passkey(register = false, name = null, options = {}) {
name: name || $("#key-name")?.value || "我的 Passkey",
}, "POST", options);
if (!active()) throw new DOMException("Account page changed", "AbortError");
- return api("/auth/status");
+ return api("/auth/status", undefined, "GET", options);
}
async function admin() {
stopPoll();
@@ -1521,24 +1539,38 @@ function shell() {
{ name: tr("店铺设置", "Shop settings"), keys: ["profiles", "commerce", "proxy", "security"] },
{ name: tr("平台管理", "Platform settings"), keys: ["shops", "mail"] },
].map((group) => ({ ...group, items: group.keys.map((key) => items.find(([value]) => value === key)).filter(Boolean) })).filter((group) => group.items.length);
- app.innerHTML = `
${role === "staff" ? tr("商品管理", "Product management") : tr("商家后台", "Merchant dashboard")}
${esc(managementIdentity())}
`;
+ app.innerHTML = `${role === "staff" ? tr("商品管理", "Product management") : tr("商家后台", "Merchant dashboard")}
${esc(managementIdentity())}
`;
on("#logout", async () => {
progressBoardController?.dispose(); progressBoardController = null;
await api("/auth/logout", {});
navigate("/admin");
});
const choose = (value, mobile = false) => {
- if (!items.some(([key]) => key === value) || tab === value) return;
+ if (!items.some(([key]) => key === value)) return;
+ if (tab === value) {
+ if (mobile) { closeMenu(); $("#management-section").focus(); }
+ return;
+ }
tab = value;
shell();
const target = mobile ? $("#management-section") : $(`[data-tab="${value}"]`);
target?.focus?.();
perform(renderTab);
};
+ const menu = $("#management-menu-toggle"), navigation = $("#management-navigation-items");
+ const closeMenu = () => { menu.setAttribute("aria-expanded", "false"); navigation.removeAttribute("data-open"); };
+ on("#management-menu-toggle", () => {
+ const open = menu.getAttribute("aria-expanded") !== "true";
+ menu.setAttribute("aria-expanded", String(open));
+ navigation.toggleAttribute("data-open", open);
+ });
+ navigation.addEventListener("keydown", (event) => {
+ if (event.key === "Escape") { closeMenu(); menu.focus(); }
+ });
on("#management-section", () => choose($("#management-section").value, true), "change");
document.querySelectorAll("[data-tab]").forEach((b) =>
b.addEventListener("click", () => {
- choose(b.dataset.tab);
+ choose(b.dataset.tab, menu.getAttribute("aria-expanded") === "true");
}),
);
}
diff --git a/extore/static/appearance.css b/extore/static/appearance.css
new file mode 100644
index 0000000..f0215f1
--- /dev/null
+++ b/extore/static/appearance.css
@@ -0,0 +1,50 @@
+/* Accent choices are independent of the light/dark surface colors. */
+:root[data-accent="blue"] { --green: #2457bd; --button-hover: #1b4393; --status-bg: #e5edfc; --selection: #cbddff; }
+:root[data-accent="violet"] { --green: #7041b1; --button-hover: #57308e; --status-bg: #eee6f8; --selection: #e1cef8; }
+:root[data-accent="rose"] { --green: #a53560; --button-hover: #81284a; --status-bg: #f9e5ed; --selection: #f2cddd; }
+:root[data-accent="amber"] { --green: #865006; --button-hover: #653b04; --status-bg: #f8edcf; --selection: #f3dca6; }
+:root[data-accent="graphite"] { --green: #46505e; --button-hover: #303945; --status-bg: #e8ebef; --selection: #d3dbe5; }
+:root[data-theme="dark"][data-accent="blue"] { --green: #a6c5ff; --button-hover: #c3d8ff; --status-bg: #253650; --selection: #a6c5ff; }
+:root[data-theme="dark"][data-accent="violet"] { --green: #d0b1fa; --button-hover: #e0cafa; --status-bg: #3d2e51; --selection: #d0b1fa; }
+:root[data-theme="dark"][data-accent="rose"] { --green: #f5aec9; --button-hover: #fac9db; --status-bg: #4d2d3b; --selection: #f5aec9; }
+:root[data-theme="dark"][data-accent="amber"] { --green: #f0ca84; --button-hover: #f8dfae; --status-bg: #493b23; --selection: #f0ca84; }
+:root[data-theme="dark"][data-accent="graphite"] { --green: #c4cedc; --button-hover: #e1e6ed; --status-bg: #343b46; --selection: #c4cedc; }
+.appearance-control { position: relative; }
+.appearance-control > summary { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 8px 10px; cursor: pointer; list-style: none; color: var(--ink); border-radius: 10px; white-space: nowrap; }
+.appearance-control > summary::-webkit-details-marker { display: none; }
+.appearance-control > summary:hover, .appearance-control[open] > summary { background: var(--warm); }
+.appearance-icon { width: 17px; height: 17px; border: 2px solid var(--green); border-radius: 50%; background: linear-gradient(90deg, var(--green) 50%, transparent 50%); }
+.appearance-panel { position: absolute; z-index: 30; inset-inline-end: 0; top: calc(100% + 10px); width: 260px; max-width: calc(100vw - 40px); padding: 22px; border-radius: 18px; background: var(--white); box-shadow: 0 12px 45px rgb(0 0 0 / 16%); }
+.appearance-panel .field { margin: 0 0 16px; gap: 6px; }
+.header-right .appearance-panel select { width: 100%; min-height: 44px; padding: 9px 12px; background: var(--paper); border: 1px solid var(--line); font-size: 16px; }
+.appearance-panel .caption { font-size: 12px; }
+header .source-link { background: transparent; border: 0; }
+body:has(.management-shell, .account-page.narrow) :is(header, footer) { background-image: none; }
+/* The dashboard is a workspace, not a stack of torn paper cards. */
+.management-shell .panel, .account-page.narrow .panel { border-radius: 18px; filter: none; padding: clamp(20px, 3vw, 32px); }
+.management-shell .panel::after, .account-page.narrow .panel::after { content: none; }
+.management-shell .panel > :first-child { margin-top: 0; }
+.account-page .panel > .field + button { margin-top: 16px; }
+.management-shell :is(.product-list, .product-row, .form-divider, .empty) { border-style: none; }
+.management-shell .product-row { padding-block: 24px; gap: 18px; }
+.management-shell .product-row + .product-row { border-top: 1px solid var(--line); }
+.management-shell .form-divider { padding-top: 28px; margin-top: 28px; }
+.management-shell .empty { padding-block: 48px; }
+.management-shell .account-stepup { background: var(--status-bg); margin-top: 24px; }
+@media (max-width: 760px) {
+ header { gap: 8px; padding-block: 16px; }
+ .header-right { width: auto; flex: 1 1 auto; justify-content: flex-end; gap: 4px; }
+ .header-right .source-label, .header-right #source-label, .header-right .source-stars { display: none; }
+ header .source-link { padding: 10px; gap: 0; }
+ .header-home { border: 0; font-size: 12px; }
+ .appearance-panel { position: fixed; inset-inline: 20px; top: 76px; width: auto; max-width: 360px; margin-inline-start: auto; max-height: calc(100dvh - 96px); overflow: auto; }
+ .management-shell .product-row { gap: 16px; flex-direction: column; align-items: stretch; }
+ .management-shell .product-row .actions { justify-content: flex-start; width: auto; }
+ .management-shell .product-row .actions button { flex: 0 1 auto; }
+ .management-shell .account-page form > button[type="submit"] { margin-top: 16px; }
+}
+@media (max-width: 380px) {
+ .header-right { gap: 0; }
+ header .source-link { padding-inline: 6px; }
+ .appearance-control > summary { padding-inline: 8px; }
+}
diff --git a/extore/static/index.html b/extore/static/index.html
index 25572ab..4928948 100644
--- a/extore/static/index.html
+++ b/extore/static/index.html
@@ -7,9 +7,10 @@
Extore · 兑所
-
+
-
+
+
@@ -35,7 +36,7 @@
-
+
@@ -45,7 +46,7 @@
-
+
@@ -63,16 +64,29 @@
源码☆ —
-
-
+
+ 外观
+
+
+
+
+
+
+
+
+
+
+
自动保存到此浏览器。
+
+
+
diff --git a/extore/static/management-shell.css b/extore/static/management-shell.css
index 65c51ee..c2bca8b 100644
--- a/extore/static/management-shell.css
+++ b/extore/static/management-shell.css
@@ -1,13 +1,13 @@
-.management-shell { --management-gap: 2rem; }
-.management-shell .admin-top { padding-bottom: 1.5rem; margin-bottom: 1.75rem; border-bottom: 1px dashed var(--dashed-line); align-items: start; }
+.management-shell { --management-gap: 3rem; }
+.management-shell .admin-top { padding-bottom: 1.5rem; margin-bottom: 1.75rem; align-items: start; border: 0; }
.management-shell .admin-top h1 { margin: 0 0 .35rem; font-size: 2rem; line-height: 1.35; }
.management-shell .admin-top p { margin: 0; font-size: .88rem; }
.management-shell .admin-top > button { min-height: 44px; flex: 0 0 auto; font-size: .85rem; }
.management-layout { display: grid; grid-template-columns: 184px minmax(0, 1fr); align-items: start; gap: var(--management-gap); }
-.management-navigation { min-width: 0; position: sticky; top: 1.5rem; }
-.management-navigation-group + .management-navigation-group { margin-top: 1.35rem; }
-.management-navigation-group h2 { margin: 0 0 .4rem .7rem; color: var(--muted); font-size: .75rem; font-weight: 500; }
-.management-navigation-group button { display: block; width: 100%; text-align: start; min-height: 44px; padding: .65rem .7rem; margin: .2rem 0; border-radius: 6px; background: transparent; color: var(--muted); font-size: .9rem; font-weight: 500; line-height: 1.45; }
+.management-navigation { min-width: 0; position: sticky; top: 1.5rem; max-height: calc(100dvh - 3rem); overflow: auto; scrollbar-width: thin; }
+.management-navigation-group + .management-navigation-group { margin-top: 1.5rem; }
+.management-navigation-group h2 { margin: 0 0 .5rem .8rem; color: var(--muted); font-size: .75rem; font-weight: 500; }
+.management-navigation-group button { display: block; width: 100%; text-align: start; min-height: 44px; padding: .7rem .8rem; margin: .2rem 0; border-radius: 10px; background: transparent; color: var(--muted); font-size: .9rem; font-weight: 500; line-height: 1.45; }
.management-navigation-group button:hover { background: var(--warm); color: var(--ink); }
.management-navigation-group button.active { background: var(--status-bg); color: var(--green); font-weight: 600; }
.management-mobile-navigation { display: none; }
@@ -21,18 +21,22 @@
.management-workspace :is(button, summary, select) { min-height: 44px; }
.management-workspace :is(input:not([type="checkbox"]), textarea, select) { font-size: 16px; }
@media (max-width: 980px) {
- .management-shell { --management-gap: 1.25rem; }
+ .management-shell { --management-gap: 1.75rem; }
.management-layout { grid-template-columns: 160px minmax(0, 1fr); }
}
@media (max-width: 860px) {
- .management-shell .admin-top { margin-bottom: 1.25rem; padding-bottom: 1.25rem; }
+ .management-shell .admin-top { margin-bottom: .75rem; padding-bottom: .75rem; }
.management-shell .admin-top h1 { font-size: 1.7rem; }
.management-layout { display: block; }
- .management-navigation { position: static; margin-bottom: 1.75rem; }
+ .management-navigation { position: sticky; top: 0; z-index: 10; margin-bottom: 1.75rem; padding-block: .75rem; background: var(--paper); max-height: calc(100dvh - 1rem); overflow: visible; }
.management-desktop-navigation { display: none; }
- .management-mobile-navigation { display: block; }
+ .management-desktop-navigation[data-open] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px 10px; margin-top: 14px; padding: 20px 12px; max-height: calc(100dvh - 140px); overflow: auto; overscroll-behavior: contain; background: var(--white); border-radius: 16px; box-shadow: 0 10px 30px rgb(0 0 0 / 10%); }
+ .management-navigation-group + .management-navigation-group { margin-top: 0; }
+ .management-mobile-navigation { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 12px; }
.management-mobile-navigation label { display: block; color: var(--muted); font-size: .8rem; margin-bottom: .4rem; }
- .management-mobile-navigation select { width: 100%; font-size: 16px; min-height: 46px; }
+ .management-mobile-navigation select { width: 100%; font-size: 16px; min-height: 46px; border-color: var(--line); border-radius: 10px; }
+ #management-menu-toggle { min-height: 46px; padding-inline: 16px; color: var(--green); background: var(--status-bg); border-radius: 10px; }
+ #management-menu-toggle[aria-expanded="true"] { outline: 2px solid var(--green); outline-offset: -2px; }
.management-workspace h2 { font-size: 1.4rem; }
}
@media (max-width: 420px) {
diff --git a/extore/static/preferences.js b/extore/static/preferences.js
index ba9cfc2..f6fad9a 100644
--- a/extore/static/preferences.js
+++ b/extore/static/preferences.js
@@ -3,6 +3,7 @@
"use strict";
const themes = new Set(["auto", "light", "dark"]);
+ const accents = new Set(["green", "blue", "violet", "rose", "amber", "graphite"]);
const languages = new Set(["auto", "zh-CN", "en"]);
const listeners = new Set();
let storage = null;
@@ -33,10 +34,12 @@
}
}
const themeValue = (value) => (themes.has(value) ? value : "auto");
+ const accentValue = (value) => (accents.has(value) ? value : "green");
const languageValue = (value) =>
languages.has(value) ? value : "auto";
let settings = {
theme: themeValue(read("extore_theme")),
+ accent: accentValue(read("extore_accent")),
language: languageValue(read("extore_language")),
};
@@ -70,6 +73,7 @@
let resolved = resolve();
let previous = {
theme: settings.theme,
+ accent: settings.accent,
language: settings.language,
resolvedTheme: resolved.theme,
resolvedLanguage: resolved.language,
@@ -79,6 +83,7 @@
const element = root.document?.documentElement;
if (!element) return;
element.dataset.theme = resolved.theme;
+ element.dataset.accent = settings.accent;
element.lang = resolved.language;
}
function snapshot() {
@@ -89,12 +94,14 @@
apply();
const next = {
theme: settings.theme,
+ accent: settings.accent,
language: settings.language,
resolvedTheme: resolved.theme,
resolvedLanguage: resolved.language,
};
if (
previous.theme === next.theme &&
+ previous.accent === next.accent &&
previous.language === next.language &&
previous.resolvedTheme === next.resolvedTheme &&
previous.resolvedLanguage === next.resolvedLanguage
@@ -115,11 +122,14 @@
if (event.storageArea && event.storageArea !== storage) return;
if (event.key === "extore_theme")
settings.theme = themeValue(event.newValue);
+ else if (event.key === "extore_accent")
+ settings.accent = accentValue(event.newValue);
else if (event.key === "extore_language")
settings.language = languageValue(event.newValue);
else if (event.key === null)
settings = {
theme: themeValue(read("extore_theme")),
+ accent: accentValue(read("extore_accent")),
language: languageValue(read("extore_language")),
};
else return;
@@ -144,6 +154,12 @@
write("extore_theme", value);
refresh();
},
+ setAccent(value) {
+ if (!accents.has(value)) throw new TypeError("Invalid accent preference");
+ settings.accent = value;
+ write("extore_accent", value);
+ refresh();
+ },
setLanguage(value) {
if (!languages.has(value))
throw new TypeError("Invalid language preference");
diff --git a/scripts/check_management_ui.py b/scripts/check_management_ui.py
new file mode 100644
index 0000000..b58ec59
--- /dev/null
+++ b/scripts/check_management_ui.py
@@ -0,0 +1,262 @@
+"""Render shipped pages with isolated API fixtures; never use a live account.
+
+Run: uv run --with playwright==1.57.0 python scripts/check_management_ui.py
+Install Chromium first with: uv run --with playwright==1.57.0 playwright install chromium
+"""
+
+import argparse
+import json
+import threading
+from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+from urllib.parse import urlparse
+
+from playwright.sync_api import sync_playwright
+
+STATIC = Path(__file__).resolve().parents[1] / "extore" / "static"
+AUTH = {
+ "configured": True,
+ "password_enabled": False,
+ "registration_enabled": True,
+ "role": "admin",
+ "shop_id": None,
+ "superadmin": True,
+ "session_id": "isolated-review-session",
+}
+FIXTURES = {
+ "/api/source": {},
+ "/api/auth/status": AUTH,
+ "/api/admin/products": [],
+ "/api/products": [],
+ "/api/admin/product-templates": [],
+ "/api/platform/shops": [
+ {
+ "id": "review-shop-one",
+ "name": "拾光工作室",
+ "email": "owner@example.test",
+ "verified": True,
+ "enabled": True,
+ },
+ {
+ "id": "review-shop-two",
+ "name": "文字与设计",
+ "email": "design@example.test",
+ "verified": False,
+ "enabled": True,
+ },
+ ],
+ "/api/platform/settings": {
+ "registration_enabled": False,
+ "smtp": {
+ "enabled": True,
+ "host": "smtp.example.test",
+ "sender": "store@example.test",
+ "from_name": "Extore",
+ "port": 587,
+ "mode": "starttls",
+ "password_configured": True,
+ },
+ },
+ "/api/auth/passkeys": [
+ {"id": "review-passkey", "name": "日常使用的设备", "created": 1791547200}
+ ],
+ "/api/auth/reauth/passkey/options": {
+ "challenge": "Y2hhbGxlbmdl",
+ "userVerification": "required",
+ "allowCredentials": [{"id": "a2V5", "type": "public-key"}],
+ },
+ "/api/upload-limits": {"max_file_bytes": 20971520},
+}
+
+
+class Handler(SimpleHTTPRequestHandler):
+ def translate_path(self, path):
+ relative = urlparse(path).path.removeprefix("/static/")
+ target = (STATIC / relative).resolve()
+ return str(
+ target
+ if target.is_relative_to(STATIC) and target.is_file()
+ else STATIC / "index.html"
+ )
+
+ def log_message(self, *_args):
+ pass
+
+
+def review(browser, origin, output, width, theme, language="zh-CN"):
+ context = browser.new_context(
+ viewport={"width": width, "height": 900},
+ locale=language,
+ reduced_motion="reduce",
+ color_scheme=theme,
+ )
+ requests, errors, shots = [], [], []
+ anonymous = False
+
+ def route_request(route):
+ request = route.request
+ if not request.url.startswith(origin + "/"):
+ route.abort()
+ return
+ path = urlparse(request.url).path
+ if not path.startswith("/api/"):
+ route.continue_()
+ return
+ requests.append(
+ {"path": path, "method": request.method, "headers": request.headers}
+ )
+ if path == "/api/auth/reauth/passkey/verify":
+ route.fulfill(
+ status=401, json={"detail": "所选 Passkey 不属于当前账号,请重新选择"}
+ )
+ elif path == "/api/auth/status" and anonymous:
+ route.fulfill(
+ json={**AUTH, "role": None, "superadmin": False, "session_id": None}
+ )
+ elif path in FIXTURES:
+ route.fulfill(json=FIXTURES[path])
+ else:
+ errors.append(f"Unexpected API: {path}")
+ route.fulfill(status=404, json={"detail": "Missing review fixture"})
+
+ context.route("**/*", route_request)
+ context.add_init_script("""Object.defineProperty(navigator, 'credentials', {value: {get: async () => ({
+ id: 'wrong-key', rawId: new Uint8Array([1]).buffer, type: 'public-key', getClientExtensionResults: () => ({}),
+ response: {clientDataJSON: new Uint8Array([1]).buffer, authenticatorData: new Uint8Array([2]).buffer,
+ signature: new Uint8Array([3]).buffer, userHandle: null}
+ })}});""")
+ page = context.new_page()
+ page.on("pageerror", lambda error: errors.append(str(error)))
+
+ def snap(name):
+ page.evaluate("window.scrollTo(0, 0)")
+ page.wait_for_timeout(80)
+ assert not page.evaluate("document.documentElement.scrollWidth > innerWidth"), (
+ f"Horizontal overflow: {name}/{width}"
+ )
+ assert not errors, errors
+ filename = f"{width}-{theme}-{language}-{name}.png"
+ page.screenshot(path=str(output / filename), full_page=True)
+ shots.append(filename)
+
+ def tab(name):
+ if width <= 860:
+ page.locator("#management-section").select_option(name)
+ else:
+ page.locator(f'[data-tab="{name}"]').click()
+ page.wait_for_timeout(80)
+
+ page.goto(origin + "/admin")
+ page.locator(".management-shell").wait_for()
+ snap("products")
+ if width <= 860:
+ page.locator("#management-menu-toggle").click()
+ assert (
+ page.locator("#management-menu-toggle").get_attribute("aria-expanded")
+ == "true"
+ )
+ assert page.locator('[data-tab="mail"]').is_visible()
+ assert page.locator('[data-tab="security"]').is_visible()
+ snap("directory")
+ page.locator('[data-tab="shops"]').click()
+ assert (
+ page.locator("#management-menu-toggle").get_attribute("aria-expanded")
+ == "false"
+ )
+ else:
+ tab("shops")
+ page.locator("#shop-name").fill("待确认的新店铺")
+ page.locator("#shop-email").fill("new@example.test")
+ snap("shops")
+ page.locator("#account-shop-form button[type=submit]").click()
+ page.locator("#account-fresh-passkey").click()
+ page.locator("#account-error").filter(has_text="不属于当前账号").wait_for()
+ assert page.locator("#shop-name").input_value() == "待确认的新店铺"
+ assert page.locator("#shop-email").input_value() == "new@example.test"
+ assert not any(
+ item["path"].startswith("/api/auth/login/")
+ or (item["path"] == "/api/platform/shops" and item["method"] == "POST")
+ for item in requests
+ )
+ for item in requests:
+ if item["path"].startswith("/api/auth/reauth/passkey/"):
+ assert item["headers"]["x-extore-shop-scope"] == "platform"
+ assert item["headers"]["x-extore-session-id"] == AUTH["session_id"]
+ snap("wrong-passkey")
+ page.locator("#account-fresh-cancel").click()
+ tab("mail")
+ page.locator("#mail-host").fill("draft.example.test")
+ snap("mail")
+ page.locator("#appearance summary").click()
+ page.locator("#theme").select_option(theme)
+ for accent in ["green", "blue", "violet", "rose", "amber", "graphite"]:
+ page.locator("#accent").select_option(accent)
+ assert page.locator("html").get_attribute("data-accent") == accent
+ assert page.locator("#mail-host").input_value() == "draft.example.test"
+ snap("appearance")
+ page.locator("#appearance summary").press("Escape")
+ assert not page.locator("#appearance").evaluate("node => node.open")
+ tab("security")
+ page.get_by_text("日常使用的设备", exact=True).wait_for()
+ snap("security")
+ page.reload()
+ assert page.locator("html").get_attribute("data-accent") == "graphite"
+ anonymous = True
+ page.goto(origin + "/account/login")
+ page.locator("#login-email").wait_for()
+ snap("login")
+ page.goto(origin + "/")
+ page.locator("#code").wait_for()
+ snap("home")
+ context.close()
+ return {
+ "width": width,
+ "theme": theme,
+ "language": language,
+ "screenshots": shots,
+ "errors": errors,
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output", type=Path, default=Path("output/ui-review"))
+ args = parser.parse_args()
+ args.output.mkdir(parents=True, exist_ok=True)
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ try:
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch()
+ try:
+ results = [
+ review(
+ browser,
+ f"http://127.0.0.1:{server.server_port}",
+ args.output,
+ width,
+ theme,
+ language,
+ )
+ for width, theme, language in [
+ (320, "dark", "zh-CN"),
+ (390, "light", "zh-CN"),
+ (768, "dark", "en"),
+ (1440, "light", "zh-CN"),
+ ]
+ ]
+ finally:
+ browser.close()
+ (args.output / "results.json").write_text(
+ json.dumps(results, ensure_ascii=False, indent=2)
+ )
+ print(
+ f"Reviewed {sum(len(result['screenshots']) for result in results)} screenshots across {len(results)} viewport/theme combinations."
+ )
+ finally:
+ server.shutdown()
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/account-ui.test.cjs b/tests/account-ui.test.cjs
index 6a7804e..9d8e282 100644
--- a/tests/account-ui.test.cjs
+++ b/tests/account-ui.test.cjs
@@ -102,7 +102,7 @@ function fixture(options = {}) {
const ui = context.window.ExtoreAccount;
const instance = ui.mount({ root, auth: options.auth || {}, mode: options.mode || "login", token: options.token || "", isCurrent: () => current,
language: options.language || "zh-CN",
- navigate: (url) => navigations.push(url), passkey: async () => { passkeyCalls++; return options.verified; }, onAuth: (auth) => adopted.push(auth),
+ navigate: (url) => navigations.push(url), passkey: async (...args) => { passkeyCalls++; options.onPasskey?.(...args); return options.verified; }, onAuth: (auth) => adopted.push(auth),
copyProcessorPrompt: options.copyProcessorPrompt,
api(url, body, method, extra) { return new Promise((resolve, reject) => requests.push({ url, body, method, extra, resolve, reject })); },
});
@@ -1101,19 +1101,20 @@ test("password change adopts only a new session for the verified original shop",
assert.doesNotMatch(p.root.innerHTML, /old-private-password|new-private-password/);
});
-test("fresh Passkey may replace the session but cannot change the target shop", async () => {
- const auth = { ...shopAuth, session_id: "old-session" };
- const verified = { ...auth, session_id: "new-session" };
- const p = fixture({ auth, verified, mode: "confirm" });
+test("fresh Passkey keeps the original session and rejects both session and shop changes", async () => {
+ const auth = { ...shopAuth, session_id: "current-session" };
+ const p = fixture({ auth, verified: auth, mode: "confirm" });
let called = 0;
p.instance.confirmFresh(async () => { called++; });
await p.node("#account-fresh-passkey").emit("click");
- assert.equal(called, 1); assert.equal(p.adopted[0].session_id, "new-session");
- const q = fixture({ auth, verified: { ...verified, shop_id: "shop-two" }, mode: "confirm" });
- q.instance.confirmFresh(async () => { called++; });
- await q.node("#account-fresh-passkey").emit("click");
- assert.equal(called, 1); assert.equal(q.adopted.length, 0);
- assert.match(q.node("#account-error").textContent, /账户或店铺已改变/);
+ assert.equal(called, 1); assert.equal(p.adopted[0].session_id, "current-session");
+ for (const verified of [{ ...auth, session_id: "different-session" }, { ...auth, shop_id: "shop-two" }, rootAuth]) {
+ const q = fixture({ auth, verified, mode: "confirm" });
+ q.instance.confirmFresh(async () => { called++; });
+ await q.node("#account-fresh-passkey").emit("click");
+ assert.equal(called, 1); assert.equal(q.adopted.length, 0);
+ assert.match(q.node("#account-error").textContent, /账户或店铺已改变/);
+ }
});
test("fresh confirmation displays the owner shop name and email as escaped, separately wrapping text", () => {
@@ -1155,3 +1156,19 @@ test("display metadata changes cannot replace the authenticated shop or affect s
assert.equal(q.adopted.length, 0);
assert.match(q.node("#account-error").textContent, /账户或店铺已改变/);
});
+
+
+test("root and merchant confirmations bind Passkey to the captured account and session", async () => {
+ for (const auth of [{ ...rootAuth, session_id: "root-session" }, { ...shopAuth, session_id: "shop-session" }]) {
+ let captured;
+ const p = fixture({ auth, verified: auth, mode: "confirm", onPasskey: (...args) => { captured = args; } });
+ let writes = 0;
+ p.instance.confirmFresh(async () => { writes++; });
+ await p.node("#account-fresh-passkey").emit("click");
+ assert.equal(captured[0], false);
+ assert.equal(captured[2].reauthenticate, true);
+ assert.equal(captured[2].expectedScope, auth.shop_id || "platform");
+ assert.equal(captured[2].expectedSessionId, auth.session_id);
+ assert.equal(writes, 1);
+ }
+});
diff --git a/tests/device-login.test.cjs b/tests/device-login.test.cjs
index e311d8f..2c0ce85 100644
--- a/tests/device-login.test.cjs
+++ b/tests/device-login.test.cjs
@@ -321,8 +321,8 @@ test("existing same-key CLI device can be restored with zero remaining bindings"
assert.equal(p.instance.state, "approved");
});
-test("real Account Passkey confirmation preserves updated session and authorization lifecycle", async () => {
- const fresh = { ...shopAuth, session_id: "fresh-real-account-session" };
+test("real Account Passkey confirmation preserves the current session and authorization lifecycle", async () => {
+ const fresh = { ...shopAuth };
const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }), value = await review(p);
await approvePrelude(p, value);
assert.equal(p.instance.state, "confirm");
@@ -330,9 +330,9 @@ test("real Account Passkey confirmation preserves updated session and authorizat
const pending = p.node("#account-fresh-passkey").emit("click"); await flush();
assert.equal(p.requests[6].url, "/auth/status");
assert.equal(p.requests[6].extra.expectedSessionId, fresh.session_id);
- const sessionBoundReview = { ...value, review_digest: "e".repeat(64) };
+ const sessionBoundReview = { ...value };
await resolve(p, 6, fresh); await resolve(p, 7, sessionBoundReview);
- assert.equal(p.requests[8].body.review_digest, "e".repeat(64));
+ assert.equal(p.requests[8].body.review_digest, value.review_digest);
await resolve(p, 8, { ok: true, status: "approved" }); await pending;
assert.equal(p.instance.state, "approved");
assert.equal(p.accounts[0].disposed, true);
@@ -366,8 +366,8 @@ test("real Account cancellation aborts in-flight Passkey without a later approva
assert.match(p.node("#device-error").textContent, /已取消/);
});
-test("real Passkey session rotation still stops changed device or permission metadata", async () => {
- const fresh = { ...shopAuth, session_id: "fresh-changed-session" };
+test("real Passkey confirmation still stops changed device or permission metadata", async () => {
+ const fresh = { ...shopAuth };
const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }), value = await review(p);
await approvePrelude(p, value);
const pending = p.node("#account-fresh-passkey").emit("click"); await flush();
@@ -457,16 +457,16 @@ test("active shop request freezes current products and allows explicit smaller s
});
test("active scope approval requires owner confirmation and only enters awaiting-claim state", async () => {
- const fresh = { ...shopAuth, session_id: "active-fresh-session" };
+ const fresh = { ...shopAuth };
const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh });
const value = await activeReview(p, activeOptions());
await approvePrelude(p, value);
assert.equal(p.instance.state, "confirm");
const pending = p.node("#account-fresh-passkey").emit("click"); await flush();
- const latest = { ...value, review_digest: "c".repeat(64) };
+ const latest = { ...value };
await resolve(p, 6, fresh); await resolve(p, 7, latest);
assert.equal(p.requests[8].url, "/manage/device/approve");
- assert.deepEqual(JSON.parse(JSON.stringify(p.requests[8].body)), { user_code: code, product_ids: value.selected.product_ids, permissions: value.selected.permissions, expires: value.selected.expires, review_digest: "c".repeat(64) });
+ assert.deepEqual(JSON.parse(JSON.stringify(p.requests[8].body)), { user_code: code, product_ids: value.selected.product_ids, permissions: value.selected.permissions, expires: value.selected.expires, review_digest: value.review_digest });
await resolve(p, 8, { ok: true, status: "approved" }); await pending;
assert.equal(p.instance.state, "approved");
assert.match(p.root.innerHTML, /已批准,等待 CLI 领取/);
@@ -586,7 +586,7 @@ test("scope review changes to current revision, future products, permissions or
});
test("fresh reauthentication cannot silently apply changed scope authorization", async () => {
- const fresh = { ...shopAuth, session_id: "scope-new-session" };
+ const fresh = { ...shopAuth };
const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh }), value = await activeReview(p, activeOptions());
await approvePrelude(p, value);
const pending = p.node("#account-fresh-passkey").emit("click"); await flush();
@@ -648,9 +648,9 @@ test("scope accepts the backend limit of 500 frozen current products and rejects
}
});
-test("fresh session rotation cannot hide a changed server snapshot in either approval flow", async () => {
+test("fresh confirmation cannot hide a changed server snapshot in either approval flow", async () => {
for (const scoped of [false, true]) {
- const fresh = { ...shopAuth, session_id: "snapshot-fresh-session" };
+ const fresh = { ...shopAuth };
const p = fixture({ auth: shopAuth, realAccount: true, passkey: async () => fresh });
const value = scoped ? await activeReview(p, activeOptions()) : await review(p);
await approvePrelude(p, value);
diff --git a/tests/management-copy.test.cjs b/tests/management-copy.test.cjs
index 92faea1..218f688 100644
--- a/tests/management-copy.test.cjs
+++ b/tests/management-copy.test.cjs
@@ -143,9 +143,9 @@ function page(options = {}) {
function find(selector, root = null) {
return findAll(selector, root)[0] || null;
}
- for (const id of ["app", "brand", "theme", "language", "header-context", "toast", "workspace"])
+ for (const id of ["app", "brand", "theme", "language", "accent", "appearance", "appearance-label", "appearance-note", "theme-label", "language-label", "accent-label", "header-context", "toast", "workspace"])
nodes.set(id, element(id));
- for (const [id, values] of [["theme", ["auto", "light", "dark"]], ["language", ["auto"]]]) {
+ for (const [id, values] of [["theme", ["auto", "light", "dark"]], ["language", ["auto"]], ["accent", ["green", "blue", "violet", "rose", "amber", "graphite"]]]) {
for (const value of values) {
const option = element(`${id}-${value}`, nodes.get(id));
option.attributes.value = value;
@@ -154,7 +154,7 @@ function page(options = {}) {
}
const originalFind = find;
const query = (selector) => {
- const option = selector.match(/^#(theme|language) option\[value=(\w+)\]$/);
+ const option = selector.match(/^#(theme|language|accent) option\[value=(\w+)\]$/);
return option ? nodes.get(`${option[1]}-${option[2]}`) : originalFind(selector);
};
const location = {
diff --git a/tests/owner-cli-integration.test.cjs b/tests/owner-cli-integration.test.cjs
index 6e9cf12..4357ec6 100644
--- a/tests/owner-cli-integration.test.cjs
+++ b/tests/owner-cli-integration.test.cjs
@@ -212,12 +212,12 @@ test("approval and QR modules load before app startup with revised assets", () =
assert.ok(owner >= 0 && account > owner && app > account);
assert.match(scripts[owner][1], /\bdefer\b/);
assert.match(scripts[owner][2], /\?v=20261007-shops$/);
- assert.match(scripts[app][2], /\?v=20261008-frontend-commerce$/);
+ assert.match(scripts[app][2], /\?v=20261010-bound-confirmation$/);
const device = scripts.findIndex((script) => script[2].startsWith("/static/device-login.js"));
const encoder = scripts.findIndex((script) => script[2].startsWith("/static/vendor/qrcodegen.js"));
const qr = scripts.findIndex((script) => script[2].startsWith("/static/totp-qr.js"));
assert.ok(encoder >= 0 && qr > encoder && account > qr && device > account && app > device);
- assert.match(scripts[account][2], /\?v=20261008-frontend-commerce$/);
+ assert.match(scripts[account][2], /\?v=20261010-bound-confirmation$/);
assert.match(scripts[device][2], /\?v=20261007-queue-design$/);
});
diff --git a/tests/passkey-confirmation-ui.test.cjs b/tests/passkey-confirmation-ui.test.cjs
new file mode 100644
index 0000000..718baff
--- /dev/null
+++ b/tests/passkey-confirmation-ui.test.cjs
@@ -0,0 +1,58 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const { appFixture, flush } = require("./app-fixture.cjs");
+
+function browser() {
+ const page = appFixture();
+ page.navigate("/admin");
+ let requested;
+ page.context.navigator.credentials = { async get(options) {
+ requested = options;
+ return { getClientExtensionResults: () => ({}), id: "a2V5", rawId: new Uint8Array([107, 101, 121]).buffer, type: "public-key",
+ response: { clientDataJSON: new Uint8Array([1]).buffer, authenticatorData: new Uint8Array([2]).buffer,
+ signature: new Uint8Array([3]).buffer, userHandle: null } };
+ } };
+ return { ...page, requested: () => requested };
+}
+
+test("confirmation sends only session-bound reauthentication endpoints and retains allowCredentials", async () => {
+ const page = browser();
+ const pending = page.context.passkey(false, null, { reauthenticate: true, expectedScope: "platform", expectedSessionId: "captured-session" });
+ assert.equal(page.requests[0].url, "/api/auth/reauth/passkey/options");
+ page.requests[0].respond({ challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "a2V5" }], userVerification: "required" });
+ await flush();
+ assert.equal(page.requested().publicKey.userVerification, "required");
+ assert.equal(page.requested().publicKey.allowCredentials[0].id.byteLength, 3);
+ assert.equal(page.requests[1].url, "/api/auth/reauth/passkey/verify");
+ for (const request of page.requests.slice(0, 2)) {
+ assert.equal(request.options.headers["X-Extore-Shop-Scope"], "platform");
+ assert.equal(request.options.headers["X-Extore-Session-ID"], "captured-session");
+ }
+ page.requests[1].respond({ ok: true }); await flush();
+ assert.equal(page.requests[2].url, "/api/auth/status");
+ page.requests[2].respond({ role: "admin", session_id: "captured-session" });
+ assert.equal((await pending).session_id, "captured-session");
+ assert.equal(page.requests.some((request) => request.url.includes("/login/")), false);
+});
+
+test("a failed confirmation stops before status or a business write and never retries as login", async () => {
+ const page = browser();
+ const pending = page.context.passkey(false, null, { reauthenticate: true, expectedScope: "shop-one", expectedSessionId: "current" });
+ const rejected = assert.rejects(pending, /wrong account/);
+ page.requests[0].respond({ challenge: "Y2hhbGxlbmdl", allowCredentials: [{ type: "public-key", id: "a2V5" }] }); await flush();
+ page.requests[1].reject(new Error("wrong account"));
+ await rejected;
+ assert.equal(page.requests.length, 2);
+ assert.ok(page.requests.every((request) => request.url.startsWith("/api/auth/reauth/passkey/")));
+});
+
+test("explicit sign-in still uses the discoverable login ceremony", async () => {
+ const page = browser();
+ const pending = page.context.passkey();
+ assert.equal(page.requests[0].url, "/api/auth/login/options");
+ page.requests[0].respond({ challenge: "Y2hhbGxlbmdl" }); await flush();
+ assert.equal(page.requests[1].url, "/api/auth/login/verify");
+ page.requests[1].respond({ role: "admin" }); await flush();
+ page.requests[2].respond({ role: "admin", shop_id: "chosen-account" });
+ assert.equal((await pending).shop_id, "chosen-account");
+});
diff --git a/tests/pipeline-authorizations.test.cjs b/tests/pipeline-authorizations.test.cjs
index bf5feef..71f8280 100644
--- a/tests/pipeline-authorizations.test.cjs
+++ b/tests/pipeline-authorizations.test.cjs
@@ -291,8 +291,8 @@ test("an upgrade between reviewing metadata and DELETE is version-pinned and res
assert.equal(p.requests.filter((request) => request.method === "DELETE").length, 1);
});
-test("real account Passkey verification rotates the session before the pinned revoke requests", async () => {
- const fresh = { ...shopAuth, session_id: "real-fresh-session" };
+test("real account Passkey verification keeps the same session for pinned revoke requests", async () => {
+ const fresh = { ...shopAuth };
const p = fixture({ realAccount: true, passkey: async () => fresh }), value = row(); await confirmation(p, value);
const pending = p.node("#account-fresh-passkey").emit("click"); await flush();
assert.equal(p.requests[2].url, "/auth/status"); assert.equal(p.requests[2].extra.expectedSessionId, fresh.session_id);
diff --git a/tests/preferences.test.cjs b/tests/preferences.test.cjs
index 0efc995..49e11e5 100644
--- a/tests/preferences.test.cjs
+++ b/tests/preferences.test.cjs
@@ -94,6 +94,7 @@ function browser(options = {}) {
test("defaults follow browser appearance and the first supported preferred language", () => {
const b = browser({ dark: true, languages: ["fr-FR", "en-GB", "zh-CN"] });
assert.deepEqual(plain(b.preferences.settings), {
+ accent: "green",
theme: "auto",
language: "auto",
});
@@ -137,7 +138,7 @@ test("automatic settings update live and notify only when preference state chang
assert.equal(notifications.length, 2);
assert.equal(b.element.lang, "zh-CN");
assert.deepEqual(notifications[1], {
- settings: { theme: "auto", language: "auto" },
+ settings: { theme: "auto", accent: "green", language: "auto" },
resolved: { theme: "dark", language: "zh-CN" },
});
b.preferences.setTheme("auto");
@@ -192,6 +193,7 @@ test("existing explicit language selections are preserved and invalid stored val
languages: ["en-US"],
});
assert.deepEqual(plain(b.preferences.settings), {
+ accent: "green",
theme: "auto",
language: "auto",
});
@@ -221,6 +223,7 @@ test("changes from another tab resolve immediately and unrelated storage is igno
b.storageCleared();
assert.equal(notified, 4);
assert.deepEqual(plain(b.preferences.settings), {
+ accent: "green",
theme: "auto",
language: "auto",
});
@@ -238,6 +241,7 @@ test("blocked storage does not prevent selecting preferences for the current pag
b.preferences.setTheme("light");
b.preferences.setLanguage("zh-CN");
assert.deepEqual(plain(b.preferences.settings), {
+ accent: "green",
theme: "light",
language: "zh-CN",
});
@@ -277,3 +281,46 @@ test("getter snapshots cannot mutate preferences and invalid choices are rejecte
assert.doesNotThrow(() => b.preferences.setTheme("dark"));
assert.equal(notified, 1);
});
+
+
+test("each accent persists independently of theme and language without inline styles", () => {
+ for (const accent of ["green", "blue", "violet", "rose", "amber", "graphite"]) {
+ const b = browser({ dark: true, languages: ["en-US"] });
+ const resolved = plain(b.preferences.resolved);
+ let notifications = 0;
+ b.preferences.subscribe(() => notifications++);
+ b.preferences.setAccent(accent);
+ assert.equal(b.preferences.settings.accent, accent);
+ assert.equal(b.element.dataset.accent, accent);
+ assert.equal(b.values.get("extore_accent"), accent);
+ assert.deepEqual(plain(b.preferences.resolved), resolved);
+ assert.equal(notifications, accent === "green" ? 0 : 1);
+ b.preferences.setAccent(accent);
+ assert.equal(notifications, accent === "green" ? 0 : 1);
+ const reloaded = browser({ stored: Object.fromEntries(b.values) });
+ assert.equal(reloaded.element.dataset.accent, accent);
+ }
+});
+
+test("accent storage changes and clear are live, invalid values fall back safely", () => {
+ const b = browser({ stored: { extore_accent: "invalid" } });
+ assert.equal(b.element.dataset.accent, "green");
+ assert.throws(() => b.preferences.setAccent("url(javascript:bad)"), /Invalid accent/);
+ assert.equal(b.values.get("extore_accent"), "invalid");
+ b.storageChanged("extore_accent", "violet");
+ assert.equal(b.element.dataset.accent, "violet");
+ b.storageChanged("extore_accent", "blue", {});
+ assert.equal(b.element.dataset.accent, "violet");
+ b.storageChanged("extore_accent", "unknown");
+ assert.equal(b.element.dataset.accent, "green");
+ b.preferences.setAccent("rose");
+ b.storageCleared();
+ assert.equal(b.element.dataset.accent, "green");
+ for (const options of [{ denyStorage: true }, { denyRead: true, denyWrite: true }]) {
+ const privateBrowser = browser(options);
+ privateBrowser.preferences.setAccent("blue");
+ assert.equal(privateBrowser.element.dataset.accent, "blue");
+ privateBrowser.themeChanged(true);
+ assert.equal(privateBrowser.element.dataset.accent, "blue");
+ }
+});
diff --git a/tests/test_passkey_reauth.py b/tests/test_passkey_reauth.py
new file mode 100644
index 0000000..e01bd93
--- /dev/null
+++ b/tests/test_passkey_reauth.py
@@ -0,0 +1,276 @@
+"""Confirmation must prove the current identity without replacing its session."""
+
+import time
+import uuid
+
+import pytest
+from fastapi.testclient import TestClient
+from starlette.responses import Response
+from test_passkeys import Authenticator, register
+from webauthn.helpers import bytes_to_base64url
+
+from extore.app import app
+from extore.config import ORIGIN
+from extore.db import db
+from extore.security import create_session, digest
+
+OPTIONS = "/api/auth/reauth/passkey/options"
+VERIFY = "/api/auth/reauth/passkey/verify"
+
+
+@pytest.fixture
+def accounts(owner):
+ opened = []
+ result = []
+ for index in range(3):
+ shop_id = str(uuid.uuid4()) if index else None
+ if shop_id:
+ with db() as c:
+ c.execute(
+ "INSERT INTO shops(id,name,email,created,verified) VALUES (?,?,?,?,1)",
+ (
+ shop_id,
+ f"Test shop {index}",
+ f"{shop_id}@example.test",
+ time.time(),
+ ),
+ )
+ cookie = create_session(c, Response(), "admin", shop_id=shop_id)
+ client = TestClient(app, base_url=ORIGIN, headers={"Origin": ORIGIN})
+ client.cookies.set("extore_session", cookie)
+ opened.append(client)
+ else:
+ client = owner
+ key = Authenticator()
+ register(client, key)
+ status = client.get("/api/auth/status").json()
+ cookie = client.cookies.get("extore_session")
+ with db() as c:
+ c.execute(
+ "UPDATE sessions SET auth_at=? WHERE digest=?", (1, digest(cookie))
+ )
+ result.append(
+ dict(client=client, key=key, shop_id=shop_id, status=status, cookie=cookie)
+ )
+ yield result
+ for client in opened:
+ client.close()
+
+
+def options(account):
+ response = account["client"].post(OPTIONS, json={})
+ assert response.status_code == 200, response.text
+ value = response.json()
+ assert value["userVerification"] == "required"
+ assert [item["id"] for item in value["allowCredentials"]] == [account["key"].id]
+ return value
+
+
+def assertion(account, challenge, **kwargs):
+ credential = account["key"].assertion(challenge, **kwargs)
+ handle = (
+ ("extore-shop:" + account["shop_id"]).encode()
+ if account["shop_id"]
+ else b"extore-owner"
+ )
+ credential["response"]["userHandle"] = bytes_to_base64url(handle)
+ return credential
+
+
+def session_row(account):
+ with db() as c:
+ return dict(
+ c.execute(
+ "SELECT * FROM sessions WHERE digest=?", (digest(account["cookie"]),)
+ ).fetchone()
+ )
+
+
+def assert_identity(account):
+ client = account["client"]
+ assert client.cookies.get("extore_session") == account["cookie"]
+ assert client.get("/api/auth/status").json() == account["status"]
+
+
+@pytest.mark.parametrize("index", [0, 1, 2])
+def test_correct_passkey_refreshes_only_current_session(accounts, index):
+ account = accounts[index]
+ before = session_row(account)
+ credential = assertion(account, options(account))
+ response = account["client"].post(VERIFY, json={"credential": credential})
+ assert response.status_code == 200, response.text
+ assert not any(
+ value.startswith("extore_session=")
+ for value in response.headers.get_list("set-cookie")
+ )
+ after = session_row(account)
+ assert after["auth_at"] > before["auth_at"]
+ assert after["auth_method"] == "passkey"
+ for key in before.keys() - {"auth_at", "auth_method"}:
+ assert after[key] == before[key], key
+ assert_identity(account)
+ assert (
+ account["client"].post(VERIFY, json={"credential": credential}).status_code
+ == 400
+ )
+
+
+@pytest.mark.parametrize("current,wrong", [(0, 1), (1, 0), (1, 2), (2, 1)])
+def test_wrong_account_passkey_fails_without_login_or_session_changes(
+ accounts, current, wrong
+):
+ account = accounts[current]
+ before = [session_row(item) for item in accounts]
+ credential = assertion(accounts[wrong], options(account))
+ response = account["client"].post(VERIFY, json={"credential": credential})
+ assert response.status_code == 401, response.text
+ assert "不属于当前账号" in response.text
+ assert [session_row(item) for item in accounts] == before
+ assert_identity(account)
+ # A failed signed response cannot be reused, even with the correct key.
+ assert (
+ account["client"].post(VERIFY, json={"credential": credential}).status_code
+ == 400
+ )
+ # The next explicit attempt can still confirm the original identity.
+ credential = assertion(account, options(account))
+ assert (
+ account["client"].post(VERIFY, json={"credential": credential}).status_code
+ == 200
+ )
+ assert_identity(account)
+
+
+@pytest.mark.parametrize(
+ "failure",
+ [
+ "signature",
+ "user-verification",
+ "user-handle",
+ "malformed-handle",
+ "origin",
+ "challenge",
+ ],
+)
+def test_invalid_assertions_cannot_refresh_and_are_consumed(accounts, failure):
+ account = accounts[0]
+ challenge = options(account)
+ before = session_row(account)
+ kwargs = {}
+ if failure == "signature":
+ kwargs["signer"] = accounts[1]["key"]
+ elif failure == "user-verification":
+ kwargs["verified"] = False
+ elif failure == "challenge":
+ challenge = {**challenge, "challenge": "b3RoZXItY2hhbGxlbmdl"}
+ credential = assertion(account, challenge, **kwargs)
+ if failure in {"user-handle", "malformed-handle"}:
+ credential["response"]["userHandle"] = (
+ "@@@" if failure == "malformed-handle" else "b3RoZXI"
+ )
+ elif failure == "origin":
+ import base64
+
+ value = credential["response"]["clientDataJSON"]
+ data = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
+ credential["response"]["clientDataJSON"] = bytes_to_base64url(
+ data.replace(ORIGIN.encode(), b"https://different.example.test")
+ )
+ response = account["client"].post(VERIFY, json={"credential": credential})
+ assert response.status_code == 401, response.text
+ assert session_row(account) == before
+ assert_identity(account)
+ assert (
+ account["client"].post(VERIFY, json={"credential": credential}).status_code
+ == 400
+ )
+
+
+def test_allow_list_assertion_without_user_handle_is_valid(accounts):
+ account = accounts[1]
+ credential = assertion(account, options(account))
+ credential["response"]["userHandle"] = None
+ response = account["client"].post(VERIFY, json={"credential": credential})
+ assert response.status_code == 200, response.text
+ assert_identity(account)
+
+
+@pytest.mark.parametrize(
+ "replacement", ["session", "expired", "revoked", "disabled-shop"]
+)
+def test_confirmation_requires_the_original_live_session(accounts, replacement):
+ account = accounts[1]
+ credential = assertion(account, options(account))
+ with db() as c:
+ if replacement == "session":
+ cookie = create_session(c, Response(), "admin", shop_id=account["shop_id"])
+ account["client"].cookies.set("extore_session", cookie)
+ elif replacement == "expired":
+ c.execute("UPDATE challenges SET expires=0")
+ elif replacement == "revoked":
+ c.execute(
+ "UPDATE sessions SET revoked=1 WHERE digest=?",
+ (digest(account["cookie"]),),
+ )
+ else:
+ c.execute("UPDATE shops SET enabled=0 WHERE id=?", (account["shop_id"],))
+ response = account["client"].post(VERIFY, json={"credential": credential})
+ assert response.status_code in {400, 401}, response.text
+ assert session_row(account)["auth_at"] == 1
+
+
+def test_login_and_confirmation_challenges_cannot_be_interchanged(accounts):
+ account = accounts[0]
+ credential = assertion(account, options(account))
+ response = account["client"].post(
+ "/api/auth/login/verify", json={"credential": credential}
+ )
+ assert response.status_code == 400
+ assert_identity(account)
+ challenge = account["client"].post("/api/auth/login/options", json={}).json()
+ assert not challenge.get("allowCredentials")
+ credential = assertion(account, challenge)
+ assert (
+ account["client"].post(VERIFY, json={"credential": credential}).status_code
+ == 400
+ )
+ assert_identity(account)
+ # Choosing an account remains intentional and supported on the login route.
+ credential = assertion(accounts[1], challenge)
+ response = account["client"].post(
+ "/api/auth/login/verify", json={"credential": credential}
+ )
+ assert response.status_code == 200, response.text
+ assert response.json()["shop_id"] == accounts[1]["shop_id"]
+
+
+def test_no_passkeys_does_not_offer_an_unrestricted_confirmation(owner):
+ before = owner.cookies.get("extore_session")
+ response = owner.post(OPTIONS, json={})
+ assert response.status_code == 409, response.text
+ assert owner.cookies.get("extore_session") == before
+ with db() as c:
+ assert c.execute("SELECT count(*) FROM challenges").fetchone()[0] == 0
+
+
+def test_confirmation_requires_authentication_and_captured_browser_scope(accounts):
+ with TestClient(app, base_url=ORIGIN, headers={"Origin": ORIGIN}) as anonymous:
+ assert anonymous.post(OPTIONS, json={}).status_code == 401
+ assert anonymous.post(VERIFY, json={"credential": {}}).status_code == 401
+ account = accounts[0]
+ for headers in [
+ {"X-Extore-Shop-Scope": accounts[1]["shop_id"]},
+ {"X-Extore-Session-ID": "stale-browser-session"},
+ ]:
+ assert (
+ account["client"].post(OPTIONS, json={}, headers=headers).status_code == 409
+ )
+ credential = assertion(account, options(account))
+ assert (
+ account["client"]
+ .post(VERIFY, json={"credential": credential}, headers=headers)
+ .status_code
+ == 409
+ )
+ assert session_row(account)["auth_at"] == 1
+ assert_identity(account)