修复认证验证码投递与登录入口体验 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 WecomEntryConfig = {
|
||||
@@ -76,6 +89,10 @@ export async function fetchSsoProviders() {
|
||||
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() {
|
||||
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ export function mapApiErrorMessage(
|
||||
return "目标数据不存在或已被删除。";
|
||||
}
|
||||
|
||||
if (code === "SYS_001" && details?.email === "delivery_not_configured") {
|
||||
return "邮件验证码暂不可用,请使用密码登录或联系管理员。";
|
||||
}
|
||||
|
||||
if (code === "SYS_001") {
|
||||
return "服务暂不可用,请稍后重试或联系管理员。";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MailCheck } from "lucide-react";
|
||||
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 { isApiError } from "../api/types";
|
||||
import { AuthPanel } from "../components/auth/AuthPanel";
|
||||
@@ -26,6 +28,15 @@ export function ForgotPasswordPage() {
|
||||
const [submittingReset, setSubmittingReset] = useState(false);
|
||||
const [message, setMessage] = 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>) {
|
||||
event.preventDefault();
|
||||
@@ -37,12 +48,20 @@ export function ForgotPasswordPage() {
|
||||
setError("请输入邮箱。");
|
||||
return;
|
||||
}
|
||||
if (!emailDeliveryReady) {
|
||||
setError(
|
||||
emailDeliveryChecking
|
||||
? "正在检查邮件服务,请稍后再试。"
|
||||
: "邮件验证码暂不可用,请联系管理员处理。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmittingCode(true);
|
||||
try {
|
||||
await forgotPassword({ email: normalizedEmail });
|
||||
setCodeRequested(true);
|
||||
setMessage("如账号可用,验证码将发送到该邮箱。请在有效期内完成重置。");
|
||||
setMessage("如账号可用,验证码将发送到企业邮箱;请在有效期内完成重置,并查看免提醒邮件。");
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||
@@ -118,8 +137,27 @@ export function ForgotPasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button className="w-full" data-testid="forgot-password-request-code" disabled={submittingCode || !email.trim()} type="submit">
|
||||
{submittingCode ? "发送中..." : codeRequested ? "重新发送验证码" : "发送验证码"}
|
||||
{!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="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>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Building2, MailCheck } from "lucide-react";
|
||||
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 { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
@@ -70,6 +71,16 @@ export function LoginPage() {
|
||||
});
|
||||
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({
|
||||
queryKey: ["auth", "wecom-config"],
|
||||
queryFn: () => fetchWecomConfig(),
|
||||
@@ -85,6 +96,7 @@ export function LoginPage() {
|
||||
|
||||
const showWecomPrimary = isWecomContext;
|
||||
const showPasswordPanel = !showWecomPrimary || Boolean(wecomNotice) || passwordFallbackOpen || (showWecomPrimary && wecomConfig && !wecomEntry);
|
||||
const showBrowserWecomNotice = !showWecomPrimary && Boolean(wecomEntry);
|
||||
|
||||
useEffect(() => {
|
||||
if (emailCodeCooldown <= 0) {
|
||||
@@ -140,13 +152,21 @@ export function LoginPage() {
|
||||
setEmailCodeError("请输入企业邮箱。");
|
||||
return;
|
||||
}
|
||||
if (!emailDeliveryReady) {
|
||||
setEmailCodeError(
|
||||
emailDeliveryChecking
|
||||
? "正在检查邮件服务,请稍后再试。"
|
||||
: "邮件验证码暂不可用,请使用密码登录或联系管理员。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setEmailCodeSending(true);
|
||||
try {
|
||||
await requestEmailLoginCode({ email });
|
||||
setEmailCodeRequested(true);
|
||||
setEmailCodeCooldown(60);
|
||||
setEmailCodeMessage("如账号可用,验证码将发送到该邮箱。");
|
||||
setEmailCodeMessage("如账号可用,验证码将发送到企业邮箱;请同时查看免提醒邮件。");
|
||||
} catch (err) {
|
||||
if (isApiError(err)) {
|
||||
setEmailCodeError(mapApiErrorMessage(err.code, err.details, err.message));
|
||||
@@ -266,14 +286,41 @@ export function LoginPage() {
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="login-email-code-request"
|
||||
disabled={emailCodeSending || emailCodeCooldown > 0 || !emailLoginEmail.trim()}
|
||||
disabled={emailCodeSending || emailCodeCooldown > 0 || !emailLoginEmail.trim() || !emailDeliveryReady}
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
>
|
||||
{emailCodeSending ? "发送中..." : emailCodeCooldown > 0 ? `${emailCodeCooldown} 秒后重发` : emailCodeRequested ? "重新发送验证码" : "发送验证码"}
|
||||
{emailDeliveryChecking
|
||||
? "检查邮件服务..."
|
||||
: emailCodeSending
|
||||
? "发送中..."
|
||||
: emailCodeCooldown > 0
|
||||
? `${emailCodeCooldown} 秒后重发`
|
||||
: emailCodeRequested
|
||||
? "重新发送验证码"
|
||||
: "发送验证码"}
|
||||
</Button>
|
||||
</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}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-email-code">验证码</Label>
|
||||
@@ -375,7 +422,7 @@ export function LoginPage() {
|
||||
: renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||
: null}
|
||||
|
||||
{!showWecomPrimary && (wecomEntry || oidcProvider) ? (
|
||||
{!showWecomPrimary && (showBrowserWecomNotice || oidcProvider) ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
{wecomEntry ? (
|
||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
||||
企业微信登录
|
||||
</Button>
|
||||
{showBrowserWecomNotice ? (
|
||||
<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"
|
||||
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}
|
||||
|
||||
{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([
|
||||
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) => {
|
||||
await route.fulfill({
|
||||
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([
|
||||
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) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -397,10 +426,33 @@ test("邮箱验证码登录可发送验证码并进入工作台", async ({ page
|
||||
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 }) => {
|
||||
const requestedEmails: string[] = [];
|
||||
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) => {
|
||||
const payload = route.request().postDataJSON() as { email?: string };
|
||||
requestedEmails.push(String(payload.email ?? ""));
|
||||
@@ -473,10 +525,10 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
|
||||
await expect(page.locator("summary").filter({ hasText: "我的申请与资产 / 继续工作" })).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 }) => {
|
||||
@@ -501,7 +553,7 @@ test("Portal 管理视角重排正确,备用模块默认折叠", async ({ page
|
||||
"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.getByRole("link", { name: "采购申请" })).not.toBeVisible();
|
||||
await page.locator("summary").filter({ hasText: "备用模块" }).click();
|
||||
@@ -594,7 +646,7 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => {
|
||||
|
||||
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-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 route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
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",
|
||||
body: JSON.stringify(envelope(mockWecomConfigPayload())),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("E2E 企微入口:普通浏览器下只提示企业微信工作台打开", async ({ page }) => {
|
||||
await mockAuthBase(page);
|
||||
await mockWecomConfig(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page.getByTestId("login-password-primary-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("wecom-entry-secondary")).toBeVisible();
|
||||
|
||||
await page.getByTestId("wecom-entry-secondary").click();
|
||||
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
|
||||
await expect(page.getByTestId("wecom-entry-browser-notice")).toContainText("请从企业微信工作台打开 OA");
|
||||
await expect(page.getByTestId("wecom-entry-secondary")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => {
|
||||
@@ -49,16 +70,8 @@ test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未
|
||||
value: "Mozilla/5.0 wxwork/4.1.20 MicroMessenger/7.0.1",
|
||||
});
|
||||
});
|
||||
await page.route("**/api/auth/wecom/config", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
requestId: "mock-wecom-config",
|
||||
data: mockWecomConfigPayload(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
await mockAuthBase(page);
|
||||
await mockWecomConfig(page);
|
||||
await page.route("**/api/auth/wecom/login?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
||||
@@ -121,6 +121,7 @@ async function main() {
|
||||
|
||||
const ready = await fetchJson(`${baseUrl}/ready`);
|
||||
const wecomConfig = await fetchJson(`${baseUrl}/api/auth/wecom/config`);
|
||||
const authCapabilities = await fetchJson(`${baseUrl}/api/auth/capabilities`);
|
||||
const result = {
|
||||
checkedAt: new Date().toISOString(),
|
||||
...checks,
|
||||
@@ -137,6 +138,14 @@ async function main() {
|
||||
defaultReturnTo: wecomConfig.json?.data?.defaultReturnTo ?? 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) {
|
||||
@@ -145,6 +154,7 @@ async function main() {
|
||||
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] 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] liveOcrReady=${result.liveOcrReady}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+80
-7
@@ -8,7 +8,7 @@ const { validatePassword } = require("../utils/passwordPolicy");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { createNotifications } = require("../services/notifications");
|
||||
const { sendNotificationEmailSafe } = require("../services/email");
|
||||
const { resolveEmailDeliveryStatus, sendNotificationEmailSafe } = require("../services/email");
|
||||
const { generators, getOidcClient } = require("../services/oidc");
|
||||
|
||||
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 }) {
|
||||
const activeSessions = await tx.authSession.findMany({
|
||||
where: {
|
||||
@@ -303,6 +350,22 @@ async function authRoutes(fastify) {
|
||||
: undefined;
|
||||
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) => {
|
||||
const parsed = loginBodySchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -574,6 +637,8 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
assertEmailDeliveryReady(fastify.config);
|
||||
|
||||
const { email } = parsed.data;
|
||||
const employee = await fastify.prisma.employee.findFirst({
|
||||
where: { email },
|
||||
@@ -588,6 +653,7 @@ async function authRoutes(fastify) {
|
||||
const codeHash = hashEmailLoginCode(employee.id, code, fastify.config.jwtSecret);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.emailLoginCodeTtlSeconds * 1000);
|
||||
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "login" });
|
||||
|
||||
await fastify.prisma.emailLoginCode.create({
|
||||
data: {
|
||||
@@ -616,8 +682,8 @@ async function authRoutes(fastify) {
|
||||
requestId: request.id,
|
||||
type: "auth.email_login_code_requested",
|
||||
recipientEmployeeIds: [employee.id],
|
||||
subject: "【安全】OA 登录验证码",
|
||||
body: `你的 OA 登录验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
||||
subject: emailMessage.subject,
|
||||
body: emailMessage.body,
|
||||
});
|
||||
|
||||
return sendData(reply, request, {});
|
||||
@@ -1355,7 +1421,7 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -1369,6 +1435,8 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
assertEmailDeliveryReady(fastify.config);
|
||||
|
||||
const { email } = parsed.data;
|
||||
const employee = await fastify.prisma.employee.findFirst({
|
||||
where: { email },
|
||||
@@ -1383,6 +1451,7 @@ async function authRoutes(fastify) {
|
||||
const codeHash = sha256Hex(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + fastify.config.passwordResetCodeTtlSeconds * 1000);
|
||||
const emailMessage = buildAuthVerificationEmail({ code, expiresAt, purpose: "password_reset" });
|
||||
|
||||
await fastify.prisma.passwordResetCode.create({
|
||||
data: {
|
||||
@@ -1427,8 +1496,8 @@ async function authRoutes(fastify) {
|
||||
requestId: request.id,
|
||||
type: "auth.password_reset_requested",
|
||||
recipientEmployeeIds: [employee.id],
|
||||
subject: "【安全】密码重置验证码",
|
||||
body: `你的密码重置验证码为:${code}\n有效期至:${expiresAt.toISOString()}\n如非本人操作,请忽略并联系管理员核查账号安全。`,
|
||||
subject: emailMessage.subject,
|
||||
body: emailMessage.body,
|
||||
});
|
||||
|
||||
if (fastify.config.devShowPasswordResetCode) {
|
||||
@@ -1442,7 +1511,7 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
|
||||
const resetPasswordBodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email().transform((value) => value.toLowerCase()),
|
||||
code: z.string().length(6),
|
||||
newPassword: z.string().min(12),
|
||||
});
|
||||
@@ -1577,3 +1646,7 @@ async function authRoutes(fastify) {
|
||||
}
|
||||
|
||||
module.exports = authRoutes;
|
||||
module.exports._test = {
|
||||
buildAuthVerificationEmail,
|
||||
formatChinaDateTime,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require('dotenv/config');
|
||||
|
||||
const { getConfig } = require('../config');
|
||||
const { resolveEmailDeliveryStatus } = require('../services/email');
|
||||
|
||||
function assertCondition(condition, message) {
|
||||
if (!condition) {
|
||||
@@ -42,17 +43,20 @@ function evaluateWecom(config) {
|
||||
}
|
||||
|
||||
function evaluateEmail(config) {
|
||||
if (!config.emailEnabled) {
|
||||
const status = resolveEmailDeliveryStatus(config);
|
||||
if (status.reasons.includes('email_disabled')) {
|
||||
return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] };
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
if (!String(config.emailFrom ?? '').trim()) missing.push('EMAIL_FROM');
|
||||
if (!String(config.smtpHost ?? '').trim()) missing.push('SMTP_HOST');
|
||||
if (!Number.isFinite(Number(config.smtpPort)) || Number(config.smtpPort) <= 0) missing.push('SMTP_PORT');
|
||||
if (String(config.smtpUser ?? '').trim() && !String(config.smtpPass ?? '').trim()) missing.push('SMTP_PASS');
|
||||
const reasonLabels = {
|
||||
email_from_missing: 'EMAIL_FROM',
|
||||
smtp_host_missing: 'SMTP_HOST',
|
||||
smtp_port_invalid: 'SMTP_PORT',
|
||||
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 {
|
||||
state: 'ready',
|
||||
|
||||
@@ -51,6 +51,37 @@ function getTransporter(config) {
|
||||
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) {
|
||||
return Array.from(new Set((values ?? []).filter(Boolean)));
|
||||
}
|
||||
@@ -168,6 +199,7 @@ async function sendNotificationEmail({
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveEmailDeliveryStatus,
|
||||
sendNotificationEmailSafe,
|
||||
sendNotificationEmail,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ const fastifyCookie = require("@fastify/cookie");
|
||||
|
||||
const authRoutes = require("../../../src/routes/auth");
|
||||
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 EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
@@ -28,15 +28,18 @@ async function buildApp(passwordHash) {
|
||||
login_failed_first_at: null,
|
||||
login_locked_until: null,
|
||||
});
|
||||
const employeeFindMany = vi.fn().mockResolvedValue([]);
|
||||
const employeeUpdate = vi.fn().mockResolvedValue({});
|
||||
const authSessionFindMany = vi.fn().mockResolvedValue([]);
|
||||
const authSessionUpdateMany = vi.fn().mockResolvedValue({ count: 0 });
|
||||
const authSessionCreate = vi.fn().mockResolvedValue({ id: "session-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 emailLoginCodeFindFirst = vi.fn().mockResolvedValue(null);
|
||||
const emailLoginCodeUpdate = vi.fn().mockResolvedValue({ id: "email-code-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 wecomUpsert = vi.fn().mockResolvedValue({ id: "binding-1" });
|
||||
|
||||
@@ -56,6 +59,9 @@ async function buildApp(passwordHash) {
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
notification: {
|
||||
createMany: notificationCreateMany,
|
||||
},
|
||||
wecomIdentityBinding: {
|
||||
findFirst: wecomFindFirst,
|
||||
upsert: wecomUpsert,
|
||||
@@ -72,6 +78,14 @@ async function buildApp(passwordHash) {
|
||||
loginFailMaxAttempts: 5,
|
||||
emailLoginCodeTtlSeconds: 600,
|
||||
emailLoginCodeMaxAttempts: 5,
|
||||
passwordResetCodeTtlSeconds: 600,
|
||||
emailEnabled: true,
|
||||
emailFrom: "[email protected]",
|
||||
smtpHost: "smtp.hyinda.test",
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUser: "",
|
||||
smtpPass: "",
|
||||
rateLimitEnabled: false,
|
||||
jwtSecret: JWT_SECRET,
|
||||
appOrigin: "http://127.0.0.1:3000",
|
||||
@@ -80,14 +94,21 @@ async function buildApp(passwordHash) {
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
findFirst: employeeFindFirst,
|
||||
findMany: employeeFindMany,
|
||||
},
|
||||
emailLoginCode: {
|
||||
create: emailLoginCodeCreate,
|
||||
findFirst: emailLoginCodeFindFirst,
|
||||
},
|
||||
passwordResetCode: {
|
||||
create: passwordResetCodeCreate,
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
},
|
||||
notification: {
|
||||
createMany: notificationCreateMany,
|
||||
},
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
});
|
||||
app.decorate("issueAccessToken", vi.fn().mockReturnValue("access-token"));
|
||||
@@ -110,15 +131,18 @@ async function buildApp(passwordHash) {
|
||||
return {
|
||||
app,
|
||||
employeeFindFirst,
|
||||
employeeFindMany,
|
||||
employeeUpdate,
|
||||
authSessionFindMany,
|
||||
authSessionUpdateMany,
|
||||
authSessionCreate,
|
||||
auditCreate,
|
||||
notificationCreateMany,
|
||||
emailLoginCodeCreate,
|
||||
emailLoginCodeFindFirst,
|
||||
emailLoginCodeUpdate,
|
||||
emailLoginCodeUpdateMany,
|
||||
passwordResetCodeCreate,
|
||||
wecomFindFirst,
|
||||
wecomUpsert,
|
||||
};
|
||||
@@ -209,6 +233,78 @@ describe("auth login route", () => {
|
||||
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 () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, wecomFindFirst, wecomUpsert } = await buildApp(passwordHash);
|
||||
@@ -303,6 +399,26 @@ describe("auth login route", () => {
|
||||
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 () => {
|
||||
const passwordHash = await hashPassword("AdminPassword1234");
|
||||
const { app, employeeFindFirst, emailLoginCodeCreate } = await buildApp(passwordHash);
|
||||
@@ -408,4 +524,56 @@ describe("auth login route", () => {
|
||||
|
||||
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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户