修复认证验证码投递与登录入口体验 05-27-oa-auth-delivery-wecom-entry-fix
这个提交包含在:
@@ -0,0 +1,4 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Check backend syntax, focused route tests and Prisma validation."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Check frontend typecheck, build, Playwright auth flows and screenshot evidence."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Check test output and task notes do not expose SMTP secrets, real verification codes or sensitive accounts."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/wecom-boundary.md", "reason": "Check ordinary browser WeCom entry is non-clickable while WebView CTA remains available."}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"file": ".trellis/spec/hyb-oa/backend-fastify-prisma.md", "reason": "Auth route and email service changes must follow backend route/service validation rules."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Login and forgot-password UI changes must follow auth-page layout, PWA and frontend design contracts."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "Auth delivery work must avoid leaking credentials, verification codes, tokens or account-existence signals."}
|
||||||
|
{"file": ".trellis/spec/hyb-oa/wecom-boundary.md", "reason": "Enterprise WeChat login entry behavior must preserve OA/WeCom boundary and avoid unapproved backend OAuth changes."}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Implementation Notes
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
- Backend exposes `/api/auth/capabilities` with non-secret email delivery readiness.
|
||||||
|
- Email-login code and forgot-password code requests fail before account lookup when SMTP delivery is not configured, preserving account enumeration protection.
|
||||||
|
- Verification email copy uses Chinese Beijing-time expiry wording instead of ISO UTC timestamps.
|
||||||
|
- Normal browsers show Enterprise WeChat as an informational entry only; Enterprise WeChat WebView keeps one-click login as the primary CTA.
|
||||||
|
- Frontend email-code login and forgot-password pages disable code sending when delivery is not ready.
|
||||||
|
|
||||||
|
## Verification Focus
|
||||||
|
|
||||||
|
- Backend unit coverage for delivery capability, delivery-unavailable behavior and Beijing-time email wording.
|
||||||
|
- Frontend E2E coverage for password visibility, PWA update prompt, email-code login, disabled email delivery state, forgot-password flow and Enterprise WeChat entry hierarchy.
|
||||||
|
- Deployment checks report public auth capability status without printing SMTP values.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 认证验证码投递与企业微信入口修复
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
修复部署后认证入口的两个真实问题:邮箱登录验证码和密码重置验证码无法收到;普通浏览器点击企业微信登录会跳转到只能在企业微信内使用的 OAuth 页面并报错。目标是让认证入口行为和真实能力一致,避免用户误判“验证码已发送”或点击错误入口。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 本任务覆盖后端认证接口、邮件投递能力探测、认证验证码邮件文案、登录页、找回密码页、普通浏览器企业微信入口提示、部署检查和对应自动化测试。
|
||||||
|
- 本任务不接入 PC 企业微信扫码登录,不修改企业微信后台配置,不在未获批准时继续修改远端 `.env` 或部署代码。
|
||||||
|
|
||||||
|
## What I Already Know
|
||||||
|
|
||||||
|
- 用户反馈:
|
||||||
|
- 邮箱收不到登录验证码和密码重置验证码。
|
||||||
|
- 点击企业微信登录报错,实际需要在企业微信里点击。
|
||||||
|
- 登录验证码和密码重置验证码都通过 `sendNotificationEmailSafe` + SMTP 邮件服务发送。
|
||||||
|
- 公网远端运行时配置显示:
|
||||||
|
- `emailEnabled=false`
|
||||||
|
- `smtpHost=false`
|
||||||
|
- `smtpUser=false`
|
||||||
|
- `smtpPass=false`
|
||||||
|
- `emailFrom=true`
|
||||||
|
- `smtpPort=587`
|
||||||
|
- `smtpSecure=false`
|
||||||
|
- 本地 `~/.hyb-oa/live-smoke.env` 也没有 SMTP 配置可直接同步。
|
||||||
|
- 当前邮箱验证码接口会在账号存在且 active 时创建验证码和审计记录,然后异步调用邮件发送;当邮件服务关闭时,用户仍看到通用成功提示。
|
||||||
|
- 当前普通浏览器下只要 `wecomEntry` ready,就显示“企业微信登录”按钮;点击后进入 `/api/auth/wecom/login` 并跳转企业微信 OAuth。
|
||||||
|
- 当前企业微信入口的 OAuth 设计是企业微信工作台/WebView 入口,不是 PC 浏览器扫码登录。
|
||||||
|
|
||||||
|
## Diagnosis
|
||||||
|
|
||||||
|
- 邮箱问题的直接原因是公网试运行环境未启用或配置 SMTP,两个验证码功能没有可用邮件通道。
|
||||||
|
- 代码体验问题是认证页没有把“邮件服务未启用”和“普通浏览器不支持企业微信 OAuth”转化为清晰交互。
|
||||||
|
- 安全约束:验证码请求仍必须避免账号枚举。不能因为邮箱是否存在而返回不同提示。
|
||||||
|
|
||||||
|
## Discussion Notes
|
||||||
|
|
||||||
|
- 企业邮箱 SMTP 配置复杂度低,前提是企业邮箱已允许客户端 SMTP,且能为专用发件邮箱生成客户端专用密码或授权码。
|
||||||
|
- 推荐使用独立发件邮箱,例如 `oa` 或 `no-reply` 类账号;不要使用个人员工邮箱或管理员真实登录密码。
|
||||||
|
- 试运行环境当前缺 SMTP 配置,因此必须先补远端 `.env` 并重启 server,才能验证真实收信。
|
||||||
|
- PC 企业微信扫码登录是独立能力,不等同于当前“企业微信内 OAuth 免登”。它适合 PC 常用用户,但需要单独配置企业微信授权登录回调域、前端二维码入口和后端扫码回调处理。
|
||||||
|
- 当前修复建议先做 SMTP 投递和普通浏览器企业微信入口防误点;PC 企业微信扫码登录可列为后续 P1,不放进本次故障修复 MVP。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 邮件服务全局未启用或 SMTP 未配置时:
|
||||||
|
- 登录页邮箱验证码发送应提示“邮件服务暂未启用,请联系管理员或使用密码登录”,而不是“验证码将发送”。
|
||||||
|
- 忘记密码页应给出同类提示。
|
||||||
|
- 不暴露输入邮箱是否存在。
|
||||||
|
- 公网/部署检查应能发现邮件验证码依赖的 SMTP 未启用状态,避免再次“功能上线但无法收信”。
|
||||||
|
- 普通浏览器下:
|
||||||
|
- 不提供可点击的企业微信 OAuth 登录按钮。
|
||||||
|
- 显示清晰提示:企业微信一键进入需从企业微信工作台或企业微信内打开 OA。
|
||||||
|
- 不影响企业微信 WebView 下主 CTA。
|
||||||
|
- 企业微信 WebView 下:
|
||||||
|
- 企微一键进入仍为主入口。
|
||||||
|
- 账号密码/邮箱验证码兜底仍可按现有逻辑出现。
|
||||||
|
- 不打印、提交或写入 SMTP 密码、真实邮箱、验证码、token、Secret。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] 公网或本地配置缺 SMTP 时,邮箱验证码和忘记密码不再误提示“验证码已发送”。
|
||||||
|
- [ ] 配置完整时,验证码请求仍返回通用成功,不暴露账号是否存在。
|
||||||
|
- [ ] 普通浏览器不再点击进入企业微信 OAuth 报错页。
|
||||||
|
- [ ] 企业微信 WebView 下企业微信主入口不回退。
|
||||||
|
- [ ] 后端/前端测试覆盖邮件服务未启用提示和普通浏览器企微入口提示。
|
||||||
|
- [ ] 部署/公网检查能报告邮件投递配置是否 ready。
|
||||||
|
- [ ] 需要真实收信验证时,仅在获得 SMTP 凭据和远端配置修改批准后执行。
|
||||||
|
|
||||||
|
## Proposed Implementation
|
||||||
|
|
||||||
|
1. Backend:
|
||||||
|
- 增加一个安全的公开认证能力配置接口,或扩展现有公开 auth config,暴露 `emailDeliveryReady` 这类非敏感布尔值。
|
||||||
|
- 在邮箱验证码和忘记密码请求中,若邮件服务全局未配置,返回统一错误(不依赖邮箱是否存在),让前端能提示服务未启用。
|
||||||
|
2. Frontend:
|
||||||
|
- 登录页邮箱验证码模式根据 `emailDeliveryReady` 禁用发送按钮并显示说明。
|
||||||
|
- 忘记密码页根据 `emailDeliveryReady` 禁用发送或显示服务未启用说明。
|
||||||
|
- 普通浏览器下企业微信区域改为说明性入口,不触发 OAuth;企业微信 WebView 才显示可点击 CTA。
|
||||||
|
3. QA:
|
||||||
|
- 增加/更新 auth 单测覆盖邮件未配置分支。
|
||||||
|
- 更新登录页、忘记密码、企微入口 E2E。
|
||||||
|
- 更新 live smoke/env 检查,输出邮件投递 ready 状态但不打印 SMTP 值。
|
||||||
|
4. Operations:
|
||||||
|
- 若用户提供并批准 SMTP 配置,先备份远端 `.env`,再写入 SMTP 相关键并重启 server。
|
||||||
|
- 配置后执行一次受控真实收信验证,不在日志和记录中保存验证码。
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && node --check src/routes/auth.js src/routes/wecomAuth.js src/config.js
|
||||||
|
cd server && npm test -- --run tests/unit/routes/auth.login.test.js
|
||||||
|
cd client && npm run typecheck
|
||||||
|
cd client && npm run build
|
||||||
|
E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/wecom-entry.spec.ts --workers=1 --reporter=list
|
||||||
|
node deploy/trialrun/validate-env-contract.js
|
||||||
|
node scripts/qa/check-live-smoke-env.mjs
|
||||||
|
python3 ./.trellis/scripts/task.py validate 05-27-oa-auth-delivery-wecom-entry-fix
|
||||||
|
node scripts/qa/check-trellis-governance.mjs
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 不在本任务接入 PC 企业微信扫码登录。
|
||||||
|
- 不在未获批准时修改远端 `.env`。
|
||||||
|
- 不在聊天、Trellis、docs 或日志中记录 SMTP 密码、验证码或完整账号。
|
||||||
|
- 不修改企业微信后台配置。
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- Email service: `server/src/services/email/index.js`
|
||||||
|
- Auth routes: `server/src/routes/auth.js`
|
||||||
|
- WeCom auth routes: `server/src/routes/wecomAuth.js`
|
||||||
|
- Login page: `client/src/pages/LoginPage.tsx`
|
||||||
|
- WeCom entry helpers: `client/src/lib/wecomEntry.ts`
|
||||||
|
- Runtime context: `client/src/lib/runtimeContext.ts`
|
||||||
|
- Live env check: `scripts/qa/check-live-smoke-env.mjs`
|
||||||
|
|
||||||
|
## Progress Notes
|
||||||
|
|
||||||
|
- 公网 SMTP 配置已完成一次受控验证:有效企业邮箱可以收到登录验证码和密码重置验证码;邮件被企业邮箱归入免提醒邮件。
|
||||||
|
- 本地代码已补齐认证能力接口、北京时间有效期文案、邮箱验证码入口禁用态和普通浏览器企业微信入口提示。
|
||||||
|
- 截止本记录,代码改动尚未部署到公网;公网 `/api/auth/capabilities` 仍需部署后验证。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "oa-auth-delivery-wecom-entry-fix",
|
||||||
|
"name": "oa-auth-delivery-wecom-entry-fix",
|
||||||
|
"title": "认证验证码投递与企业微信入口修复",
|
||||||
|
"description": "修复邮箱验证码真实投递能力、验证码有效期展示和普通浏览器企业微信入口误点击问题,并补齐登录页交互验证。",
|
||||||
|
"status": "in_progress",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": "server/client/auth",
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "shunli",
|
||||||
|
"assignee": "shunli",
|
||||||
|
"createdAt": "2026-05-27",
|
||||||
|
"completedAt": null,
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [
|
||||||
|
"server/src/routes/auth.js",
|
||||||
|
"server/src/services/email/index.js",
|
||||||
|
"client/src/pages/LoginPage.tsx",
|
||||||
|
"client/src/pages/ForgotPasswordPage.tsx",
|
||||||
|
"client/src/api/auth.ts",
|
||||||
|
"client/tests/e2e/login-portal-ux.spec.ts",
|
||||||
|
"client/tests/e2e/wecom-entry.spec.ts"
|
||||||
|
],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -57,6 +57,19 @@ export async function verifyEmailLoginCode(payload: { email: string; code: strin
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthCapabilities = {
|
||||||
|
emailDelivery: {
|
||||||
|
ready: boolean;
|
||||||
|
statusReasons: string[];
|
||||||
|
};
|
||||||
|
emailLoginCode: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
passwordReset: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
||||||
|
|
||||||
export type WecomEntryConfig = {
|
export type WecomEntryConfig = {
|
||||||
@@ -76,6 +89,10 @@ export async function fetchSsoProviders() {
|
|||||||
return apiRequestData<SsoProviderInfo[]>("/api/auth/sso/providers", { auth: false });
|
return apiRequestData<SsoProviderInfo[]>("/api/auth/sso/providers", { auth: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAuthCapabilities() {
|
||||||
|
return apiRequestData<AuthCapabilities>("/api/auth/capabilities", { auth: false });
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWecomConfig() {
|
export async function fetchWecomConfig() {
|
||||||
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,10 @@ export function mapApiErrorMessage(
|
|||||||
return "目标数据不存在或已被删除。";
|
return "目标数据不存在或已被删除。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (code === "SYS_001" && details?.email === "delivery_not_configured") {
|
||||||
|
return "邮件验证码暂不可用,请使用密码登录或联系管理员。";
|
||||||
|
}
|
||||||
|
|
||||||
if (code === "SYS_001") {
|
if (code === "SYS_001") {
|
||||||
return "服务暂不可用,请稍后重试或联系管理员。";
|
return "服务暂不可用,请稍后重试或联系管理员。";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { MailCheck } from "lucide-react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { forgotPassword, resetPassword } from "../api/auth";
|
import { fetchAuthCapabilities, forgotPassword, resetPassword } from "../api/auth";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { AuthPanel } from "../components/auth/AuthPanel";
|
import { AuthPanel } from "../components/auth/AuthPanel";
|
||||||
@@ -26,6 +28,15 @@ export function ForgotPasswordPage() {
|
|||||||
const [submittingReset, setSubmittingReset] = useState(false);
|
const [submittingReset, setSubmittingReset] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const authCapabilitiesQuery = useQuery({
|
||||||
|
queryKey: ["auth", "capabilities"],
|
||||||
|
queryFn: () => fetchAuthCapabilities(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const emailDeliveryReady = authCapabilitiesQuery.data?.emailDelivery.ready === true;
|
||||||
|
const emailDeliveryChecking = authCapabilitiesQuery.isLoading;
|
||||||
|
const emailDeliveryUnavailable = authCapabilitiesQuery.isSuccess && !emailDeliveryReady;
|
||||||
|
const emailDeliveryUnknown = authCapabilitiesQuery.isError;
|
||||||
|
|
||||||
async function handleRequestCode(event: React.FormEvent<HTMLFormElement>) {
|
async function handleRequestCode(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -37,12 +48,20 @@ export function ForgotPasswordPage() {
|
|||||||
setError("请输入邮箱。");
|
setError("请输入邮箱。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!emailDeliveryReady) {
|
||||||
|
setError(
|
||||||
|
emailDeliveryChecking
|
||||||
|
? "正在检查邮件服务,请稍后再试。"
|
||||||
|
: "邮件验证码暂不可用,请联系管理员处理。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmittingCode(true);
|
setSubmittingCode(true);
|
||||||
try {
|
try {
|
||||||
await forgotPassword({ email: normalizedEmail });
|
await forgotPassword({ email: normalizedEmail });
|
||||||
setCodeRequested(true);
|
setCodeRequested(true);
|
||||||
setMessage("如账号可用,验证码将发送到该邮箱。请在有效期内完成重置。");
|
setMessage("如账号可用,验证码将发送到企业邮箱;请在有效期内完成重置,并查看免提醒邮件。");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isApiError(err)) {
|
if (isApiError(err)) {
|
||||||
setError(mapApiErrorMessage(err.code, err.details, err.message));
|
setError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||||
@@ -118,8 +137,27 @@ export function ForgotPasswordPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button className="w-full" data-testid="forgot-password-request-code" disabled={submittingCode || !email.trim()} type="submit">
|
{!emailDeliveryReady ? (
|
||||||
{submittingCode ? "发送中..." : codeRequested ? "重新发送验证码" : "发送验证码"}
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800"
|
||||||
|
data-testid="forgot-password-email-delivery-status"
|
||||||
|
>
|
||||||
|
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
|
||||||
|
<span>
|
||||||
|
{emailDeliveryChecking
|
||||||
|
? "正在检查企业邮箱验证码服务。"
|
||||||
|
: emailDeliveryUnknown
|
||||||
|
? "暂时无法确认邮件服务,请稍后刷新或联系管理员。"
|
||||||
|
: emailDeliveryUnavailable
|
||||||
|
? "邮件验证码暂不可用,请联系管理员处理。"
|
||||||
|
: "邮件验证码暂不可用。"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button className="w-full" data-testid="forgot-password-request-code" disabled={submittingCode || !email.trim() || !emailDeliveryReady} type="submit">
|
||||||
|
{emailDeliveryChecking ? "检查邮件服务..." : submittingCode ? "发送中..." : codeRequested ? "重新发送验证码" : "发送验证码"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Building2, MailCheck } from "lucide-react";
|
||||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
|
import { fetchAuthCapabilities, fetchSsoProviders, fetchWecomConfig, requestEmailLoginCode } from "../api/auth";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -70,6 +71,16 @@ export function LoginPage() {
|
|||||||
});
|
});
|
||||||
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
|
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
|
||||||
|
|
||||||
|
const authCapabilitiesQuery = useQuery({
|
||||||
|
queryKey: ["auth", "capabilities"],
|
||||||
|
queryFn: () => fetchAuthCapabilities(),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const emailDeliveryReady = authCapabilitiesQuery.data?.emailDelivery.ready === true;
|
||||||
|
const emailDeliveryChecking = authCapabilitiesQuery.isLoading;
|
||||||
|
const emailDeliveryUnavailable = authCapabilitiesQuery.isSuccess && !emailDeliveryReady;
|
||||||
|
const emailDeliveryUnknown = authCapabilitiesQuery.isError;
|
||||||
|
|
||||||
const wecomConfigQuery = useQuery({
|
const wecomConfigQuery = useQuery({
|
||||||
queryKey: ["auth", "wecom-config"],
|
queryKey: ["auth", "wecom-config"],
|
||||||
queryFn: () => fetchWecomConfig(),
|
queryFn: () => fetchWecomConfig(),
|
||||||
@@ -85,6 +96,7 @@ export function LoginPage() {
|
|||||||
|
|
||||||
const showWecomPrimary = isWecomContext;
|
const showWecomPrimary = isWecomContext;
|
||||||
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
||||||
|
const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emailCodeCooldown <= 0) {
|
if (emailCodeCooldown <= 0) {
|
||||||
@@ -140,13 +152,21 @@ export function LoginPage() {
|
|||||||
setEmailCodeError("请输入企业邮箱。");
|
setEmailCodeError("请输入企业邮箱。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!emailDeliveryReady) {
|
||||||
|
setEmailCodeError(
|
||||||
|
emailDeliveryChecking
|
||||||
|
? "正在检查邮件服务,请稍后再试。"
|
||||||
|
: "邮件验证码暂不可用,请使用密码登录或联系管理员。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setEmailCodeSending(true);
|
setEmailCodeSending(true);
|
||||||
try {
|
try {
|
||||||
await requestEmailLoginCode({ email });
|
await requestEmailLoginCode({ email });
|
||||||
setEmailCodeRequested(true);
|
setEmailCodeRequested(true);
|
||||||
setEmailCodeCooldown(60);
|
setEmailCodeCooldown(60);
|
||||||
setEmailCodeMessage("如账号可用,验证码将发送到该邮箱。");
|
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;请同时查看免提醒邮件。");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isApiError(err)) {
|
if (isApiError(err)) {
|
||||||
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||||
@@ -266,14 +286,41 @@ export function LoginPage() {
|
|||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
data-testid="login-email-code-request"
|
data-testid="login-email-code-request"
|
||||||
disabled={emailCodeSending || emailCodeCooldown > 0 || !emailLoginEmail.trim()}
|
disabled={emailCodeSending || emailCodeCooldown > 0 || !emailLoginEmail.trim() || !emailDeliveryReady}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
{emailCodeSending ? "发送中..." : emailCodeCooldown > 0 ? `${emailCodeCooldown} 秒后重发` : emailCodeRequested ? "重新发送验证码" : "发送验证码"}
|
{emailDeliveryChecking
|
||||||
|
? "检查邮件服务..."
|
||||||
|
: emailCodeSending
|
||||||
|
? "发送中..."
|
||||||
|
: emailCodeCooldown > 0
|
||||||
|
? `${emailCodeCooldown} 秒后重发`
|
||||||
|
: emailCodeRequested
|
||||||
|
? "重新发送验证码"
|
||||||
|
: "发送验证码"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{!emailDeliveryReady ? (
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-800"
|
||||||
|
data-testid="login-email-delivery-status"
|
||||||
|
>
|
||||||
|
<MailCheck aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none" />
|
||||||
|
<span>
|
||||||
|
{emailDeliveryChecking
|
||||||
|
? "正在检查企业邮箱验证码服务。"
|
||||||
|
: emailDeliveryUnknown
|
||||||
|
? "暂时无法确认邮件服务,请使用密码登录或稍后刷新。"
|
||||||
|
: emailDeliveryUnavailable
|
||||||
|
? "邮件验证码暂不可用,请使用密码登录或联系管理员。"
|
||||||
|
: "邮件验证码暂不可用。"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<form className="space-y-4" onSubmit={handleEmailCodeLogin}>
|
<form className="space-y-4" onSubmit={handleEmailCodeLogin}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="login-email-code">验证码</Label>
|
<Label htmlFor="login-email-code">验证码</Label>
|
||||||
@@ -375,7 +422,7 @@ export function LoginPage() {
|
|||||||
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
{!showWecomPrimary && (wecomEntry || oidcProvider) ? (
|
{!showWecomPrimary && (showBrowserWecomNotice || oidcProvider) ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||||
<span className="h-px flex-1 bg-slate-100" />
|
<span className="h-px flex-1 bg-slate-100" />
|
||||||
@@ -383,10 +430,17 @@ export function LoginPage() {
|
|||||||
<span className="h-px flex-1 bg-slate-100" />
|
<span className="h-px flex-1 bg-slate-100" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wecomEntry ? (
|
{showBrowserWecomNotice ? (
|
||||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
<div
|
||||||
企业微信登录
|
className="flex gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600"
|
||||||
</Button>
|
data-testid="wecom-entry-browser-notice"
|
||||||
|
>
|
||||||
|
<Building2 aria-hidden="true" className="mt-0.5 h-4 w-4 flex-none text-primary" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-900">企业微信一键进入</div>
|
||||||
|
<div>请从企业微信工作台打开 OA;普通浏览器使用密码或邮箱验证码。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{oidcProvider ? (
|
{oidcProvider ? (
|
||||||
|
|||||||
@@ -15,8 +15,30 @@ function envelope(data: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockPasswordLogin(page: import("@playwright/test").Page) {
|
function authCapabilitiesPayload(emailReady = true) {
|
||||||
|
return {
|
||||||
|
emailDelivery: {
|
||||||
|
ready: emailReady,
|
||||||
|
statusReasons: emailReady ? [] : ["email_disabled"],
|
||||||
|
},
|
||||||
|
emailLoginCode: {
|
||||||
|
enabled: emailReady,
|
||||||
|
},
|
||||||
|
passwordReset: {
|
||||||
|
enabled: emailReady,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPasswordLogin(page: import("@playwright/test").Page, emailReady = true) {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
|
page.route("**/api/auth/capabilities", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(authCapabilitiesPayload(emailReady))),
|
||||||
|
});
|
||||||
|
}),
|
||||||
page.route("**/api/auth/sso/providers", async (route) => {
|
page.route("**/api/auth/sso/providers", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -66,8 +88,15 @@ function mockPasswordLogin(page: import("@playwright/test").Page) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockGuestAuthChrome(page: import("@playwright/test").Page) {
|
async function mockGuestAuthChrome(page: import("@playwright/test").Page, emailReady = true) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
page.route("**/api/auth/capabilities", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(authCapabilitiesPayload(emailReady))),
|
||||||
|
});
|
||||||
|
}),
|
||||||
page.route("**/api/auth/sso/providers", async (route) => {
|
page.route("**/api/auth/sso/providers", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -397,10 +426,33 @@ test("邮箱验证码登录可发送验证码并进入工作台", async ({ page
|
|||||||
expect(verifyPayloads).toEqual([{ email: "[email protected]", code: "654321" }]);
|
expect(verifyPayloads).toEqual([{ email: "[email protected]", code: "654321" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("邮件服务不可用时登录和找回密码禁用验证码发送", async ({ page }) => {
|
||||||
|
await mockGuestAuthChrome(page, false);
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.getByTestId("login-mode-email-code").click();
|
||||||
|
await expect(page.getByTestId("login-email-delivery-status")).toContainText("邮件验证码暂不可用");
|
||||||
|
await page.getByTestId("login-email-code-email").fill("[email protected]");
|
||||||
|
await expect(page.getByTestId("login-email-code-request")).toBeDisabled();
|
||||||
|
|
||||||
|
await page.goto("/forgot-password");
|
||||||
|
await expect(page.getByTestId("forgot-password-email-delivery-status")).toContainText("邮件验证码暂不可用");
|
||||||
|
await page.getByTestId("forgot-password-email").fill("[email protected]");
|
||||||
|
await expect(page.getByTestId("forgot-password-request-code")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => {
|
test("找回密码流程使用邮箱验证码重置新密码", async ({ page }) => {
|
||||||
const requestedEmails: string[] = [];
|
const requestedEmails: string[] = [];
|
||||||
const resetPayloads: unknown[] = [];
|
const resetPayloads: unknown[] = [];
|
||||||
|
|
||||||
|
await page.route("**/api/auth/capabilities", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope(authCapabilitiesPayload())),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
await page.route("**/api/auth/forgot-password", async (route) => {
|
await page.route("**/api/auth/forgot-password", async (route) => {
|
||||||
const payload = route.request().postDataJSON() as { email?: string };
|
const payload = route.request().postDataJSON() as { email?: string };
|
||||||
requestedEmails.push(String(payload.email ?? ""));
|
requestedEmails.push(String(payload.email ?? ""));
|
||||||
@@ -473,10 +525,10 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
|||||||
|
|
||||||
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "快捷操作栏" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "常用" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "团队全员状态" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "今日待办聚合" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "待处理" })).toBeVisible();
|
||||||
await expect(page.getByRole("heading", { name: "近期动态流" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page }) => {
|
||||||
@@ -501,7 +553,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
|||||||
"portal-card-weekly_help",
|
"portal-card-weekly_help",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
|
||||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
await expect(page.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
||||||
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
||||||
@@ -594,7 +646,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
|||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,31 +15,52 @@ function mockWecomConfigPayload() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async ({ page }) => {
|
function envelope(data: unknown) {
|
||||||
|
return {
|
||||||
|
requestId: "mock-request",
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockAuthBase(page: import("@playwright/test").Page) {
|
||||||
|
await page.route("**/api/auth/capabilities", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope({
|
||||||
|
emailDelivery: { ready: true, statusReasons: [] },
|
||||||
|
emailLoginCode: { enabled: true },
|
||||||
|
passwordReset: { enabled: true },
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.route("**/api/auth/sso/providers", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(envelope([])),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockWecomConfig(page: import("@playwright/test").Page) {
|
||||||
await page.route("**/api/auth/wecom/config", async (route) => {
|
await page.route("**/api/auth/wecom/config", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(envelope(mockWecomConfigPayload())),
|
||||||
requestId: "mock-wecom-config",
|
|
||||||
data: mockWecomConfigPayload(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await page.route("**/api/auth/wecom/login?**", async (route) => {
|
|
||||||
await route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: "text/plain",
|
|
||||||
body: "mock wecom login entry",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("E2E 企微入口:普通浏览器下只提示企业微信工作台打开", async ({ page }) => {
|
||||||
|
await mockAuthBase(page);
|
||||||
|
await mockWecomConfig(page);
|
||||||
|
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
|
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
|
||||||
await expect(page.getByTestId("wecom-entry-secondary")).toBeVisible();
|
await expect(page.getByTestId("wecom-entry-browser-notice")).toContainText("请从企业微信工作台打开 OA");
|
||||||
|
await expect(page.getByTestId("wecom-entry-secondary")).toHaveCount(0);
|
||||||
await page.getByTestId("wecom-entry-secondary").click();
|
|
||||||
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => {
|
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => {
|
||||||
@@ -49,16 +70,8 @@ test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未
|
|||||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await page.route("**/api/auth/wecom/config", async (route) => {
|
await mockAuthBase(page);
|
||||||
await route.fulfill({
|
await mockWecomConfig(page);
|
||||||
status: 200,
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify({
|
|
||||||
requestId: "mock-wecom-config",
|
|
||||||
data: mockWecomConfigPayload(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await page.route("**/api/auth/wecom/login?**", async (route) => {
|
await page.route("**/api/auth/wecom/login?**", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ async function main() {
|
|||||||
|
|
||||||
const ready = await fetchJson(`${baseUrl}/ready`);
|
const ready = await fetchJson(`${baseUrl}/ready`);
|
||||||
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
||||||
|
const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`);
|
||||||
const result = {
|
const result = {
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
...checks,
|
...checks,
|
||||||
@@ -137,6 +138,14 @@ async function main() {
|
|||||||
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? null,
|
||||||
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
|
loginUrl: wecomConfig.json?.data?.loginUrl ?? null,
|
||||||
},
|
},
|
||||||
|
authCapabilities: {
|
||||||
|
ok: authCapabilities.ok,
|
||||||
|
status: authCapabilities.status,
|
||||||
|
emailDeliveryReady: authCapabilities.json?.data?.emailDelivery?.ready ?? null,
|
||||||
|
emailDeliveryReasons: authCapabilities.json?.data?.emailDelivery?.statusReasons ?? null,
|
||||||
|
emailLoginCodeEnabled: authCapabilities.json?.data?.emailLoginCode?.enabled ?? null,
|
||||||
|
passwordResetEnabled: authCapabilities.json?.data?.passwordReset?.enabled ?? null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (jsonOnly) {
|
if (jsonOnly) {
|
||||||
@@ -145,6 +154,7 @@ async function main() {
|
|||||||
console.log(`[live-smoke-env] base=${result.baseUrl}`);
|
console.log(`[live-smoke-env] base=${result.baseUrl}`);
|
||||||
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
|
console.log(`[live-smoke-env] publicReady=${result.publicReady.ok} status=${result.publicReady.status}`);
|
||||||
console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`);
|
console.log(`[live-smoke-env] wecom=${result.wecomConfig.statusText ?? "unknown"} entry=${result.wecomConfig.entryEnabled} oauth=${result.wecomConfig.oauthEnabled}`);
|
||||||
|
console.log(`[live-smoke-env] authCapabilities=${result.authCapabilities.ok} emailDelivery=${result.authCapabilities.emailDeliveryReady}`);
|
||||||
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
|
console.log(`[live-smoke-env] liveSmokeReady=${result.liveSmokeReady}`);
|
||||||
console.log(`[live-smoke-env] liveOcrReady=${result.liveOcrReady}`);
|
console.log(`[live-smoke-env] liveOcrReady=${result.liveOcrReady}`);
|
||||||
if (result.missing.length) {
|
if (result.missing.length) {
|
||||||
@@ -152,7 +162,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok)) {
|
if (strict && (!result.liveSmokeReady || !result.liveOcrReady || !result.publicReady.ok || !result.wecomConfig.ok || !result.authCapabilities.ok)) {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-7
@@ -8,7 +8,7 @@ const { validatePassword } = require("../utils/passwordPolicy");
|
|||||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||||
const { sendData } = require("../utils/reply");
|
const { sendData } = require("../utils/reply");
|
||||||
const { createNotifications } = require("../services/notifications");
|
const { createNotifications } = require("../services/notifications");
|
||||||
const { sendNotificationEmailSafe } = require("../services/email");
|
const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = require("../services/email");
|
||||||
const { generators, getOidcClient } = require("../services/oidc");
|
const { generators, getOidcClient } = require("../services/oidc");
|
||||||
|
|
||||||
const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind";
|
const PENDING_WECOM_BIND_COOKIE_NAME = "wecom_login_bind";
|
||||||
@@ -245,6 +245,53 @@ function emailLoginCodeError() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emailDeliveryUnavailableError() {
|
||||||
|
return new AppError({
|
||||||
|
code: "SYS_001",
|
||||||
|
message: "邮件服务暂未启用",
|
||||||
|
statusCode: 503,
|
||||||
|
details: { email: "delivery_not_configured" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEmailDeliveryReady(config) {
|
||||||
|
const status = resolveEmailDeliveryStatus(config);
|
||||||
|
if (!status.ready) {
|
||||||
|
throw emailDeliveryUnavailableError();
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPart(parts, type) {
|
||||||
|
return parts.find((part) => part.type === type)?.value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChinaDateTime(date) {
|
||||||
|
const parts = new Intl.DateTimeFormat("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hourCycle: "h23",
|
||||||
|
}).formatToParts(date);
|
||||||
|
|
||||||
|
return `${getPart(parts, "year")}年${getPart(parts, "month")}月${getPart(parts, "day")}日 ${getPart(parts, "hour")}:${getPart(parts, "minute")}(北京时间)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthVerificationEmail({ code, expiresAt, purpose }) {
|
||||||
|
const subject =
|
||||||
|
purpose === "password_reset"
|
||||||
|
? "【海音达OA】密码重置验证码"
|
||||||
|
: "【海音达OA】登录验证码";
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject,
|
||||||
|
body: `验证码:${code}\n有效期至:${formatChinaDateTime(expiresAt)}\n如非本人操作,请忽略并联系管理员。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFingerprint, request, now, expiresAt }) {
|
async function createRefreshSession(tx, { employeeId, refreshTokenHash, deviceFingerprint, request, now, expiresAt }) {
|
||||||
const activeSessions = await tx.authSession.findMany({
|
const activeSessions = await tx.authSession.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -303,6 +350,22 @@ async function authRoutes(fastify) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl);
|
const accessTokenExpiresIn = parseAccessTokenTtlSeconds(fastify.config.accessTokenTtl);
|
||||||
|
|
||||||
|
fastify.get("/capabilities", async (request, reply) => {
|
||||||
|
const emailDelivery = resolveEmailDeliveryStatus(fastify.config);
|
||||||
|
return sendData(reply, request, {
|
||||||
|
emailDelivery: {
|
||||||
|
ready: emailDelivery.ready,
|
||||||
|
statusReasons: emailDelivery.reasons,
|
||||||
|
},
|
||||||
|
emailLoginCode: {
|
||||||
|
enabled: emailDelivery.ready,
|
||||||
|
},
|
||||||
|
passwordReset: {
|
||||||
|
enabled: emailDelivery.ready,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
fastify.post("/login", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
const parsed = loginBodySchema.safeParse(request.body);
|
const parsed = loginBodySchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -574,6 +637,8 @@ async function authRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertEmailDeliveryReady(fastify.config);
|
||||||
|
|
||||||
const { email } = parsed.data;
|
const { email } = parsed.data;
|
||||||
const employee = await fastify.prisma.employee.findFirst({
|
const employee = await fastify.prisma.employee.findFirst({
|
||||||
where: { email },
|
where: { email },
|
||||||
@@ -588,6 +653,7 @@ async function authRoutes(fastify) {
|
|||||||
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
|
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
|
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
|
||||||
|
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "login" });
|
||||||
|
|
||||||
await fastify.prisma.emailLoginCode.create({
|
await fastify.prisma.emailLoginCode.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -616,8 +682,8 @@ async function authRoutes(fastify) {
|
|||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
type: "auth.email_login_code_requested",
|
type: "auth.email_login_code_requested",
|
||||||
recipientEmployeeIds: [employee.id],
|
recipientEmployeeIds: [employee.id],
|
||||||
subject: "【安全】OA 登录验证码",
|
subject: emailMessage.subject,
|
||||||
body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
body: emailMessage.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
return sendData(reply, request, {});
|
return sendData(reply, request, {});
|
||||||
@@ -1355,7 +1421,7 @@ async function authRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const forgotPasswordBodySchema = z.object({
|
const forgotPasswordBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
|
fastify.post("/forgot-password", { preHandler: authRateLimit }, async (request, reply) => {
|
||||||
@@ -1369,6 +1435,8 @@ async function authRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertEmailDeliveryReady(fastify.config);
|
||||||
|
|
||||||
const { email } = parsed.data;
|
const { email } = parsed.data;
|
||||||
const employee = await fastify.prisma.employee.findFirst({
|
const employee = await fastify.prisma.employee.findFirst({
|
||||||
where: { email },
|
where: { email },
|
||||||
@@ -1383,6 +1451,7 @@ async function authRoutes(fastify) {
|
|||||||
const codeHash = sha256Hex(code);
|
const codeHash = sha256Hex(code);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
|
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
|
||||||
|
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "password_reset" });
|
||||||
|
|
||||||
await fastify.prisma.passwordResetCode.create({
|
await fastify.prisma.passwordResetCode.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1427,8 +1496,8 @@ async function authRoutes(fastify) {
|
|||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
type: "auth.password_reset_requested",
|
type: "auth.password_reset_requested",
|
||||||
recipientEmployeeIds: [employee.id],
|
recipientEmployeeIds: [employee.id],
|
||||||
subject: "【安全】密码重置验证码",
|
subject: emailMessage.subject,
|
||||||
body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
body: emailMessage.body,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fastify.config.devShowPasswordResetCode) {
|
if (fastify.config.devShowPasswordResetCode) {
|
||||||
@@ -1442,7 +1511,7 @@ async function authRoutes(fastify) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resetPasswordBodySchema = z.object({
|
const resetPasswordBodySchema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||||
code: z.string().length(6),
|
code: z.string().length(6),
|
||||||
newPassword: z.string().min(12),
|
newPassword: z.string().min(12),
|
||||||
});
|
});
|
||||||
@@ -1577,3 +1646,7 @@ async function authRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = authRoutes;
|
module.exports = authRoutes;
|
||||||
|
module.exports._test = {
|
||||||
|
buildAuthVerificationEmail,
|
||||||
|
formatChinaDateTime,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
require('dotenv/config');
|
require('dotenv/config');
|
||||||
|
|
||||||
const { getConfig } = require('../config');
|
const { getConfig } = require('../config');
|
||||||
|
const { resolveEmailDeliveryStatus } = require('../services/email');
|
||||||
|
|
||||||
function assertCondition(condition, message) {
|
function assertCondition(condition, message) {
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
@@ -42,17 +43,20 @@ function evaluateWecom(config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function evaluateEmail(config) {
|
function evaluateEmail(config) {
|
||||||
if (!config.emailEnabled) {
|
const status = resolveEmailDeliveryStatus(config);
|
||||||
|
if (status.reasons.includes('email_disabled')) {
|
||||||
return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] };
|
return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const missing = [];
|
const reasonLabels = {
|
||||||
if (!String(config.emailFrom ?? '').trim()) missing.push('EMAIL_FROM');
|
email_from_missing: 'EMAIL_FROM',
|
||||||
if (!String(config.smtpHost ?? '').trim()) missing.push('SMTP_HOST');
|
smtp_host_missing: 'SMTP_HOST',
|
||||||
if (!Number.isFinite(Number(config.smtpPort)) || Number(config.smtpPort) <= 0) missing.push('SMTP_PORT');
|
smtp_port_invalid: 'SMTP_PORT',
|
||||||
if (String(config.smtpUser ?? '').trim() && !String(config.smtpPass ?? '').trim()) missing.push('SMTP_PASS');
|
smtp_auth_incomplete: 'SMTP_USER/SMTP_PASS',
|
||||||
|
};
|
||||||
|
const missing = status.reasons.map((reason) => reasonLabels[reason] ?? reason);
|
||||||
|
|
||||||
assertCondition(missing.length === 0, `SMTP 配置不完整:${formatList(missing)}`);
|
assertCondition(status.ready, `SMTP 配置不完整:${formatList(missing)}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
|
|||||||
@@ -51,6 +51,37 @@ function getTransporter(config) {
|
|||||||
return cachedTransporter;
|
return cachedTransporter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveEmailDeliveryStatus(config) {
|
||||||
|
const reasons = [];
|
||||||
|
const authUser = String(config.smtpUser ?? '').trim();
|
||||||
|
const authPass = String(config.smtpPass ?? '').trim();
|
||||||
|
const smtpPort = Number(config.smtpPort);
|
||||||
|
|
||||||
|
if (!config.emailEnabled) {
|
||||||
|
reasons.push('email_disabled');
|
||||||
|
}
|
||||||
|
if (!String(config.emailFrom ?? '').trim()) {
|
||||||
|
reasons.push('email_from_missing');
|
||||||
|
}
|
||||||
|
if (!String(config.smtpHost ?? '').trim()) {
|
||||||
|
reasons.push('smtp_host_missing');
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(smtpPort) || smtpPort <= 0) {
|
||||||
|
reasons.push('smtp_port_invalid');
|
||||||
|
}
|
||||||
|
if (authUser && !authPass) {
|
||||||
|
reasons.push('smtp_auth_incomplete');
|
||||||
|
}
|
||||||
|
if (!authUser && authPass) {
|
||||||
|
reasons.push('smtp_auth_incomplete');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: reasons.length === 0,
|
||||||
|
reasons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function uniq(values) {
|
function uniq(values) {
|
||||||
return Array.from(new Set((values ?? []).filter(Boolean)));
|
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||||
}
|
}
|
||||||
@@ -168,6 +199,7 @@ async function sendNotificationEmail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
resolveEmailDeliveryStatus,
|
||||||
sendNotificationEmailSafe,
|
sendNotificationEmailSafe,
|
||||||
sendNotificationEmail,
|
sendNotificationEmail,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const fastifyCookie = require("@fastify/cookie");
|
|||||||
|
|
||||||
const authRoutes = require("../../../src/routes/auth");
|
const authRoutes = require("../../../src/routes/auth");
|
||||||
const { AppError } = require("../../../src/utils/AppError");
|
const { AppError } = require("../../../src/utils/AppError");
|
||||||
const { hashPassword } = require("../../../src/utils/crypto");
|
const { hashPassword, sha256Hex } = require("../../../src/utils/crypto");
|
||||||
const { signPayload } = require("../../../src/utils/signedUrl");
|
const { signPayload } = require("../../../src/utils/signedUrl");
|
||||||
|
|
||||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||||
@@ -28,15 +28,18 @@ async function buildApp(passwordHash) {
|
|||||||
login_failed_first_at: null,
|
login_failed_first_at: null,
|
||||||
login_locked_until: null,
|
login_locked_until: null,
|
||||||
});
|
});
|
||||||
|
const employeeFindMany = vi.fn().mockResolvedValue([]);
|
||||||
const employeeUpdate = vi.fn().mockResolvedValue({});
|
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||||
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
||||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||||
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-1" });
|
||||||
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
const auditCreate = vi.fn().mockResolvedValue({ id: "audit-1" });
|
||||||
|
const notificationCreateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
const emailLoginCodeCreate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||||
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
|
const emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-1" });
|
||||||
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
const emailLoginCodeUpdateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
|
const passwordResetCodeCreate = vi.fn().mockResolvedValue({ id: "password-reset-code-1" });
|
||||||
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
const wecomFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
const wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||||
|
|
||||||
@@ -56,6 +59,9 @@ async function buildApp(passwordHash) {
|
|||||||
auditLog: {
|
auditLog: {
|
||||||
create: auditCreate,
|
create: auditCreate,
|
||||||
},
|
},
|
||||||
|
notification: {
|
||||||
|
createMany: notificationCreateMany,
|
||||||
|
},
|
||||||
wecomIdentityBinding: {
|
wecomIdentityBinding: {
|
||||||
findFirst: wecomFindFirst,
|
findFirst: wecomFindFirst,
|
||||||
upsert: wecomUpsert,
|
upsert: wecomUpsert,
|
||||||
@@ -72,6 +78,14 @@ async function buildApp(passwordHash) {
|
|||||||
loginFailMaxAttempts: 5,
|
loginFailMaxAttempts: 5,
|
||||||
emailLoginCodeTtlSeconds: 600,
|
emailLoginCodeTtlSeconds: 600,
|
||||||
emailLoginCodeMaxAttempts: 5,
|
emailLoginCodeMaxAttempts: 5,
|
||||||
|
passwordResetCodeTtlSeconds: 600,
|
||||||
|
emailEnabled: true,
|
||||||
|
emailFrom: "[email protected]",
|
||||||
|
smtpHost: "smtp.hyinda.test",
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUser: "",
|
||||||
|
smtpPass: "",
|
||||||
rateLimitEnabled: false,
|
rateLimitEnabled: false,
|
||||||
jwtSecret: JWT_SECRET,
|
jwtSecret: JWT_SECRET,
|
||||||
appOrigin: "http://127.0.0.1:3000",
|
appOrigin: "http://127.0.0.1:3000",
|
||||||
@@ -80,14 +94,21 @@ async function buildApp(passwordHash) {
|
|||||||
app.decorate("prisma", {
|
app.decorate("prisma", {
|
||||||
employee: {
|
employee: {
|
||||||
findFirst: employeeFindFirst,
|
findFirst: employeeFindFirst,
|
||||||
|
findMany: employeeFindMany,
|
||||||
},
|
},
|
||||||
emailLoginCode: {
|
emailLoginCode: {
|
||||||
create: emailLoginCodeCreate,
|
create: emailLoginCodeCreate,
|
||||||
findFirst: emailLoginCodeFindFirst,
|
findFirst: emailLoginCodeFindFirst,
|
||||||
},
|
},
|
||||||
|
passwordResetCode: {
|
||||||
|
create: passwordResetCodeCreate,
|
||||||
|
},
|
||||||
auditLog: {
|
auditLog: {
|
||||||
create: auditCreate,
|
create: auditCreate,
|
||||||
},
|
},
|
||||||
|
notification: {
|
||||||
|
createMany: notificationCreateMany,
|
||||||
|
},
|
||||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||||
});
|
});
|
||||||
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||||
@@ -110,15 +131,18 @@ async function buildApp(passwordHash) {
|
|||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
employeeFindFirst,
|
employeeFindFirst,
|
||||||
|
employeeFindMany,
|
||||||
employeeUpdate,
|
employeeUpdate,
|
||||||
authSessionFindMany,
|
authSessionFindMany,
|
||||||
authSessionUpdateMany,
|
authSessionUpdateMany,
|
||||||
authSessionCreate,
|
authSessionCreate,
|
||||||
auditCreate,
|
auditCreate,
|
||||||
|
notificationCreateMany,
|
||||||
emailLoginCodeCreate,
|
emailLoginCodeCreate,
|
||||||
emailLoginCodeFindFirst,
|
emailLoginCodeFindFirst,
|
||||||
emailLoginCodeUpdate,
|
emailLoginCodeUpdate,
|
||||||
emailLoginCodeUpdateMany,
|
emailLoginCodeUpdateMany,
|
||||||
|
passwordResetCodeCreate,
|
||||||
wecomFindFirst,
|
wecomFindFirst,
|
||||||
wecomUpsert,
|
wecomUpsert,
|
||||||
};
|
};
|
||||||
@@ -209,6 +233,78 @@ describe("auth login route", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports auth delivery capabilities without exposing SMTP values", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app } = await buildApp(passwordHash);
|
||||||
|
|
||||||
|
const readyResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/auth/capabilities",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readyResponse.statusCode).toBe(200);
|
||||||
|
expect(readyResponse.json().data).toEqual({
|
||||||
|
emailDelivery: {
|
||||||
|
ready: true,
|
||||||
|
statusReasons: [],
|
||||||
|
},
|
||||||
|
emailLoginCode: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
passwordReset: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(readyResponse.json())).not.toContain("smtp.hyinda.test");
|
||||||
|
|
||||||
|
app.config.emailEnabled = false;
|
||||||
|
app.config.smtpHost = "";
|
||||||
|
const disabledResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/auth/capabilities",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(disabledResponse.statusCode).toBe(200);
|
||||||
|
expect(disabledResponse.json().data).toEqual({
|
||||||
|
emailDelivery: {
|
||||||
|
ready: false,
|
||||||
|
statusReasons: ["email_disabled", "smtp_host_missing"],
|
||||||
|
},
|
||||||
|
emailLoginCode: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
passwordReset: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds auth verification emails with Beijing-time expiry wording", () => {
|
||||||
|
expect(
|
||||||
|
authRoutes._test.buildAuthVerificationEmail({
|
||||||
|
code: "123456",
|
||||||
|
expiresAt: new Date("2026-05-27T01:12:00.000Z"),
|
||||||
|
purpose: "login",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
subject: "【海音达OA】登录验证码",
|
||||||
|
body: "验证码:123456\n有效期至:2026年05月27日 09:12(北京时间)\n如非本人操作,请忽略并联系管理员。",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
authRoutes._test.buildAuthVerificationEmail({
|
||||||
|
code: "654321",
|
||||||
|
expiresAt: new Date("2026-05-27T01:12:00.000Z"),
|
||||||
|
purpose: "password_reset",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
subject: "【海音达OA】密码重置验证码",
|
||||||
|
body: "验证码:654321\n有效期至:2026年05月27日 09:12(北京时间)\n如非本人操作,请忽略并联系管理员。",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("auto-binds pending wecom identity after password login", async () => {
|
it("auto-binds pending wecom identity after password login", async () => {
|
||||||
const passwordHash = await hashPassword("AdminPassword1234");
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||||
@@ -303,6 +399,26 @@ describe("auth login route", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects email login code requests when delivery is not configured before account lookup", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||||
|
app.config.emailEnabled = false;
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/email-login/code",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
payload: { email: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
|
||||||
|
expect(employeeFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(emailLoginCodeCreate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not reveal missing accounts when requesting an email login code", async () => {
|
it("does not reveal missing accounts when requesting an email login code", async () => {
|
||||||
const passwordHash = await hashPassword("AdminPassword1234");
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||||
@@ -408,4 +524,56 @@ describe("auth login route", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends password reset codes with Beijing-time expiry wording", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.654321);
|
||||||
|
const { app, passwordResetCodeCreate, auditCreate, notificationCreateMany } = await buildApp(passwordHash);
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/forgot-password",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
payload: { email: " [email protected] " },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().data).toEqual({});
|
||||||
|
expect(passwordResetCodeCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
employee_id: EMPLOYEE_ID,
|
||||||
|
code_hash: sha256Hex("654321"),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(auditCreate).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
action: "AUTH_PASSWORD_RESET_REQUESTED",
|
||||||
|
actor_id: EMPLOYEE_ID,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(notificationCreateMany).toHaveBeenCalled();
|
||||||
|
|
||||||
|
randomSpy.mockRestore();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects password reset code requests when delivery is not configured before account lookup", async () => {
|
||||||
|
const passwordHash = await hashPassword("AdminPassword1234");
|
||||||
|
const { app, employeeFindFirst, passwordResetCodeCreate } = await buildApp(passwordHash);
|
||||||
|
app.config.smtpHost = "";
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/auth/forgot-password",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
payload: { email: "[email protected]" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json().error.details).toEqual({ email: "delivery_not_configured" });
|
||||||
|
expect(employeeFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(passwordResetCodeCreate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { resolveEmailDeliveryStatus } = require("../../../src/services/email");
|
||||||
|
|
||||||
|
describe("email delivery status", () => {
|
||||||
|
it("reports ready only when SMTP delivery has the required public settings", () => {
|
||||||
|
expect(
|
||||||
|
resolveEmailDeliveryStatus({
|
||||||
|
emailEnabled: true,
|
||||||
|
emailFrom: "[email protected]",
|
||||||
|
smtpHost: "smtp.hyinda.test",
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUser: "",
|
||||||
|
smtpPass: "",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ready: true,
|
||||||
|
reasons: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports non-secret missing SMTP reasons", () => {
|
||||||
|
expect(
|
||||||
|
resolveEmailDeliveryStatus({
|
||||||
|
emailEnabled: true,
|
||||||
|
emailFrom: "",
|
||||||
|
smtpHost: "",
|
||||||
|
smtpPort: 0,
|
||||||
|
smtpSecure: false,
|
||||||
|
smtpUser: "sender",
|
||||||
|
smtpPass: "",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
ready: false,
|
||||||
|
reasons: ["email_from_missing", "smtp_host_missing", "smtp_port_invalid", "smtp_auth_incomplete"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户