Files
sso-portal/components/login/index.tsx
zhangheng 775b4f6044 chore(sso-portal): 整合近期重构、UX 改进、部署脚本与文档
项目改名 sso-mock → sso-portal:
- 重命名仓库目录 sso-mock/ → sso-portal/
- package.json name 改 sso-portal
- app/layout.tsx title/description 改 SSO Portal
- 源码 [sso-mock] console.log 前缀、注释全部 → [sso-portal]
- 文档(README、ADAPTATION_GUIDE)、env 同步
- 生产 svc 名 sso-mock-svc → sso-portal-svc(external-app 引用同步)

SSO tenant 改环境变量驱动:
- 新增 SSO_TENANT env(默认 cowarobot)
- login/route.ts 顶部 const SSO_TENANT,ssoBody 显式使用
- callSsoApi 改回纯透传(不再硬编码覆盖 tenant)
- external-app 同步用 SSO_TENANT,命名对齐

external-app 登录态改为基于 token:
- page.tsx 读 token cookie 判定 authenticated(不再依赖 user cookie)
- DashboardPage 用 authenticated 决定跳转,user 为 null 时优雅兜底展示
- 解决「200 后又跳回 SSO」bug(access_token 接口不返回 user)

sso-portal 缺 SSO 参数体验优化:
- 缺 app_id/app_url 时 Alert 提示 + 禁用所有输入和登录按钮
- components/login/index.tsx 加 missingSsoParams 检测
- Alert 样式两排完整显示

部署脚本(参考 uirefbase):
- 新增 build.sh(git → pnpm install → build → docker build → tag → push)
- 新增 .gitlab-ci.yml(main 分支触发 build.sh prod)
- 新增 Dockerfile(standalone 模式,端口 5501)
- package.json 加 env-cmd 依赖 + build:stage/build 脚本
- next.config.ts 保持 output: 'standalone'

代码清理:
- 删除 utils/code-store.ts
- 删除 app/api/auth/verify/(连同目录)
- 移除旧的 codeStore / generateCode 逻辑

文档:
- ADAPTATION_GUIDE.md:完整重写对齐真实后端三个 SSO 接口(/sso/account、/sso/phone/smscode、/sso/access_token)
- 新增 INTEGRATION_GUIDE.md(从 external-app 迁移过来作为通用接入指南)
- README.md 同步更新(sso-portal 仓库门面 + 文档索引)
- external-app/README.md 加交叉链接指向 sso-portal 的接入文档
2026-07-08 20:53:51 +08:00

