crypto.randomUUID() 与 navigator.clipboard 只在安全上下文(https / localhost)里存在,
生产入口是纯 http,于是:
- 所有 POST/PATCH/DELETE 在拼 Idempotency-Key 时直接抛 TypeError,
登录页只显示"登录失败",GET 一切正常,所以这个坑一直没暴露
- 「复制邀请链接」永远回"浏览器不允许自动复制"
改成幂等键自己拼一个 v4(getRandomValues 不受安全上下文限制,退回 Math.random),
复制走 navigator.clipboard → document.execCommand("copy") 两级降级。
顺带说明:这是生产上"能看不能写"的真正原因,不是权限或接口问题。
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 生产只读核对:首页外壳、账号页、工作区页的宽度是否一致。
|
||||||
|
*
|
||||||
|
* 这个脚本**不写数据**:不设密码、不改昵称,只用验证码登录看界面。
|
||||||
|
* 生产是内测模式(任意手机号 + 123456),所以用真实账号也能进。
|
||||||
|
*/
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const WEB = process.env.WEB ?? "http://192.168.8.188:8200";
|
||||||
|
const PHONE = process.env.PHONE ?? "13605513683";
|
||||||
|
const CODE = process.env.CODE ?? "123456";
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath:
|
||||||
|
process.env.CHROME ??
|
||||||
|
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`,
|
||||||
|
});
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 960 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("console", (m) => m.type() === "error" && errors.push(m.text()));
|
||||||
|
page.on("request", (r) => r.url().includes("/api") && console.log("req:", r.method(), r.url()));
|
||||||
|
page.on("response", (r) => r.url().includes("/auth/") && console.log("resp:", r.status(), r.url()));
|
||||||
|
page.on("requestfailed", (r) => r.url().includes("/auth/") && console.log("reqfailed:", r.failure()?.errorText, r.url()));
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/login`);
|
||||||
|
await page.getByLabel("手机号").fill(PHONE);
|
||||||
|
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||||
|
await page.getByLabel("验证码").fill(CODE);
|
||||||
|
await page.getByRole("button", { name: "登录" }).click();
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
console.log("url after login:", page.url());
|
||||||
|
console.log("body:", (await page.locator("body").innerText()).replace(/\s+/g, " | ").slice(0, 400));
|
||||||
|
await page.waitForURL(`${WEB}/`, { timeout: 20_000 });
|
||||||
|
await page.waitForSelector("text=我的工作区");
|
||||||
|
|
||||||
|
const homeRail = await page.locator("aside").first().boundingBox();
|
||||||
|
console.log("首页侧栏宽度:", homeRail?.width);
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/prod-home.png" });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /我的备忘/ }).first().click();
|
||||||
|
await page.waitForURL(/\/w\//);
|
||||||
|
await page.waitForSelector("aside");
|
||||||
|
const wsRail = await page.locator("aside").first().boundingBox();
|
||||||
|
console.log("工作区侧栏宽度:", wsRail?.width, "差值:", Math.abs((wsRail?.width ?? 0) - (homeRail?.width ?? 0)));
|
||||||
|
|
||||||
|
await page.goto(`${WEB}/account`);
|
||||||
|
await page.waitForSelector("text=登录密码");
|
||||||
|
const accountRail = await page.locator("aside").first().boundingBox();
|
||||||
|
console.log("账号页侧栏宽度:", accountRail?.width);
|
||||||
|
const bodyText = await page.locator("main").innerText();
|
||||||
|
console.log("账号页文案片段:", bodyText.replace(/\n+/g, " | ").slice(0, 220));
|
||||||
|
await page.screenshot({ path: "e2e/artifacts/prod-account.png" });
|
||||||
|
|
||||||
|
console.log("控制台报错:", errors.length === 0 ? "无" : errors.slice(0, 3));
|
||||||
|
await browser.close();
|
||||||
+25
-2
@@ -48,6 +48,29 @@ export const session = {
|
|||||||
|
|
||||||
export const authExpired = new EventTarget();
|
export const authExpired = new EventTarget();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等键。
|
||||||
|
*
|
||||||
|
* 别直接用 `crypto.randomUUID()`:它只在**安全上下文**(https 或 localhost)里存在,
|
||||||
|
* 而内网生产入口是纯 http,那里 `crypto.randomUUID` 是 undefined,调它会把
|
||||||
|
* 所有写请求变成 "登录失败" 这种莫名其妙的报错——GET 没事,所以特别难发现。
|
||||||
|
* `getRandomValues` 不受这个限制,退回它自己拼一个 v4。
|
||||||
|
*/
|
||||||
|
function newIdempotencyKey(): string {
|
||||||
|
const c = globalThis.crypto;
|
||||||
|
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
if (typeof c?.getRandomValues === "function") {
|
||||||
|
c.getRandomValues(bytes);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
@@ -68,7 +91,7 @@ async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|||||||
|
|
||||||
const isWrite = method !== "GET";
|
const isWrite = method !== "GET";
|
||||||
if (isWrite) {
|
if (isWrite) {
|
||||||
headers["Idempotency-Key"] = opts.idempotencyKey ?? crypto.randomUUID();
|
headers["Idempotency-Key"] = opts.idempotencyKey ?? newIdempotencyKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch(`${BASE}${path}`, {
|
const resp = await fetch(`${BASE}${path}`, {
|
||||||
@@ -118,7 +141,7 @@ export async function uploadFile<T>(path: string, file: File): Promise<T> {
|
|||||||
const headers: Record<string, string> = { Accept: "application/json" };
|
const headers: Record<string, string> = { Accept: "application/json" };
|
||||||
const token = session.access;
|
const token = session.access;
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
headers["Idempotency-Key"] = crypto.randomUUID();
|
headers["Idempotency-Key"] = newIdempotencyKey();
|
||||||
|
|
||||||
const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form });
|
const resp = await fetch(`${BASE}${path}`, { method: "POST", headers, body: form });
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 复制文本。
|
||||||
|
*
|
||||||
|
* `navigator.clipboard` 只在**安全上下文**(https 或 localhost)里有,内网生产入口是
|
||||||
|
* 纯 http,那里它是 undefined——直接调会抛错,用户看到的却是"浏览器不允许自动复制"。
|
||||||
|
* 所以这里退回 `document.execCommand("copy")`:老,但在 http 下确实能用。
|
||||||
|
*/
|
||||||
|
export async function copyText(text: string): Promise<boolean> {
|
||||||
|
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// 权限被拒也走兜底,别直接判定失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = document.createElement("textarea");
|
||||||
|
area.value = text;
|
||||||
|
area.setAttribute("readonly", "");
|
||||||
|
area.style.position = "fixed";
|
||||||
|
area.style.top = "-1000px";
|
||||||
|
area.style.opacity = "0";
|
||||||
|
document.body.appendChild(area);
|
||||||
|
area.select();
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand("copy");
|
||||||
|
} catch {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
document.body.removeChild(area);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
useToast,
|
useToast,
|
||||||
} from "../components/ui";
|
} from "../components/ui";
|
||||||
import { ApiError, api } from "../lib/client";
|
import { ApiError, api } from "../lib/client";
|
||||||
|
import { copyText } from "../lib/clipboard";
|
||||||
import type { Invitation, Member, Page, Workspace } from "../lib/types";
|
import type { Invitation, Member, Page, Workspace } from "../lib/types";
|
||||||
|
|
||||||
const ROLE_LABEL: Record<string, string> = {
|
const ROLE_LABEL: Record<string, string> = {
|
||||||
@@ -94,14 +95,14 @@ export function WorkspacePage() {
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
async function copyLink() {
|
async function copyLink() {
|
||||||
try {
|
const ok = await copyText(inviteLink);
|
||||||
await navigator.clipboard.writeText(inviteLink);
|
if (!ok) {
|
||||||
setCopied(true);
|
|
||||||
toast("邀请链接已复制", "success");
|
|
||||||
window.setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch {
|
|
||||||
setError("浏览器不允许自动复制,请手动选中下面这行链接");
|
setError("浏览器不允许自动复制,请手动选中下面这行链接");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setCopied(true);
|
||||||
|
toast("邀请链接已复制", "success");
|
||||||
|
window.setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user