Files
sso-portal/app/api/login/route.ts
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

131 lines
3.9 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.

import CryptoJS from "crypto-js";
import { NextResponse } from "next/server";
const CODE_CRYPTO_KEY = "k#8Mp$2Qr&9Tz@4W";
const SHARED_API = (
process.env.NEXT_PUBLIC_SHARED_API_PATH || "http://172.16.115.31:6610"
).replace(/\/+$/, "");
// SSO 接口的租户标识account / phone-smscode / access_token 共用,由环境变量驱动)
const SSO_TENANT = process.env.SSO_TENANT || "cowarobot";
function decrypt(encrypted: string): string {
try {
const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY);
return bytes.toString(CryptoJS.enc.Utf8);
} catch {
return encrypted;
}
}
/**
* 调用真实后端 SSO 端点。
* 透传响应(含后端签发的一次性授权 code不再自造 code。
*/
async function callSsoApi(path: string, body: object) {
const url = `${SHARED_API}${path}`;
console.log("[sso-portal] →", url, body);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let data: any;
try {
data = JSON.parse(text);
} catch {
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`);
}
return { data, status: res.status };
}
// ─── POST /api/login ───
// 与 uirefbase 保持一致:客户端发 JSON.stringify(encrypt(...))
// req.json() 去掉外层引号得到加密字符串decrypt 解密得到原始 JSON
export async function POST(req: Request) {
try {
const decryptData = await req.json(); // JSON 字符串 → 去掉引号 → 加密原文
const body = JSON.parse(decrypt(decryptData));
const type = body?.type;
console.log(
"[sso-portal] login | type:",
type,
"| body keys:",
Object.keys(body || {}),
);
if (!type) {
return NextResponse.json({
error: "缺少 type 字段",
debug_type: typeof type,
});
}
const client_id = body.client_id;
const client_redirect_uri = body.client_redirect_uri;
// OAuth2 授权码流程client_id / client_redirect_uri 为必填(授权 code 必须绑定应用)
if (!client_id || !client_redirect_uri) {
return NextResponse.json({
error: "缺少 client_id 或 client_redirect_uri无法签发授权 code",
});
}
let path: string;
let ssoBody: Record<string, unknown>;
if (type === "account") {
path = "/api/v1/basis/sso/account";
ssoBody = {
account: body.account,
password: body.password,
tenant: SSO_TENANT,
client_id,
client_redirect_uri,
};
} else if (type === "phone") {
path = "/api/v1/basis/sso/phone/smscode";
ssoBody = {
phone: body.phone,
smscode: body.verify_code,
tenant: SSO_TENANT,
client_id,
client_redirect_uri,
};
} else {
console.error("[sso-portal] 未知 type:", type);
return NextResponse.json({ error: `不支持的登录方式: ${type}` });
}
const { data: ssoRes, status } = await callSsoApi(path, ssoBody);
console.log(
"[sso-portal] backend status:",
status,
"| res:",
JSON.stringify(ssoRes).slice(0, 300),
);
// ⚠️ code 在新流程里是「授权码字符串」,不再是业务状态码。
// 兼容平铺 { code } 与外壳 { code:200, data:{ code } } 两种响应。
const authCode = ssoRes?.data?.code ?? ssoRes?.code;
if (!authCode || typeof authCode !== "string") {
const message =
ssoRes?.message || ssoRes?.msg || "登录失败,后端未返回授权 code";
console.log("[sso-portal] no auth code:", message);
return NextResponse.json({ error: message });
}
console.log("[sso-portal] auth code →", authCode);
return NextResponse.json({ code: authCode });
} catch (err: any) {
console.error("[sso-portal] login error:", err.message);
return NextResponse.json({ error: err?.message || "登录失败" });
}
}