541 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { showErrorMsg, showSuccessMsg } from "@/utils/message";
import {
Alert,
BackgroundImage,
Box,
Button,
Flex,
FloatingIndicator,
Input,
LoadingOverlay,
Paper,
PasswordInput,
Stack,
Tabs,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { IconAlertCircle } from "@tabler/icons-react";
import { DigitalIcon } from "./components/icons/digital";
import DarkBgImage from "./components/images/dark-login-bg.png";
import LightBgImage from "./components/images/light-login-bg.png";
import loginBg2Dark from "./components/images/login-bg2-dark.png";
import loginBg2Light from "./components/images/login-bg2-light.png";
import LogoDigImage from "./components/images/logo-dig.png";
import QrLogin from "./components/login/qr-login";
import { useLoginStore } from "./store";
import tabClasses from "./Tab.module.css";
import { encrypt, getQueryVariable, validatePhoneNumber } from "./util";
const HeaderIcon = DigitalIcon;
const headerTitle = "酷哇数字化平台";
const fixedTenant = "coowa";
export default function LoginPage() {
const colorScheme = useComputedColorScheme();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [addrMap, setAddrMap] = useState<Record<string, string>>({});
const [activeTab, setActiveTab] = useState<string | null>(() => {
// 飞书 OAuth 回调URL 带 ?code=xxx时自动切到飞书 tab
if (typeof window !== "undefined" && getQueryVariable("code")) {
return "feishu";
}
return "account";
});
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const controlsRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
controlsRefs.current[val] = node;
};
const { fingerprint, setFingerprint } = useLoginStore();
// SSO 跳转参数
const isClient = typeof window !== "undefined";
const appId = isClient ? (getQueryVariable("app_id") as string) || "" : "";
const appUrl = isClient
? decodeURIComponent((getQueryVariable("app_url") as string) || "")
: "";
// 缺少 app_id 或 app_url 时不允许登录sso-portal 自身不需要登录)
const missingSsoParams = !appId || !appUrl;
// 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage
useEffect(() => {
if (appId && appUrl) {
localStorage.setItem(appId, appUrl); // app_id -> app_url
localStorage.setItem("sso_app_id", appId); // 记住当前 app_id
}
}, [appId, appUrl]);
// 获取租户简称列表(与 uirefbase 一致)
useEffect(() => {
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/front/getAbbrList`, {
method: "GET",
headers: { "Content-type": "application/json" },
})
.then((response) => response.json())
.then((res) => {
setAddrMap(res?.data?.keys || {});
})
.catch(() => {});
}, []);
// 生成浏览器指纹
useEffect(() => {
const ua = navigator.userAgent;
const screenInfo = `${screen.width}x${screen.height}`;
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const raw = `${ua}|${screenInfo}|${timezone}`;
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = (hash << 5) - hash + raw.charCodeAt(i);
hash |= 0;
}
setFingerprint(Math.abs(hash).toString(36));
}, [setFingerprint]);
// ============ 登录成功后跳转回外部应用 ============
const handleLoginSuccess = useCallback(
(res: any) => {
if (res?.error) {
setError(res.error || "登录失败,请重试");
return;
}
// if (!res?.user_info?.account) {
// setError('登录失败:后端未返回有效用户信息')
// return
// }
// 从 localStorage 获取外部应用地址
const storedAppId = appId || localStorage.getItem("sso_app_id") || "";
const targetUrl =
appUrl || (storedAppId ? localStorage.getItem(storedAppId) || "" : "");
if (res?.code && targetUrl) {
const separator = targetUrl.includes("?") ? "&" : "?";
window.location.href = `${targetUrl}${separator}sso_code=${res.code}`;
return;
}
// 无外部应用地址 — sso-portal 不提供本地登录
setError("缺少外部应用回调地址app_url无法完成 SSO 跳转");
},
[appId, appUrl],
);
// ============ 统一调用 /api/login ============
// SSO 参数优先取 URL其次取 localStorage飞书 OAuth 回调后 URL 参数丢失)
const postLogin = async (payload: object) => {
const effectiveAppId = appId || localStorage.getItem("sso_app_id") || "";
const effectiveAppUrl =
appUrl ||
(effectiveAppId ? localStorage.getItem(effectiveAppId) || "" : "");
const res = await fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/login`,
{
method: "POST",
body: JSON.stringify(
encrypt(
JSON.stringify({
...payload,
fingerprint,
...(effectiveAppId ? { client_id: effectiveAppId } : {}),
...(effectiveAppUrl
? { client_redirect_uri: effectiveAppUrl }
: {}),
}),
),
),
},
)
.then((r) => r.text())
.then((t) => JSON.parse(t));
return res;
};
// ============ Tab 1: 账户登录 ============
const accountForm = useForm({
initialValues: { username: "", password: "" },
validate: {
username: (v) => (!v ? "账户名不能为空" : null),
password: (v) => (!v ? "密码不能为空" : null),
},
validateInputOnChange: true,
validateInputOnBlur: true,
clearInputErrorOnChange: true,
});
const handleAccountSubmit = accountForm.onSubmit(async (values) => {
setLoading(true);
setError(null);
try {
const res = await postLogin({
account: values.username,
password: values.password,
abbr: fixedTenant,
type: "account",
});
handleLoginSuccess(res);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败,请重试");
} finally {
setLoading(false);
}
});
// ============ Tab 2: 手机号登录 ============
const [codeTime, setCodeTime] = useState(0);
const handleCountDown = () => {
setCodeTime(60);
const timer = setInterval(() => {
setCodeTime((t) => {
if (t <= 1) {
clearInterval(timer);
return 0;
}
return t - 1;
});
}, 1000);
};
const handleGetCode = async () => {
if (!validatePhoneNumber(phoneForm.values.phone)) {
setError("请输入正确的手机号");
return;
}
try {
const tntkey = addrMap?.[fixedTenant] || "";
if (!tntkey) {
showErrorMsg("请输入正确的租户");
return;
}
const res: any = await fetch(
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/getVerifyCode`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
phone: phoneForm.values.phone.startsWith("+86")
? phoneForm.values.phone
: `+86${phoneForm.values.phone}`,
tntkey,
}),
},
).then((r) => r.json());
if (res?.code === 200) {
showSuccessMsg("验证码已发送, 请注意查收");
handleCountDown();
} else {
showErrorMsg(res?.message || "验证码发送失败");
}
} catch {
showSuccessMsg("验证码已发送");
handleCountDown();
}
};
const phoneForm = useForm({
initialValues: { phone: "", code: "" },
validate: {
phone: (v) => {
if (!v) return "手机号不能为空";
if (v.length !== 11) return "手机号必须为11位";
return null;
},
code: (v) => {
if (!v) return "验证码不能为空";
if (v.length !== 6) return "验证码必须为6位";
return null;
},
},
validateInputOnChange: true,
validateInputOnBlur: true,
clearInputErrorOnChange: true,
});
const handlePhoneSubmit = phoneForm.onSubmit(async (values) => {
setLoading(true);
setError(null);
try {
const tntkey = addrMap?.[fixedTenant] || "";
if (!tntkey) {
setError("请输入正确的租户");
setLoading(false);
return;
}
const res = await postLogin({
phone: values.phone.startsWith("+86")
? values.phone
: `+86${values.phone}`,
verify_code: values.code,
tntkey,
type: "phone",
});
handleLoginSuccess(res);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败,请重试");
} finally {
setLoading(false);
}
});
// ============ 错误提示 ============
useEffect(() => {
if (error) showErrorMsg(error);
}, [error]);
const bgImage = useMemo(
() => (colorScheme === "dark" ? DarkBgImage : LightBgImage),
[colorScheme],
);
const stackBg2Image = useMemo(
() => (colorScheme === "dark" ? loginBg2Dark : loginBg2Light),
[colorScheme],
);
// 飞书 OAuth 回调中:显示 loading不渲染表单
const isFeishuCallback = isClient && !!getQueryVariable("code");
if (isFeishuCallback) {
return (
<Stack
align="center"
justify="center"
h={"100vh"}
w="100%"
bg={`url(${bgImage.src})`}
bgsz="cover"
bgp="center"
bgr="no-repeat"
>
<Paper withBorder shadow="md" radius="md" p="xl">
<Stack align="center" gap="md">
<LoadingOverlay visible />
<Text size="sm" c="dimmed">
...
</Text>
</Stack>
</Paper>
{/* QrLogin 仍在后台执行 feishuLogin */}
<Box
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
>
<QrLogin onLoginSuccess={handleLoginSuccess} />
</Box>
</Stack>
);
}
return (
<Stack
align="center"
justify="center"
h={"100vh"}
w="100%"
bg={`url(${bgImage.src})`}
bgsz="cover"
bgp="center"
bgr="no-repeat"
>
<Paper
withBorder
shadow="md"
radius="md"
h={520}
maw={"100%"}
style={{ borderColor: "rgba(255, 255, 255, 0.5)" }}
>
<Flex w="100%" h={"100%"}>
{/* 左侧装饰区域 */}
<Stack
w={600}
p={32}
visibleFrom="md"
style={{ position: "relative" }}
>
<Box
w="100%"
h="100%"
style={{ position: "absolute", top: 0, left: 0 }}
>
<BackgroundImage src={stackBg2Image.src} w="100%" h="100%" />
</Box>
<Flex
justify="center"
align="center"
h="100%"
style={{ zIndex: 11 }}
>
<BackgroundImage
src={LogoDigImage.src}
radius="xs"
w={432}
h={310}
visibleFrom="md"
/>
</Flex>
</Stack>
{/* 右侧登录表单 */}
<Stack h={"100%"} w={380} p={"54px 32px"} gap={0}>
<Flex mb={"2rem"} gap={8} justify="center" align={"center"}>
<HeaderIcon />
<Text size="xxl">{headerTitle}</Text>
</Flex>
{/* 缺少 SSO 参数时顶部提示;有参数时显示来源 */}
{missingSsoParams ? (
<Alert
color="yellow"
variant="light"
mb="sm"
icon={<IconAlertCircle size={16} />}
>
<Text size="sm" style={{ lineHeight: 1.5 }}>
SSO app_id / app_url
<br />
</Text>
</Alert>
) : (
appId && (
<Text size="xs" c="dimmed" ta="center" mb="sm">
{appId}
</Text>
)
)}
<Tabs variant="none" value={activeTab} onChange={setActiveTab}>
<Tabs.List ref={setRootRef} className={tabClasses.list}>
<Tabs.Tab
value="account"
ref={setControlRef("account")}
className={tabClasses.tab}
>
</Tabs.Tab>
<Tabs.Tab
value="phone"
ref={setControlRef("phone")}
className={tabClasses.tab}
>
</Tabs.Tab>
{/* TODO: 暂时隐藏飞书扫码 tab恢复时取消下方注释 + 同步恢复 Tabs.Panel value="feishu" */}
{/*
<Tabs.Tab
value="feishu"
ref={setControlRef('feishu')}
className={tabClasses.tab}>
飞书扫码
</Tabs.Tab>
*/}
<FloatingIndicator
target={activeTab ? controlsRefs.current[activeTab] : null}
parent={rootRef}
className={tabClasses.indicator}
/>
</Tabs.List>
{/* 账户登录 */}
<Tabs.Panel value="account">
<form
onSubmit={handleAccountSubmit}
style={{ marginTop: "30px" }}
>
<LoadingOverlay visible={loading} />
<TextInput
withAsterisk
label="账户名"
placeholder="请输入账户名"
mt="md"
disabled={missingSsoParams}
{...accountForm.getInputProps("username")}
/>
<PasswordInput
withAsterisk
label="密码"
placeholder="请输入密码"
mt="md"
disabled={missingSsoParams}
{...accountForm.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" disabled={loading || missingSsoParams}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs.Panel>
{/* 手机号登录 */}
<Tabs.Panel value="phone">
<form
onSubmit={handlePhoneSubmit}
style={{ marginTop: "30px" }}
>
<LoadingOverlay visible={loading} />
<TextInput
withAsterisk
label="手机号"
placeholder="请输入手机号"
mt="md"
disabled={missingSsoParams}
{...phoneForm.getInputProps("phone")}
/>
<Input.Wrapper
label="验证码"
withAsterisk
mt="md"
error={phoneForm.errors.code}
>
<Flex align="center" gap={18}>
<Input
placeholder="请输入验证码"
style={{ flex: 1 }}
disabled={missingSsoParams}
{...phoneForm.getInputProps("code")}
/>
<Button
style={{ flexShrink: 0 }}
type="button"
disabled={
!validatePhoneNumber(phoneForm.values.phone) ||
!!codeTime ||
missingSsoParams
}
onClick={handleGetCode}
>
{codeTime !== 0 ? `${codeTime}秒后获取` : "获取验证码"}
</Button>
</Flex>
</Input.Wrapper>
<Button type="submit" fullWidth mt="xl" disabled={loading || missingSsoParams}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs.Panel>
{/* 飞书扫码(真实二维码) - 暂时隐藏 */}
{false && (
<Tabs.Panel value="feishu">
{isClient && <QrLogin onLoginSuccess={handleLoginSuccess} />}
</Tabs.Panel>
)}
</Tabs>
</Stack>
</Flex>
</Paper>
</Stack>
);
}