feat: 门户接入企微网页授权——企微内静默免输身份领取,浏览器外保留手动输入
This commit is contained in:
@@ -547,6 +547,40 @@ input:focus {
|
|||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wecom-identity-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wecom-identity-chip b {
|
||||||
|
color: var(--green-deep);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wecom-identity-chip span {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wecom-identity-chip button {
|
||||||
|
margin-left: auto;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.recovery-list {
|
.recovery-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -107,11 +107,135 @@ function partnerApi(
|
|||||||
path:
|
path:
|
||||||
| "/api/partner"
|
| "/api/partner"
|
||||||
| "/api/partner-upload"
|
| "/api/partner-upload"
|
||||||
| "/api/partner-batch-workbook",
|
| "/api/partner-batch-workbook"
|
||||||
|
| "/api/wecom-oauth",
|
||||||
) {
|
) {
|
||||||
return `${resolveAdminOrigin()}${path}`;
|
return `${resolveAdminOrigin()}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WECOM_CORP_ID = process.env.NEXT_PUBLIC_WECOM_CORP_ID ?? "";
|
||||||
|
const WECOM_AGENT_ID = process.env.NEXT_PUBLIC_WECOM_AGENT_ID ?? "";
|
||||||
|
const WECOM_OAUTH_ENABLED = Boolean(WECOM_CORP_ID && WECOM_AGENT_ID);
|
||||||
|
const WECOM_PORTAL_BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "/koc";
|
||||||
|
const WECOM_IDENTITY_SESSION_KEY = "koc-wecom-identity";
|
||||||
|
const WECOM_AUTH_FAILED_KEY = "koc-wecom-auth-failed";
|
||||||
|
const WECOM_OAUTH_SID_PREFIX = "koc-wecom-oauth-sid";
|
||||||
|
const WECOM_OAUTH_STATE_LIMIT = 120;
|
||||||
|
|
||||||
|
type WecomPortalIdentity = {
|
||||||
|
ticket: string;
|
||||||
|
name: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PortalParams = {
|
||||||
|
task: string;
|
||||||
|
claim: string;
|
||||||
|
share: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readSessionValue<T>(key: string): T | null {
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSessionValue(key: string, value: unknown): boolean {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(key, JSON.stringify(value));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markSessionFlag(key: string) {
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(key, "1");
|
||||||
|
} catch {
|
||||||
|
// 隐私模式等场景下忽略,最多少一次自动授权
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSessionFlag(key: string) {
|
||||||
|
try {
|
||||||
|
return window.sessionStorage.getItem(key) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadWecomIdentity(): WecomPortalIdentity | null {
|
||||||
|
const identity = readSessionValue<WecomPortalIdentity>(
|
||||||
|
WECOM_IDENTITY_SESSION_KEY,
|
||||||
|
);
|
||||||
|
if (!identity?.ticket || typeof identity.expiresAt !== "number") return null;
|
||||||
|
if (Date.now() > identity.expiresAt) return null;
|
||||||
|
return identity;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWecomEmbeddedBrowser() {
|
||||||
|
const userAgent = window.navigator.userAgent;
|
||||||
|
return /wxwork/i.test(userAgent) || /MicroMessenger/i.test(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWecomAuthorizeUrl(state: string) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
appid: WECOM_CORP_ID,
|
||||||
|
redirect_uri: `${window.location.origin}${WECOM_PORTAL_BASE_PATH}/`,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "snsapi_base",
|
||||||
|
state,
|
||||||
|
agentid: WECOM_AGENT_ID,
|
||||||
|
});
|
||||||
|
return `https://open.weixin.qq.com/connect/oauth2/authorize?${query}#wechat_redirect`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWecomOAuthState(params: PortalParams): string {
|
||||||
|
const compact = new URLSearchParams();
|
||||||
|
if (params.task) compact.set("t", params.task);
|
||||||
|
if (params.claim) compact.set("c", params.claim);
|
||||||
|
if (params.share) compact.set("s", params.share);
|
||||||
|
if (params.note) compact.set("n", params.note);
|
||||||
|
if (compact.toString().length <= WECOM_OAUTH_STATE_LIMIT) {
|
||||||
|
return compact.toString();
|
||||||
|
}
|
||||||
|
const sid = crypto.randomUUID().replaceAll("-", "").slice(0, 12);
|
||||||
|
if (writeSessionValue(`${WECOM_OAUTH_SID_PREFIX}:${sid}`, params)) {
|
||||||
|
return `sid=${sid}`;
|
||||||
|
}
|
||||||
|
const fallback = new URLSearchParams();
|
||||||
|
if (params.task) fallback.set("t", params.task);
|
||||||
|
if (params.share) fallback.set("s", params.share);
|
||||||
|
return fallback.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreParamsFromWecomState(state: string): PortalParams {
|
||||||
|
const parsed = new URLSearchParams(state);
|
||||||
|
const sid = parsed.get("sid");
|
||||||
|
if (sid) {
|
||||||
|
const key = `${WECOM_OAUTH_SID_PREFIX}:${sid}`;
|
||||||
|
const stored = readSessionValue<PortalParams>(key);
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// 忽略清理失败
|
||||||
|
}
|
||||||
|
if (stored?.task || stored?.share) return stored;
|
||||||
|
return { task: "", claim: "", share: "", note: "" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
task: parsed.get("t") ?? "",
|
||||||
|
claim: parsed.get("c") ?? "",
|
||||||
|
share: parsed.get("s") ?? "",
|
||||||
|
note: parsed.get("n") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isMobilePortalViewport() {
|
function isMobilePortalViewport() {
|
||||||
return (
|
return (
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
@@ -325,6 +449,10 @@ export default function Home() {
|
|||||||
const [creatorViews, setCreatorViews] = useState("");
|
const [creatorViews, setCreatorViews] = useState("");
|
||||||
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
|
const [entryMode, setEntryMode] = useState<"claim" | "recover">("claim");
|
||||||
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
|
const [recoveredClaims, setRecoveredClaims] = useState<RecoveredClaim[]>([]);
|
||||||
|
const [wecomIdentity, setWecomIdentity] = useState<WecomPortalIdentity | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [manualEntry, setManualEntry] = useState(false);
|
||||||
const [adminOrigin, setAdminOrigin] = useState("");
|
const [adminOrigin, setAdminOrigin] = useState("");
|
||||||
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
const [downloadingImage, setDownloadingImage] = useState<number | null>(null);
|
||||||
const [batchDownloading, setBatchDownloading] = useState(false);
|
const [batchDownloading, setBatchDownloading] = useState(false);
|
||||||
@@ -401,10 +529,79 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const exchangeWecomIdentity = useCallback(
|
||||||
|
async (code: string, restored: PortalParams) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(partnerApi("/api/wecom-oauth"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
const result = (await response.json()) as {
|
||||||
|
userType?: string;
|
||||||
|
identityTicket?: string;
|
||||||
|
displayName?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
response.ok &&
|
||||||
|
result.userType === "external" &&
|
||||||
|
result.identityTicket
|
||||||
|
) {
|
||||||
|
const identity: WecomPortalIdentity = {
|
||||||
|
ticket: result.identityTicket,
|
||||||
|
name: result.displayName || "企微客户",
|
||||||
|
expiresAt:
|
||||||
|
Date.now() +
|
||||||
|
Math.max(60, (result.expiresIn ?? 1800) - 60) * 1_000,
|
||||||
|
};
|
||||||
|
writeSessionValue(WECOM_IDENTITY_SESSION_KEY, identity);
|
||||||
|
setWecomIdentity(identity);
|
||||||
|
setManualEntry(false);
|
||||||
|
setToast(`企微身份已确认:${identity.name}`);
|
||||||
|
} else {
|
||||||
|
markSessionFlag(WECOM_AUTH_FAILED_KEY);
|
||||||
|
setToast(
|
||||||
|
result.userType === "internal"
|
||||||
|
? "当前为企业内部成员身份,请手动输入微信号领取"
|
||||||
|
: result.error || "企微授权失败,请手动输入微信号",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
markSessionFlag(WECOM_AUTH_FAILED_KEY);
|
||||||
|
setToast("企微授权失败,请手动输入微信号");
|
||||||
|
}
|
||||||
|
setTaskToken(restored.task);
|
||||||
|
setClaimToken(restored.claim);
|
||||||
|
setDelegationToken(restored.share);
|
||||||
|
setSelectedId(restored.note);
|
||||||
|
if (!restored.task && !restored.share) {
|
||||||
|
setLoading(false);
|
||||||
|
setError("任务链接不完整,请向运营重新获取");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadTask(restored.task, restored.claim, restored.share);
|
||||||
|
},
|
||||||
|
[loadTask],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setAdminOrigin(resolveAdminOrigin());
|
setAdminOrigin(resolveAdminOrigin());
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const oauthCode = params.get("code") ?? "";
|
||||||
|
if (oauthCode) {
|
||||||
|
const restored = restoreParamsFromWecomState(params.get("state") ?? "");
|
||||||
|
const cleaned = new URL(window.location.href);
|
||||||
|
cleaned.searchParams.delete("code");
|
||||||
|
cleaned.searchParams.delete("state");
|
||||||
|
window.history.replaceState({}, "", cleaned);
|
||||||
|
void exchangeWecomIdentity(oauthCode, restored);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cachedIdentity = loadWecomIdentity();
|
||||||
|
if (cachedIdentity) setWecomIdentity(cachedIdentity);
|
||||||
const task = params.get("task") ?? "";
|
const task = params.get("task") ?? "";
|
||||||
const claim = params.get("claim") ?? "";
|
const claim = params.get("claim") ?? "";
|
||||||
const delegation = params.get("share") ?? "";
|
const delegation = params.get("share") ?? "";
|
||||||
@@ -418,10 +615,28 @@ export default function Home() {
|
|||||||
setError("任务链接不完整,请向运营重新获取");
|
setError("任务链接不完整,请向运营重新获取");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
WECOM_OAUTH_ENABLED &&
|
||||||
|
!cachedIdentity &&
|
||||||
|
!hasSessionFlag(WECOM_AUTH_FAILED_KEY) &&
|
||||||
|
isWecomEmbeddedBrowser()
|
||||||
|
) {
|
||||||
|
window.location.replace(
|
||||||
|
buildWecomAuthorizeUrl(
|
||||||
|
buildWecomOAuthState({
|
||||||
|
task,
|
||||||
|
claim,
|
||||||
|
share: delegation,
|
||||||
|
note,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadTask(task, claim, delegation);
|
void loadTask(task, claim, delegation);
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [loadTask]);
|
}, [loadTask, exchangeWecomIdentity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!toast) return;
|
if (!toast) return;
|
||||||
@@ -464,6 +679,9 @@ export default function Home() {
|
|||||||
action: "claim",
|
action: "claim",
|
||||||
taskToken,
|
taskToken,
|
||||||
claimantIdentifier: claimantName,
|
claimantIdentifier: claimantName,
|
||||||
|
...(wecomIdentity && !manualEntry
|
||||||
|
? { identityTicket: wecomIdentity.ticket }
|
||||||
|
: {}),
|
||||||
quantity,
|
quantity,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -512,6 +730,9 @@ export default function Home() {
|
|||||||
action: "recover",
|
action: "recover",
|
||||||
taskToken,
|
taskToken,
|
||||||
claimantIdentifier: claimantName,
|
claimantIdentifier: claimantName,
|
||||||
|
...(wecomIdentity && !manualEntry
|
||||||
|
? { identityTicket: wecomIdentity.ticket }
|
||||||
|
: {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = (await response.json()) as {
|
const result = (await response.json()) as {
|
||||||
@@ -2031,25 +2252,36 @@ export default function Home() {
|
|||||||
找回领取记录
|
找回领取记录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{wecomIdentity && !manualEntry && (
|
||||||
|
<div className="wecom-identity-chip">
|
||||||
|
<b>企微身份已确认</b>
|
||||||
|
<span>{wecomIdentity.name}</span>
|
||||||
|
<button type="button" onClick={() => setManualEntry(true)}>
|
||||||
|
切换为手动输入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{entryMode === "claim" ? (
|
{entryMode === "claim" ? (
|
||||||
<form onSubmit={claimNotes}>
|
<form onSubmit={claimNotes}>
|
||||||
<div>
|
<div>
|
||||||
<p className="micro">01 · 确认领取</p>
|
<p className="micro">01 · 确认领取</p>
|
||||||
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
|
<h2>你要领取多少{payload.task.type === "screenshot_collect" ? "份" : "篇"}?</h2>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
{(!wecomIdentity || manualEntry) && (
|
||||||
<span>微信号 / 手机号</span>
|
<label>
|
||||||
<input
|
<span>微信号 / 手机号</span>
|
||||||
value={claimantName}
|
<input
|
||||||
onChange={(event) => setClaimantName(event.target.value)}
|
value={claimantName}
|
||||||
placeholder="请输入微信号或手机号"
|
onChange={(event) => setClaimantName(event.target.value)}
|
||||||
autoCapitalize="none"
|
placeholder="请输入微信号或手机号"
|
||||||
autoCorrect="off"
|
autoCapitalize="none"
|
||||||
spellCheck={false}
|
autoCorrect="off"
|
||||||
maxLength={64}
|
spellCheck={false}
|
||||||
required
|
maxLength={64}
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="quantity-control">
|
<div className="quantity-control">
|
||||||
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
|
<button type="button" onClick={() => setQuantity(Math.max(1, quantity - 1))}>−</button>
|
||||||
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
|
<div><strong>{quantity}</strong><span>{payload.task.type === "screenshot_collect" ? "份" : "篇"}</span></div>
|
||||||
@@ -2080,22 +2312,26 @@ export default function Home() {
|
|||||||
<p className="micro">找回历史记录</p>
|
<p className="micro">找回历史记录</p>
|
||||||
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
|
<h2>继续之前领取的{payload.task.type === "screenshot_collect" ? "任务" : "笔记"}</h2>
|
||||||
<p className="recover-intro">
|
<p className="recover-intro">
|
||||||
输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。
|
{wecomIdentity && !manualEntry
|
||||||
|
? "已用企微身份查找,同一任务多次领取会分批展示。"
|
||||||
|
: "输入领取时使用的微信号或手机号;同一任务多次领取会分批展示。"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
{(!wecomIdentity || manualEntry) && (
|
||||||
<span>领取时的微信号 / 手机号</span>
|
<label>
|
||||||
<input
|
<span>领取时的微信号 / 手机号</span>
|
||||||
value={claimantName}
|
<input
|
||||||
onChange={(event) => setClaimantName(event.target.value)}
|
value={claimantName}
|
||||||
placeholder="需与领取时完全一致"
|
onChange={(event) => setClaimantName(event.target.value)}
|
||||||
autoCapitalize="none"
|
placeholder="需与领取时完全一致"
|
||||||
autoCorrect="off"
|
autoCapitalize="none"
|
||||||
spellCheck={false}
|
autoCorrect="off"
|
||||||
maxLength={64}
|
spellCheck={false}
|
||||||
required
|
maxLength={64}
|
||||||
/>
|
required
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button className="claim-primary" disabled={working}>
|
<button className="claim-primary" disabled={working}>
|
||||||
{working ? "正在查找…" : "查找我的领取记录"}
|
{working ? "正在查找…" : "查找我的领取记录"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"dev": "next dev -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "npx serve out -l 3001",
|
"start": "npx serve out -l 3001",
|
||||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
"test": "npm run build && node --import tsx --test tests/rendered-html.test.mjs",
|
||||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern out"
|
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next --ignore-pattern out"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -170,3 +170,29 @@ test("creates anonymous delegation bundles and reuses one-to-one backfill", asyn
|
|||||||
assert.match(styles, /\.share-checkbox/);
|
assert.match(styles, /\.share-checkbox/);
|
||||||
assert.match(styles, /\.delegation-history/);
|
assert.match(styles, /\.delegation-history/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("wires silent wecom oauth with full manual fallback", async () => {
|
||||||
|
const [page, styles] = await Promise.all([
|
||||||
|
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(page, /NEXT_PUBLIC_WECOM_CORP_ID/);
|
||||||
|
assert.match(page, /NEXT_PUBLIC_WECOM_AGENT_ID/);
|
||||||
|
assert.match(page, /snsapi_base/);
|
||||||
|
assert.match(page, /connect\/oauth2\/authorize/);
|
||||||
|
assert.match(page, /wxwork\/i\.test\(userAgent\)/);
|
||||||
|
assert.match(page, /MicroMessenger\/i\.test\(userAgent\)/);
|
||||||
|
assert.match(page, /"koc-wecom-identity"/);
|
||||||
|
assert.match(page, /"koc-wecom-auth-failed"/);
|
||||||
|
assert.match(page, /\/api\/wecom-oauth/);
|
||||||
|
assert.match(page, /restoreParamsFromWecomState/);
|
||||||
|
assert.match(page, /buildWecomOAuthState/);
|
||||||
|
assert.match(page, /identityTicket: wecomIdentity\.ticket/);
|
||||||
|
assert.match(page, /企微身份已确认/);
|
||||||
|
assert.match(page, /切换为手动输入/);
|
||||||
|
assert.match(page, /window\.location\.replace\(/);
|
||||||
|
assert.match(styles, /\.wecom-identity-chip/);
|
||||||
|
assert.match(page, /\(!wecomIdentity \|\| manualEntry\)/);
|
||||||
|
assert.match(page, /claimantIdentifier:\s*claimantName/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user