From f6ab3c9087aaaf4a8b4182581ddc2b96e59df1c7 Mon Sep 17 00:00:00 2001 From: CieanYau Date: Fri, 6 Nov 2026 04:52:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=86=E7=A0=81=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E5=8F=AF=E8=AE=BF=E9=97=AE=E6=80=A7=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E6=96=B0=E6=89=8B=E6=9C=BA=E5=8F=B7=E4=B8=BA=E4=B8=AD?= =?UTF-8?q?=E5=9B=BD=E5=A4=A7=E9=99=86=E5=8C=BA=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/assets/styles/PhoneCodeField.CwyHXfML.css | 33 +- app/assets/styles/account.CTvLXxT_.css | 5 +- app/components/HostList.vue | 2 +- app/components/PasswordField.vue | 95 ++++ app/components/PhoneCodeField.vue | 179 +------ app/components/instance/InstanceAccess.vue | 3 +- .../instance/InstanceRenewDialog.vue | 2 +- app/config/primevue.js | 7 + app/lib/useVerifyCode.js | 9 +- app/pages/account/index.vue | 86 +--- app/pages/forgot-password.vue | 23 +- app/pages/login.vue | 12 +- app/pages/oauth/bind.vue | 23 +- app/pages/register.vue | 23 +- tests/review-online-auth.e2e.test.mjs | 452 ++++++++++++++++++ 15 files changed, 592 insertions(+), 362 deletions(-) create mode 100644 app/components/PasswordField.vue create mode 100644 tests/review-online-auth.e2e.test.mjs diff --git a/app/assets/styles/PhoneCodeField.CwyHXfML.css b/app/assets/styles/PhoneCodeField.CwyHXfML.css index 46b1b97..6b50664 100644 --- a/app/assets/styles/PhoneCodeField.CwyHXfML.css +++ b/app/assets/styles/PhoneCodeField.CwyHXfML.css @@ -1,35 +1,6 @@ -/* 手机号国家区号字段:区号选择、输入与字段错误说明。 */ -/* 导入方:app/components/PhoneCodeField.vue、app/pages/forgot-password.vue、app/pages/oauth/bind.vue、app/pages/register.vue。 */ +/* 固定手机号区号说明:只有静态文本,不继承输入框或国家选择器的交互外观。 */ .phone-code-field[data-v-24cb0489] { - gap: 6px; - display: grid; -} -.phone-code-field label[data-v-24cb0489] { color: var(--p-text-muted-color); - font-size: 0.875rem; -} -.phone-code-input[data-v-24cb0489] { - border: 1px solid - var(--p-inputtext-border-color, var(--p-content-border-color)); - border-radius: var(--p-inputtext-border-radius, 6px); - background: var(--p-inputtext-background, var(--p-content-background)); - align-items: center; - gap: 6px; - padding: 8px 12px; - display: flex; -} -.phone-code-input input[data-v-24cb0489] { - width: 100%; - min-width: 0; - color: inherit; font: inherit; - background: 0 0; - border: 0; - outline: 0; -} -.phone-code-input[data-v-24cb0489]:focus-within { - outline: 1px solid var(--p-primary-color); -} -small[data-v-24cb0489] { - color: var(--p-text-muted-color); + white-space: nowrap; } diff --git a/app/assets/styles/account.CTvLXxT_.css b/app/assets/styles/account.CTvLXxT_.css index d75c2c4..274231d 100644 --- a/app/assets/styles/account.CTvLXxT_.css +++ b/app/assets/styles/account.CTvLXxT_.css @@ -141,8 +141,9 @@ flex: 1; min-width: 180px; } -.code-select[data-v-8ca4eb3d] { - flex: 0 0 160px; +.inline[data-v-8ca4eb3d] > .phone-code-field { + flex: 0 0 auto; + min-width: 0; } .actions[data-v-8ca4eb3d] { border-top: 1px solid var(--p-content-border-color, #e2e8f0); diff --git a/app/components/HostList.vue b/app/components/HostList.vue index fda5456..67b8b2b 100644 --- a/app/components/HostList.vue +++ b/app/components/HostList.vue @@ -62,7 +62,7 @@ import PaymentDialog from "~/components/PaymentDialog.vue"; import { t as useListPage_t } from "~/lib/useListPage.js"; import { r as instance_status_r } from "~/lib/instance-status.js"; import { t as instance_status_t } from "~/lib/instance-status.js"; -import password from "primevue/password"; +import password from "~/components/PasswordField.vue"; import textarea from "primevue/textarea"; import menu from "primevue/menu"; import tag from "primevue/tag"; diff --git a/app/components/PasswordField.vue b/app/components/PasswordField.vue new file mode 100644 index 0000000..825ec18 --- /dev/null +++ b/app/components/PasswordField.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/app/components/PhoneCodeField.vue b/app/components/PhoneCodeField.vue index d7cbe56..3fd41e4 100644 --- a/app/components/PhoneCodeField.vue +++ b/app/components/PhoneCodeField.vue @@ -1,175 +1,8 @@ - + + diff --git a/app/components/instance/InstanceAccess.vue b/app/components/instance/InstanceAccess.vue index 5ffaad5..d4a76ad 100644 --- a/app/components/instance/InstanceAccess.vue +++ b/app/components/instance/InstanceAccess.vue @@ -44,7 +44,7 @@ import checkbox from "primevue/checkbox"; import OsIcon from "~/components/OsIcon.vue"; import OperationNotice from "~/components/OperationNotice.vue"; import OperatePasswordGate from "~/components/OperatePasswordGate.vue"; -import password from "primevue/password"; +import password from "~/components/PasswordField.vue"; import skeleton from "primevue/skeleton"; import { t as os_name_t } from "~/lib/os-name.js"; import { t as password_t } from "~/lib/password-2.js"; @@ -2048,6 +2048,7 @@ var xe = { "onUpdate:modelValue": (n[27] ||= (e) => (unref(B).password = e)), "input-id": `${unref(w)}-rescue-password`, + autocomplete: `new-password`, feedback: !1, toggleMask: ``, class: `grow`, diff --git a/app/components/instance/InstanceRenewDialog.vue b/app/components/instance/InstanceRenewDialog.vue index 9a13386..4dc05bc 100644 --- a/app/components/instance/InstanceRenewDialog.vue +++ b/app/components/instance/InstanceRenewDialog.vue @@ -31,7 +31,7 @@ import inputtext from "primevue/inputtext"; import select from "primevue/select"; import OperationNotice from "~/components/OperationNotice.vue"; import OperatePasswordGate from "~/components/OperatePasswordGate.vue"; -import password from "primevue/password"; +import password from "~/components/PasswordField.vue"; import skeleton from "primevue/skeleton"; import { n as renewal_quote_n } from "~/lib/renewal-quote.js"; import { r as renewal_quote_r } from "~/lib/renewal-quote.js"; diff --git a/app/config/primevue.js b/app/config/primevue.js index 3d0725c..aca82bb 100644 --- a/app/config/primevue.js +++ b/app/config/primevue.js @@ -15,6 +15,13 @@ const preset = definePreset(Aura, { ), colorScheme: { light: { + // 普通字号白字按钮至少4.5:1;浅色主色从blue-600起,hover/active继续加深,暗色令牌不变。 + primary: { + color: "{primary.600}", + contrastColor: "#ffffff", + hoverColor: "{primary.700}", + activeColor: "{primary.800}", + }, surface: Object.fromEntries( [0, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].map( (shade) => [shade, shade === 0 ? "#ffffff" : `{slate.${shade}}`], diff --git a/app/lib/useVerifyCode.js b/app/lib/useVerifyCode.js index c77c79b..740ddbb 100644 --- a/app/lib/useVerifyCode.js +++ b/app/lib/useVerifyCode.js @@ -49,15 +49,10 @@ function i(i) { email: t, }); else { - let e = String( - typeof i.phoneCode == `function` - ? i.phoneCode() - : (i.phoneCode ?? 86), - ).trim(); - if (!/^[1-9]\d{0,3}$/.test(e)) throw Error(`请输入有效的国际电话区号`); + // 此发送器只服务新输入手机号;已保存号码的安全验证走原后端账号契约,不在这里改写。 await a.post(`/phone/code`, { ...n, - phone_code: Number(e), + phone_code: 86, phone: t, }); } diff --git a/app/pages/account/index.vue b/app/pages/account/index.vue index 56535dc..fc2f5a6 100644 --- a/app/pages/account/index.vue +++ b/app/pages/account/index.vue @@ -49,7 +49,8 @@ import CaptchaField from "~/components/CaptchaField.vue"; import select from "primevue/select"; import ClientLevelDialog from "~/components/ClientLevelDialog.vue"; import { t as interval_t } from "~/lib/interval.js"; -import password from "primevue/password"; +import password from "~/components/PasswordField.vue"; +import PhoneCodeField from "~/components/PhoneCodeField.vue"; import skeleton from "primevue/skeleton"; import toggleswitch from "primevue/toggleswitch"; import { t as useCaptcha_t } from "~/lib/useCaptcha.js"; @@ -204,8 +205,7 @@ var Ce = { _t = { class: `field`, }, - vt = [`id`, `for`], - yt = [`id`], + vt = [`for`], bt = { class: `inline`, }, @@ -729,7 +729,6 @@ var Ce = { } } let q = reactive({ - phone_code: 86, newPhone: ``, code: ``, }), @@ -799,7 +798,7 @@ var Ce = { try { if (!q.newPhone.trim()) throw Error(`请输入新手机号`); (await jn(`phone`, `update`, { - phone_code: q.phone_code, + phone_code: 86, phone: q.newPhone.trim(), }), (O.value = `验证码已发送`)); @@ -815,7 +814,7 @@ var Ce = { (M(), (Y.value = !0)); try { (await b.put(`/account/phone`, { - phone_code: q.phone_code, + phone_code: 86, phone: q.newPhone, code: q.code, }), @@ -1406,6 +1405,7 @@ var Ce = { toggleMask: ``, fluid: ``, placeholder: `输入当前密码`, + autocomplete: `current-password`, }, null, 8, @@ -1617,6 +1617,7 @@ var Ce = { toggleMask: ``, fluid: ``, placeholder: `输入新密码`, + autocomplete: `new-password`, }, null, 8, @@ -1647,6 +1648,7 @@ var Ce = { toggleMask: ``, fluid: ``, placeholder: `再次输入新密码`, + autocomplete: `new-password`, }, null, 8, @@ -2068,88 +2070,18 @@ var Ce = { createElementVNode( `label`, { - id: `${unref(x)}-phone-label`, for: `${unref(x)}-new-phone`, }, `新手机号`, 8, vt, ), - createElementVNode( - `span`, - { - id: `${unref(x)}-phone-code-label`, - class: `sr-only`, - }, - `国际区号`, - 8, - yt, - ), createElementVNode( `div`, bt, [ createVNode( - g, - { - modelValue: - unref(q) - .phone_code, - "onUpdate:modelValue": - (r[19] ||= ( - e, - ) => - (unref( - q, - ).phone_code = - e)), - "aria-labelledby": `${unref(x)}-phone-label ${unref(x)}-phone-code-label`, - options: - unref(Zt), - optionLabel: `phone_code`, - optionValue: `phone_code`, - filter: ``, - class: `code-select`, - }, - { - option: withCtx( - ({ - option: t, - }) => [ - createTextVNode( - `+` + - toDisplayString( - t.phone_code, - ) + - ` ` + - toDisplayString( - t.name, - ), - 1, - ), - ], - ), - value: withCtx( - ({ - value: t, - }) => [ - createTextVNode( - `+` + - toDisplayString( - t, - ), - 1, - ), - ], - ), - _: 1, - }, - 8, - [ - `modelValue`, - `aria-labelledby`, - `options`, - ], + PhoneCodeField, ), createVNode( l, diff --git a/app/pages/forgot-password.vue b/app/pages/forgot-password.vue index 32dd7a3..5579228 100644 --- a/app/pages/forgot-password.vue +++ b/app/pages/forgot-password.vue @@ -36,7 +36,7 @@ import { n as auth_redirect_n } from "~/lib/auth-redirect.js"; import { t as auth_redirect_t } from "~/lib/auth-redirect.js"; import inputtext from "primevue/inputtext"; import CaptchaField from "~/components/CaptchaField.vue"; -import password from "primevue/password"; +import password from "~/components/PasswordField.vue"; import PhoneCodeField from "~/components/PhoneCodeField.vue"; import { t as useCaptcha_t } from "~/lib/useCaptcha.js"; import { t as composables_t } from "~/lib/composables-2.js"; @@ -94,7 +94,6 @@ var D = { code: ``, password: ``, re_password: ``, - phone_code: `86`, }), W = ref(``), G = ref(!1), @@ -120,7 +119,6 @@ var D = { action: `password_reset`, captcha: K, error: W, - phoneCode: () => U.phone_code, }); // 复用 password_reset 场景的验证码发送与倒计时,不在页面另写一套短信请求。 function fe() { @@ -140,8 +138,6 @@ var D = { if (!e) return (W.value = H.value === `email` ? `请输入邮箱` : `请输入手机号`); - if (H.value === `phone` && !/^[1-9]\d{0,3}$/.test(U.phone_code)) - return (W.value = `请输入有效的国际电话区号`); if (!U.code.trim()) return (W.value = `请输入验证码`); if (!U.password) return (W.value = `请输入新密码`); if (U.password !== U.re_password) @@ -151,7 +147,7 @@ var D = { (await z.post(`/account/password_reset`, { type: H.value, account: e, - phone_code: U.phone_code, + phone_code: `86`, code: U.code.trim(), password: U.password, re_password: U.re_password, @@ -205,20 +201,7 @@ var D = { }, [ unref(H) === `phone` - ? (openBlock(), - createBlock( - f, - { - key: 0, - modelValue: unref(U).phone_code, - "onUpdate:modelValue": (o[0] ||= (e) => - (unref(U).phone_code = e)), - disabled: unref(G) || unref(Q), - }, - null, - 8, - [`modelValue`, `disabled`], - )) + ? (openBlock(), createBlock(f, { key: 0 })) : createCommentVNode(``, !0), createElementVNode(`div`, O, [ (o[8] ||= createElementVNode( diff --git a/app/pages/login.vue b/app/pages/login.vue index 81e7074..fbb2e4a 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -15,7 +15,7 @@ import { NuxtLink } from "#components"; import Button from "primevue/button"; import InputText from "primevue/inputtext"; import Message from "primevue/message"; -import Password from "primevue/password"; +import Password from "~/components/PasswordField.vue"; import AuthProviders from "~/components/AuthProviders.vue"; import CaptchaField from "~/components/CaptchaField.vue"; import GenericOAuthProviders from "~/components/GenericOAuthProviders.vue"; @@ -42,7 +42,8 @@ const site = useSiteConfig(); const { config, error: configError } = site; const form = reactive({ account: "", password: "", code: "" }); const loginMode = ref("password"); -const phoneCode = ref("86"); +// 新输入的手机号统一为中国大陆,不从用户输入或地区选择器派生区号。 +const phoneCode = "86"; const errorMessage = ref(""); const submitting = ref(false); const authProviders = ref(null); @@ -115,7 +116,6 @@ const { action: "login", captcha, error: errorMessage, - phoneCode: () => phoneCode.value, }); const fieldsDisabled = computed(() => formLocked.value || sendingCode.value); const captchaBusy = computed( @@ -304,10 +304,6 @@ async function submitLogin() { errorMessage.value = "当前登录方式未开放,请选择其他方式"; return; } - if (isPhoneAccount.value && !/^[1-9]\d{0,3}$/.test(phoneCode.value)) { - errorMessage.value = "请输入有效的国际电话区号"; - return; - } if (loginMode.value === "code" && form.account.includes("@")) { errorMessage.value = "请输入手机号"; return; @@ -334,7 +330,7 @@ async function submitLogin() { token: captchaToken.value, type: loginMode.value, code: form.code.trim(), - phoneCode: isPhoneAccount.value ? phoneCode.value : "", + phoneCode: isPhoneAccount.value ? phoneCode : "", }); } diff --git a/app/pages/oauth/bind.vue b/app/pages/oauth/bind.vue index cadee6b..d1b1ed8 100644 --- a/app/pages/oauth/bind.vue +++ b/app/pages/oauth/bind.vue @@ -112,7 +112,6 @@ var ve = { F = auth_redirect_r(M.query.redirect), I = ref(`phone`), L = ref(``), - R = ref(`86`), z = ref(``), B = ref(!1), V = ref(!1), @@ -182,7 +181,6 @@ var ve = { action: `oauth`, captcha: q, error: H, - phoneCode: () => R.value, }), { needed: Ae, @@ -223,10 +221,6 @@ var ve = { H.value = `请先阅读并同意服务协议和隐私协议`; return; } - if (I.value === `phone` && !/^[1-9]\d{0,3}$/.test(R.value)) { - H.value = `请输入有效的国际电话区号`; - return; - } V.value = !0; try { let e = q.validationError?.(); @@ -238,7 +232,7 @@ var ve = { { type: I.value, account: L.value.trim(), - phone_code: R.value, + phone_code: `86`, code: z.value.trim(), ...(N && P ? { @@ -404,20 +398,7 @@ var ve = { ), createElementVNode(`div`, T, [ unref(I) === `phone` - ? (openBlock(), - createBlock( - u, - { - key: 0, - modelValue: unref(R), - "onUpdate:modelValue": (n[2] ||= (e) => - isRef(R) ? (R.value = e) : null), - disabled: unref(V) || unref(J), - }, - null, - 8, - [`modelValue`, `disabled`], - )) + ? (openBlock(), createBlock(u, { key: 0 })) : createCommentVNode(``, !0), createVNode( g, diff --git a/app/pages/register.vue b/app/pages/register.vue index 3af8d6f..d477acb 100644 --- a/app/pages/register.vue +++ b/app/pages/register.vue @@ -43,7 +43,7 @@ import inputtext from "primevue/inputtext"; import CaptchaField from "~/components/CaptchaField.vue"; import select from "primevue/select"; import checkbox from "primevue/checkbox"; -import password from "primevue/password"; +import password from "~/components/PasswordField.vue"; import textarea from "primevue/textarea"; import PhoneCodeField from "~/components/PhoneCodeField.vue"; import { i as product_fields_i } from "~/lib/product-fields.js"; @@ -209,7 +209,6 @@ var ve = { username: ``, password: ``, re_password: ``, - phone_code: `86`, customfield: {}, }), I = ref(``), @@ -264,7 +263,6 @@ var ve = { action: `register`, captcha: R, error: I, - phoneCode: () => F.phone_code, }); // 仅对已开放且需要验证码的注册方式发送验证码,倒计时与验证码挑战由公共发送器管理。 function ze() { @@ -291,8 +289,6 @@ var ve = { if (!e) return (I.value = P.value === `email` ? `请输入邮箱` : `请输入手机号`); - if (P.value === `phone` && !/^[1-9]\d{0,3}$/.test(F.phone_code)) - return (I.value = `请输入有效的国际电话区号`); if (K.value && !F.code.trim()) return (I.value = `请输入验证码`); if (!F.password) return (I.value = `请输入密码`); if (F.password !== F.re_password) @@ -308,7 +304,7 @@ var ve = { (await w.post(`/register`, { type: P.value, account: e, - phone_code: F.phone_code, + phone_code: `86`, code: K.value ? F.code.trim() : ``, username: F.username.trim(), password: F.password, @@ -455,20 +451,7 @@ var ve = { ), ]), unref(P) === `phone` - ? (openBlock(), - createBlock( - m, - { - key: 3, - modelValue: unref(F).phone_code, - "onUpdate:modelValue": (a[2] ||= (e) => - (unref(F).phone_code = e)), - disabled: unref(L) || unref(Z), - }, - null, - 8, - [`modelValue`, `disabled`], - )) + ? (openBlock(), createBlock(m, { key: 3 })) : createCommentVNode(``, !0), createElementVNode(`div`, D, [ createElementVNode( diff --git a/tests/review-online-auth.e2e.test.mjs b/tests/review-online-auth.e2e.test.mjs new file mode 100644 index 0000000..cc8a8c8 --- /dev/null +++ b/tests/review-online-auth.e2e.test.mjs @@ -0,0 +1,452 @@ +/** 认证与手机号回归仅经生产页面和本地 HTTP 边界验证,不请求真实短信或修改线上账号。 */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + appOrigin, + backendOrigin, + eventually, + fixtureRequest, + login, + scenario, +} from "./helpers/console-browser.mjs"; + +async function authBoundary(page, overrides = {}) { + // 外部响应使用既有接口信封;只捕获本组声明的接口,不给缺失契约统一返回成功。 + const observed = []; + const replies = { + "/register": {}, + "/account/password_reset": {}, + "/oauth/token": { url: "/oauth/bind" }, + "/oauth/client/bind": { jwt: "e2e-session-token" }, + "/country": { + list: [ + { id: 1, name: "中国", name_zh: "中国", phone_code: 86 }, + { id: 2, name: "美国", name_zh: "美国", phone_code: 1 }, + ], + }, + "/account/phone/old": {}, + "/account/phone": {}, + ...overrides, + }; + await page + .context() + .route(`${backendOrigin}/console/v1/**`, async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname.replace( + /^\/console\/v1/, + "", + ); + if (!Object.hasOwn(replies, path)) return route.fallback(); + observed.push({ + path, + method: request.method(), + body: request.postDataJSON(), + }); + return route.fulfill({ + json: { status: 200, msg: "", data: replies[path] }, + headers: { "Access-Control-Allow-Origin": "*" }, + }); + }); + return observed; +} + +async function fixedPhonePrefix(page) { + // 固定区号必须是文本,而非可编辑、可选择、可触发国家列表的控件。 + assert.equal( + await page.locator('input[autocomplete="tel-country-code"]').count(), + 0, + ); + assert.equal( + await page.getByRole("combobox", { name: /国际区号/ }).count(), + 0, + ); + await page.getByText("中国大陆 +86", { exact: true }).waitFor(); +} + +async function passwordKeyboard(page, field) { + await field.fill("local-only-password"); + await field.focus(); + await page.keyboard.press("Tab"); + const owner = field.locator(".."); + const show = owner.getByRole("button", { name: "显示密码", exact: true }); + assert.equal(await show.evaluate((e) => e === document.activeElement), true); + assert.equal(await show.getAttribute("aria-pressed"), "false"); + await page.keyboard.press("Space"); + assert.equal(await field.getAttribute("type"), "text"); + const hide = owner.getByRole("button", { name: "隐藏密码", exact: true }); + assert.equal(await hide.getAttribute("aria-pressed"), "true"); + await page.keyboard.press("Enter"); + assert.equal(await field.getAttribute("type"), "password"); + assert.equal(await field.inputValue(), "local-only-password"); +} + +for (const [path, labels, autocomplete] of [ + ["/login", ["密码"], "current-password"], + ["/register", ["密码 *", "确认密码 *"], "new-password"], + ["/forgot-password", ["新密码 *", "确认新密码 *"], "new-password"], +]) { + test( + `password manager semantics reach native inputs on ${path}`, + { timeout: 20000 }, + (t) => + scenario(t, {}, async (page) => { + await authBoundary(page); + await page.goto(appOrigin + path); + for (const label of labels) { + const field = page.getByLabel(label, { exact: true }); + await field.waitFor(); + assert.equal(await field.getAttribute("autocomplete"), autocomplete); + } + return { + nativePasswordAutocomplete: autocomplete, + fields: labels.length, + }; + }), + ); +} + +test( + "password visibility is keyboard operable without submitting the form", + { timeout: 20000 }, + (t) => + scenario(t, {}, async (page) => { + await page.goto(`${appOrigin}/login`); + await passwordKeyboard(page, page.getByLabel("密码", { exact: true })); + assert.equal( + (await fixtureRequest("requests")).some((r) => r.path === "/login"), + false, + ); + return { keyboardShowHide: true, noAccidentalSubmit: true }; + }), +); + +async function contrast(locator) { + return locator.evaluate((element) => { + const style = getComputedStyle(element); + const rgb = (value) => + value + .match(/[\d.]+/g) + .slice(0, 3) + .map(Number); + const light = (color) => + rgb(color) + .map((channel) => { + const v = channel / 255; + return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }) + .reduce( + (sum, value, i) => sum + value * [0.2126, 0.7152, 0.0722][i], + 0, + ); + const fg = light(style.color), + bg = light(style.backgroundColor); + return (Math.max(fg, bg) + 0.05) / (Math.min(fg, bg) + 0.05); + }); +} + +test( + "primary auth actions stay readable in normal hover and focus states across themes", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await page.goto(`${appOrigin}/login`); + const submit = page.getByRole("button", { name: "登录", exact: true }); + await eventually( + () => submit.isEnabled(), + Boolean, + "Auth configuration must finish loading", + ); + const results = []; + for (const theme of ["light", "dark"]) { + await page.emulateMedia({ colorScheme: theme }); + await page.mouse.move(1, 1); + await submit.blur(); + await page.waitForTimeout(200); + assert.ok( + (await contrast(submit)) >= 4.5, + `${theme} normal action text contrast`, + ); + await submit.hover(); + await page.waitForTimeout(200); + assert.ok( + (await contrast(submit)) >= 4.5, + `${theme} hovered action text contrast`, + ); + await page.mouse.move(1, 1); + await submit.focus(); + await page.waitForTimeout(200); + assert.ok( + (await contrast(submit)) >= 4.5, + `${theme} focused action text contrast`, + ); + results.push({ theme, normalHoverFocusReadable: true }); + } + return { themes: results }; + }), +); + +test( + "phone registration uses fixed mainland prefix for SMS and registration", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const writes = await authBoundary(page); + await page.goto(`${appOrigin}/register`); + await page.getByLabel("手机号", { exact: true }).waitFor(); + await fixedPhonePrefix(page); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page + .getByRole("button", { name: "获取验证码", exact: true }) + .click(); + await eventually( + () => fixtureRequest("requests"), + (r) => r.some((e) => e.path === "/phone/code"), + "Registration SMS must reach HTTP", + ); + const sms = (await fixtureRequest("requests")).find( + (r) => r.path === "/phone/code", + ); + assert.equal(sms.body.phone_code, 86); + await page.getByLabel("验证码 *", { exact: true }).fill("123456"); + await page + .getByLabel("密码 *", { exact: true }) + .fill("new-local-password"); + await page + .getByLabel("确认密码 *", { exact: true }) + .fill("new-local-password"); + await page.getByRole("button", { name: "创建账户", exact: true }).click(); + await page.waitForURL((u) => u.pathname === "/login"); + assert.equal( + String(writes.find((r) => r.path === "/register").body.phone_code), + "86", + ); + assert.equal( + writes.some((r) => r.path === "/country"), + false, + ); + return { + smsPrefix: 86, + registrationPrefix: 86, + countryPickerAbsent: true, + }; + }), +); + +test( + "phone password recovery uses fixed mainland prefix", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const writes = await authBoundary(page); + await page.goto(`${appOrigin}/forgot-password`); + await page.getByLabel("手机号", { exact: true }).waitFor(); + await fixedPhonePrefix(page); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page + .getByRole("button", { name: "获取验证码", exact: true }) + .click(); + await eventually( + () => fixtureRequest("requests"), + (r) => r.some((e) => e.path === "/phone/code"), + "Recovery SMS must reach HTTP", + ); + assert.equal( + (await fixtureRequest("requests")).find((r) => r.path === "/phone/code") + .body.phone_code, + 86, + ); + await page.getByLabel("验证码 *", { exact: true }).fill("123456"); + await page + .getByLabel("新密码 *", { exact: true }) + .fill("new-local-password"); + await page + .getByLabel("确认新密码 *", { exact: true }) + .fill("new-local-password"); + await page.getByRole("button", { name: "重置密码", exact: true }).click(); + await page.waitForURL( + (u) => u.pathname === "/login" && u.searchParams.get("reset") === "1", + ); + assert.equal( + String( + writes.find((r) => r.path === "/account/password_reset").body + .phone_code, + ), + "86", + ); + return { smsPrefix: 86, resetPrefix: 86 }; + }), +); + +test( + "OAuth phone binding uses fixed mainland prefix after external session verification", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + const writes = await authBoundary(page); + await page.goto(`${appOrigin}/oauth/bind?redirect=/security/ssh-keys`); + await page.getByLabel("手机号", { exact: true }).waitFor(); + await fixedPhonePrefix(page); + await page.getByLabel("手机号", { exact: true }).fill("13800138000"); + await page + .getByRole("button", { name: "获取验证码", exact: true }) + .click(); + await eventually( + () => fixtureRequest("requests"), + (r) => r.some((e) => e.path === "/phone/code"), + "Binding SMS must reach HTTP", + ); + assert.equal( + (await fixtureRequest("requests")).find((r) => r.path === "/phone/code") + .body.phone_code, + 86, + ); + await page.getByLabel("验证码", { exact: true }).fill("123456"); + await page.getByRole("checkbox").check(); + await page.getByRole("button", { name: "确认绑定", exact: true }).click(); + await page.waitForURL((u) => u.pathname === "/security/ssh-keys"); + assert.equal( + String( + writes.find((r) => r.path === "/oauth/client/bind").body.phone_code, + ), + "86", + ); + return { authorizedBeforeBind: true, smsPrefix: 86, bindingPrefix: 86 }; + }), +); + +for (const existingPhone of [false, true]) { + test( + `account ${existingPhone ? "change" : "new"} phone uses fixed prefix without changing stored phone or address countries`, + { timeout: 30000 }, + (t) => + scenario(t, {}, async (page) => { + const writes = await authBoundary(page, { + "/account": { + account: { + id: 1, + username: "audit-user", + email: "audit@example.invalid", + phone: existingPhone ? "2025550100" : "", + phone_code: 1, + country_id: 2, + }, + }, + }); + await login(page, "/account?tab=phone"); + const panel = page.getByRole("tabpanel", { + name: "手机号", + exact: true, + }); + await panel.waitFor(); + if (existingPhone) { + assert.match(await panel.innerText(), /\+1 /); + await panel + .getByRole("button", { name: "获取验证码", exact: true }) + .click(); + await eventually( + () => fixtureRequest("requests"), + (r) => r.some((e) => e.path === "/phone/code"), + "Old-phone SMS must reach HTTP", + ); + const oldSms = (await fixtureRequest("requests")).find( + (r) => r.path === "/phone/code", + ); + assert.equal(oldSms.body.action, "verify"); + assert.equal( + "phone_code" in oldSms.body, + false, + "Old-phone verification must use the saved backend number", + ); + await panel + .getByLabel("原手机验证码", { exact: true }) + .fill("123456"); + await panel + .getByRole("button", { name: "下一步", exact: true }) + .click(); + } + await panel + .getByRole("textbox", { name: "新手机号", exact: true }) + .waitFor(); + await fixedPhonePrefix(page); + await panel + .getByRole("textbox", { name: "新手机号", exact: true }) + .fill("13800138000"); + await panel + .getByRole("button", { name: "获取验证码", exact: true }) + .click(); + await eventually( + () => fixtureRequest("requests"), + (r) => + r.some( + (e) => e.path === "/phone/code" && e.body.action === "update", + ), + "New-phone SMS must reach HTTP", + ); + assert.equal( + (await fixtureRequest("requests")).find( + (r) => r.path === "/phone/code" && r.body.action === "update", + ).body.phone_code, + 86, + ); + await panel.getByLabel("新手机验证码", { exact: true }).fill("123456"); + await panel + .getByRole("button", { name: "保存变更", exact: true }) + .click(); + await eventually( + () => writes.find((r) => r.path === "/account/phone"), + Boolean, + "New phone must reach update HTTP", + ); + assert.equal( + writes.find((r) => r.path === "/account/phone").body.phone_code, + 86, + ); + await page.getByRole("tab", { name: "资料", exact: true }).click(); + const country = page.getByRole("combobox", { + name: "国家/地区", + exact: true, + }); + await country.click(); + await page.getByRole("option", { name: "中国", exact: true }).waitFor(); + await page.getByRole("option", { name: "美国", exact: true }).waitFor(); + return { + phonePrefix: 86, + oldPhonePreserved: true, + addressCountriesStillSelectable: true, + }; + }), + ); +} + +test( + "account password editor shares native semantics and keyboard visibility", + { timeout: 25000 }, + (t) => + scenario(t, {}, async (page) => { + await authBoundary(page); + await login(page, "/account?tab=password"); + const panel = page.getByRole("tabpanel", { name: "密码", exact: true }); + await panel.waitFor(); + const current = panel + .getByLabel("当前密码", { exact: true }) + .and(panel.locator("input")); + assert.equal( + await current.getAttribute("autocomplete"), + "current-password", + ); + assert.equal( + await panel + .getByLabel("新密码", { exact: true }) + .getAttribute("autocomplete"), + "new-password", + ); + assert.equal( + await panel + .getByLabel("确认新密码", { exact: true }) + .getAttribute("autocomplete"), + "new-password", + ); + await passwordKeyboard(page, current); + return { nativeSemantics: true, sharedKeyboardShowHide: true }; + }), +);