oa-auth-layout-wecom-login-ux 优化认证页登录布局
这个提交包含在:
@@ -0,0 +1,31 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AppBrand } from "../shell/AppBrand";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type AuthPanelProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
"data-testid"?: string;
|
||||
};
|
||||
|
||||
export function AuthPanel({ title, subtitle, children, footer, className, "data-testid": testId }: AuthPanelProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn("w-full rounded-xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="space-y-7">
|
||||
<AppBrand compact />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold leading-7 text-slate-950">{title}</h1>
|
||||
{subtitle ? <p className="mt-1 text-sm leading-6 text-slate-500">{subtitle}</p> : null}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
{footer ? <div className="border-t border-slate-100 pt-5 text-center text-sm">{footer}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { AppBrand } from "../components/shell/AppBrand";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { usePwaUpdatePrompt } from "../hooks/usePwaUpdatePrompt";
|
||||
|
||||
@@ -7,11 +6,8 @@ export function AuthLayout() {
|
||||
const { updateReady, applyUpdate, dismissUpdate } = usePwaUpdatePrompt();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 px-4 py-8 sm:px-6 lg:px-10">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-md flex-col justify-center">
|
||||
<div className="mb-5">
|
||||
<AppBrand />
|
||||
</div>
|
||||
<div className="min-h-screen bg-[var(--color-bg-canvas)] px-4 py-8 sm:px-6 lg:px-10">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-[448px] flex-col justify-center">
|
||||
{updateReady ? (
|
||||
<div className="mb-4 rounded-xl border border-primary/20 bg-primary/5 px-4 py-3 text-sm text-slate-700" data-testid="auth-pwa-update-notice">
|
||||
<div className="font-medium text-slate-900">系统有新版本</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { AuthPanel } from "../components/auth/AuthPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { PasswordInput } from "../components/ui/password-input";
|
||||
@@ -50,17 +51,9 @@ export function ChangePasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-6 border-primary-100 p-6">
|
||||
<div className="mb-6 space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
|
||||
<h1 className="text-2xl font-bold text-zinc-900">修改密码</h1>
|
||||
<p className="text-sm text-secondary">
|
||||
为了账号安全,请先修改初始密码后再进入系统。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AuthPanel title="修改密码" subtitle="为了账号安全,请先修改初始密码">
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="oldPassword">旧密码</Label>
|
||||
<PasswordInput
|
||||
autoComplete="current-password"
|
||||
@@ -72,7 +65,7 @@ export function ChangePasswordPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">新密码</Label>
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
@@ -85,16 +78,7 @@ export function ChangePasswordPage() {
|
||||
<p className="text-xs text-zinc-500">密码要求:{PASSWORD_REQUIREMENTS.join(";")}。</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
<p className="font-medium text-zinc-700">建议的新密码规则</p>
|
||||
<ul className="mt-1 list-disc space-y-1 pl-4">
|
||||
{PASSWORD_REQUIREMENTS.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认新密码</Label>
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
@@ -123,6 +107,6 @@ export function ChangePasswordPage() {
|
||||
{submitting ? "提交中..." : "确认修改"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</AuthPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from "react-router-dom";
|
||||
import { forgotPassword, resetPassword } from "../api/auth";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { AuthPanel } from "../components/auth/AuthPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -93,13 +94,17 @@ export function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6" data-testid="forgot-password-page">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">找回密码</h1>
|
||||
<p className="mt-1 text-sm leading-6 text-slate-500">使用账号邮箱接收验证码,设置新密码后重新登录。</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleRequestCode}>
|
||||
<AuthPanel
|
||||
title="找回密码"
|
||||
subtitle="使用账号邮箱接收验证码"
|
||||
data-testid="forgot-password-page"
|
||||
footer={(
|
||||
<Link className="font-medium text-primary hover:underline" to="/login">
|
||||
返回登录
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleRequestCode}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="forgot-password-email">邮箱</Label>
|
||||
<Input
|
||||
@@ -181,12 +186,6 @@ export function ForgotPasswordPage() {
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-5 text-center text-sm">
|
||||
<Link className="font-medium text-primary hover:underline" to="/login">
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AuthPanel>
|
||||
);
|
||||
}
|
||||
|
||||
+35
-31
@@ -5,6 +5,7 @@ import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { AuthPanel } from "../components/auth/AuthPanel";
|
||||
import { getDeviceFingerprint } from "../auth/deviceFingerprint";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -108,7 +109,7 @@ export function LoginPage() {
|
||||
|
||||
function renderPasswordPanel(testId: string) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm" data-testid={testId}>
|
||||
<div data-testid={testId}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-identifier">账号</Label>
|
||||
@@ -150,29 +151,22 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">登录工作台</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{showWecomPrimary ? "企业微信进入优先。" : "账号密码登录。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<AuthPanel title="登录工作台" subtitle={showWecomPrimary ? "企业微信进入优先" : "账号密码登录"}>
|
||||
<div className="space-y-5">
|
||||
{wecomNotice ? (
|
||||
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
<div className="font-medium">{wecomNotice.title}</div>
|
||||
<div className="mt-1 leading-6">{wecomNotice.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showWecomPrimary ? (
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4" data-testid="login-wecom-primary-panel">
|
||||
<div className="space-y-3" data-testid="login-wecom-primary-panel">
|
||||
<Button className="w-full" data-testid="wecom-entry-primary" disabled={!wecomEntry} type="button" onClick={handleWecomLogin}>
|
||||
{wecomConfigQuery.isLoading ? "准备中..." : "进入工作台"}
|
||||
{wecomConfigQuery.isLoading ? "准备中..." : "企业微信一键进入"}
|
||||
</Button>
|
||||
{wecomConfig && !wecomEntry ? (
|
||||
<div className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-700">
|
||||
企业微信入口不可用,请用账号密码。
|
||||
</div>
|
||||
) : null}
|
||||
@@ -192,25 +186,35 @@ export function LoginPage() {
|
||||
? renderPasswordPanel(showWecomPrimary ? "login-password-secondary-panel" : "login-password-primary-panel")
|
||||
: null}
|
||||
|
||||
{!showWecomPrimary && wecomEntry ? (
|
||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
||||
企业微信登录
|
||||
</Button>
|
||||
) : null}
|
||||
{!showWecomPrimary && (wecomEntry || 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" />
|
||||
<span>其他登录方式</span>
|
||||
<span className="h-px flex-1 bg-slate-100" />
|
||||
</div>
|
||||
|
||||
{!showWecomPrimary && oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
{wecomEntry ? (
|
||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
||||
企业微信登录
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AuthPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { EMPLOYEE, login } from "./helpers/auth";
|
||||
import { openProfileSettings } from "./helpers/navigation";
|
||||
|
||||
function runtimeEmployee() {
|
||||
const identifier = process.env.LIVE_SMOKE_EMPLOYEE_IDENTIFIER?.trim();
|
||||
const password = process.env.LIVE_SMOKE_EMPLOYEE_PASSWORD?.trim();
|
||||
if (identifier && password) {
|
||||
return { identifier, password, name: "Live Smoke Employee" };
|
||||
}
|
||||
return EMPLOYEE;
|
||||
}
|
||||
|
||||
function mockWecomConfigPayload() {
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -52,12 +40,6 @@ test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async (
|
||||
|
||||
await page.getByTestId("wecom-entry-secondary").click();
|
||||
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
|
||||
|
||||
const employee = runtimeEmployee();
|
||||
await login(page, employee);
|
||||
await openProfileSettings(page, employee.name);
|
||||
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
||||
await expect(page.getByText("接入状态", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("E2E 企微入口:企业微信场景优先显示企微 CTA,并展示未绑定提示", async ({ page }) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户