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 @@
-
+
+
+ 中国大陆 +86
+
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 };
+ }),
+);