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 的接入文档
This commit is contained in:
@@ -3,3 +3,6 @@ NEXT_PUBLIC_BASE_PATH=""
|
||||
|
||||
# 公共服务接口地址(后端 basis 服务:登录 / 验证码 / 租户列表都走这里)
|
||||
NEXT_PUBLIC_SHARED_API_PATH="http://172.16.115.31:6610/"
|
||||
|
||||
# SSO 接口的租户标识(account / phone-smscode / access_token 共用)
|
||||
SSO_TENANT="cowarobot"
|
||||
|
||||
@@ -3,3 +3,6 @@ NEXT_PUBLIC_BASE_PATH=""
|
||||
|
||||
# 公共服务接口地址(后端 basis 服务,集群内 svc 名,与 uirefbase 生产一致)
|
||||
NEXT_PUBLIC_SHARED_API_PATH="http://basis-app-svc:6610"
|
||||
|
||||
# SSO 接口的租户标识(account / phone-smscode / access_token 共用)
|
||||
SSO_TENANT="cowarobot"
|
||||
|
||||
@@ -3,3 +3,6 @@ NEXT_PUBLIC_BASE_PATH=""
|
||||
|
||||
# 公共服务接口地址(后端 basis 服务:登录 / 验证码 / 租户列表都走这里)
|
||||
NEXT_PUBLIC_SHARED_API_PATH="http://172.16.115.31:6610/"
|
||||
|
||||
# SSO 接口的租户标识(account / phone-smscode / access_token 共用)
|
||||
SSO_TENANT="cowarobot"
|
||||
|
||||
28
.gitlab-ci.yml
Normal file
28
.gitlab-ci.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
stages:
|
||||
- sync_github
|
||||
|
||||
sync_github:
|
||||
stage: sync_github
|
||||
tags:
|
||||
- webbuild
|
||||
image: harbor.cowarobot.cn/softrepo/rust_build:1.94
|
||||
script:
|
||||
- echo "代码已合并到 main, 开始执行发布逻辑..."
|
||||
- |
|
||||
for i in {1..10}; do
|
||||
if command -v docker >/dev/null 2>&1 && [ -S /var/run/docker.sock ]; then
|
||||
echo "Docker is ready!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Docker..."
|
||||
sleep 2
|
||||
done
|
||||
- docker info
|
||||
- echo ${CI_PROJECT_NAME} ${CI_PROJECT_PATH} ${CI_PROJECT_DIR} ${CI_PROJECT_URL} ${CI_COMMIT_SHA}
|
||||
- echo ${HARBORKEY} | docker login harbor.cowarobot.cn -u 'robot$softpro+cibuild' --password-stdin
|
||||
- rm -rf ${CI_PROJECT_NAME}
|
||||
- git clone https://oauth2:${TOKEN}@git.cowarobot.com/${CI_PROJECT_PATH}
|
||||
- cd ${CI_PROJECT_NAME}
|
||||
- bash build.sh prod
|
||||
rules:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
@@ -1,174 +1,316 @@
|
||||
# SSO-Mock → 真实后端 适配指南
|
||||
# SSO-Portal → 真实后端 适配指南
|
||||
|
||||
> 目标:当后端提供真实 SSO 接口后,把 sso-mock 从「自己伪造一次性 code」切换为「纯透传真实后端」。
|
||||
> 参考实现:生产前端 `ssofront`(`src/apis/login/index.ts`)已经在用这套契约,本指南以它为准。
|
||||
> 目标:后端已落地真实 SSO 接口。把 sso-portal 从「自造一次性 code + 内存 `codeStore`」切换为「纯透传真实后端」,external-app 改用**标准 OAuth2 授权码流程**换 token。
|
||||
> 本文以 **2026-07 后端提供的三个真实接口**为准。
|
||||
|
||||
> ⚠️ 与早期设想不同:真实后端**没有**采用「单端点 `auth/index` + `login_type` 区分」的设计,而是**按登录方式拆成独立端点**(`/sso/account`、`/sso/phone/smscode`),再用一个标准 OAuth2 端点(`/sso/access_token`)把授权 code 换成 access_token。
|
||||
|
||||
---
|
||||
|
||||
## 1. 结论:需要几个接口?
|
||||
## 1. 后端提供的三个真实接口
|
||||
|
||||
核心是 **2 个**(你已想到),完全对齐生产还需 **1 个辅助 + 2 个已有代理**:
|
||||
| # | 用途 | 接口(POST) | 调用方 | 必须 |
|
||||
| --- | ---------------------------- | --------------------------------------- | -------------------- | ---- |
|
||||
| ① | 账号密码登录 → 返回授权 code | `/api/v1/basis/sso/account` | **sso-portal** | ✅ |
|
||||
| ② | 手机验证码登录 → 返回授权 code | `/api/v1/basis/sso/phone/smscode` | **sso-portal** | ✅ |
|
||||
| ③ | 授权 code → access_token | `/api/v1/basis/sso/access_token` | **external-app(服务端)** | ✅ |
|
||||
|
||||
| # | 用途 | 后端接口(生产实测) | 必须 | 备注 |
|
||||
|---|------|----------------------|------|------|
|
||||
| ① | 登录 → 返回一次性 code | `POST /api/v1/auth/index` | ✅ | **单端点**,用 `login_type` 区分账号/手机/飞书 |
|
||||
| ② | 一次性 code → 用户信息 | `GET /api/v1/auth/inner_get_user_info?code=` | ✅ | 供 external-app 调用 |
|
||||
| ③ | app_id → 应用登录方式 | `GET /api/v1/application/by_client_id?client_id=` | ⭕ | 决定开放哪些登录 tab + 应用名;前端写死可省 |
|
||||
| ④ | 发送手机验证码 | `POST /api/v1/verify_code/send` | ⭕ | 手机登录才需要(sso-mock 现已代理为 basis 版) |
|
||||
| ⑤ | 租户简称列表 | `GET /api/v1/basis/tenant/base/listabbr` | ⭕ | 多租户选择(现已代理) |
|
||||
**宿主**:`http://172.16.115.31:6610`(即 `NEXT_PUBLIC_SHARED_API_PATH`)
|
||||
|
||||
> **一句话**:后端把 ①② 做出来,sso-mock 就能删掉内存 `codeStore` 与假 `/api/auth/verify`,变成纯代理。③ 是"完全对齐生产"的锦上添花。
|
||||
**固定凭据(SSO 应用注册值)**:
|
||||
|
||||
| 字段 | 值 | 持有方 |
|
||||
| --------------- | ---------------------------------- | ------------------------------- |
|
||||
| `client_id` | `6wds5qua3b76hu748zrjmall` | sso-portal(随 URL `app_id` 传入)+ external-app |
|
||||
| `client_secret` | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` | **仅 external-app 服务端** env,绝不下发浏览器 |
|
||||
|
||||
**不变项**:
|
||||
- 发送手机验证码:依旧走 `POST /api/v1/basis/user/phone/code`(body `{ phone, tntkey }`),sso-portal 的 `app/api/getVerifyCode/route.ts` **不改**。
|
||||
- 飞书扫码:UI 已暂时隐藏,后端**未提供**对应 SSO 端点,不在本流程内。
|
||||
|
||||
---
|
||||
|
||||
## 2. 接口契约(字段级)
|
||||
|
||||
### ① 登录换 code — `POST /api/v1/auth/index`
|
||||
|
||||
请求体(三种方式共用,按 `login_type` 带不同字段):
|
||||
### ① 账号密码登录 — `POST /api/v1/basis/sso/account`
|
||||
|
||||
```jsonc
|
||||
// 请求体
|
||||
{
|
||||
"login_type": "password | phone | feishu", // 判别登录方式
|
||||
"client_id": "<app_id>", // 外部应用标识
|
||||
"client_redirect_uri": "<app_url>", // 外部应用回调地址
|
||||
|
||||
// login_type=password
|
||||
"phone": "+86138...", "password": "***",
|
||||
// login_type=phone
|
||||
"phone": "+86138...", "verify_code": "123456",
|
||||
// login_type=feishu
|
||||
"code": "<飞书授权码>", "redirect_uri": "<sso-mock/login 回调>"
|
||||
"account": "string",
|
||||
"password": "string",
|
||||
"tenant": "string", // 租户标识,取自 SSO_TENANT(默认 cowarobot)
|
||||
"client_id": "6wds5qua3b76hu748zrjmall",
|
||||
"client_redirect_uri": "<external-app>/callback"
|
||||
}
|
||||
// 响应
|
||||
{ "code": "<授权 code,一次性、短时效>" }
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{ "code": "一次性 sso_code(短时效、单次使用)" }
|
||||
```
|
||||
|
||||
### ② code 换用户信息 — `GET /api/v1/auth/inner_get_user_info?code=<sso_code>`
|
||||
|
||||
响应(`Login.UserInfo`,节选):
|
||||
### ② 手机验证码登录 — `POST /api/v1/basis/sso/phone/smscode`
|
||||
|
||||
```jsonc
|
||||
// 请求体
|
||||
{
|
||||
"token": "...", "refresh_token": "...",
|
||||
"user": {
|
||||
"id": 1, "name": "张三", "phone": "...",
|
||||
"permissions": { "/dashboard": ["view"] },
|
||||
"feishu": { "avatar_url": "..." }
|
||||
}
|
||||
"phone": "string", // 如 "+8613800000000"
|
||||
"smscode": "string", // 6 位短信验证码
|
||||
"tenant": "string",
|
||||
"client_id": "6wds5qua3b76hu748zrjmall",
|
||||
"client_redirect_uri": "<external-app>/callback"
|
||||
}
|
||||
// 响应
|
||||
{ "code": "<授权 code,一次性、短时效>" }
|
||||
```
|
||||
|
||||
### ③ 应用信息 — `GET /api/v1/application/by_client_id?client_id=<app_id>`
|
||||
> 验证码**发送**仍走 `/api/v1/basis/user/phone/code`(不变),与登录接口 `/sso/phone/smscode` 是两个不同端点,别混淆。
|
||||
|
||||
```json
|
||||
{ "data": { "providers": ["feishu", "password", "wechat"], "display_name": "XX 系统" } }
|
||||
### ③ 授权 code 换 token — `POST /api/v1/basis/sso/access_token`
|
||||
|
||||
```jsonc
|
||||
// 请求体
|
||||
{
|
||||
"client_id": "6wds5qua3b76hu748zrjmall",
|
||||
"client_secret": "oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F", // ⚠️ 仅服务端
|
||||
"code": "<①或②返回的授权 code>",
|
||||
"grant_type": "authorization_code", // 标准 OAuth2 取值
|
||||
"tenant": "string"
|
||||
}
|
||||
// 响应(按 OAuth2 习惯,以后端实测为准)
|
||||
{ "access_token": "...", "refresh_token": "...", "expires_in": 7200, "token_type": "Bearer", "user": { ... } }
|
||||
```
|
||||
|
||||
> ⚠️ **`code` 语义冲突**:basis 旧接口通常包一层业务外壳 `{ code: 200, data: {...}, message }`(此处 `code` 是**业务状态码**);而 ①②③ 里 `code` 是**授权码字符串**。落地时请以后端实测响应为准:若 ①② 返回的是平铺 `{ code: "<授权码>" }`,则直接取;若包了外壳 `{ code:200, data:{ code:"..." } }`,则取 `data.code`。下文代码示意两种都兼容。
|
||||
|
||||
---
|
||||
|
||||
## 3. 标准流程(切到真实后端后)
|
||||
## 3. 标准流程(OAuth2 授权码流程)
|
||||
|
||||
```
|
||||
external-app
|
||||
→ 跳转 sso-mock: /login?app_id=<id>&app_url=<encoded url>
|
||||
↓ 用户在 sso-mock 登录(账号/手机/飞书)
|
||||
→ sso-mock ① POST /api/v1/auth/index ⇒ { code }
|
||||
→ sso-mock 重定向: <app_url>?sso_code=<code>
|
||||
→ 跳转 sso-portal: /login?app_id=6wds5qua3b76hu748zrjmall&app_url=<external-app>/callback
|
||||
↓ 用户在 sso-portal 登录(账号 / 手机)
|
||||
→ sso-portal ① 或 ② 调 /sso/account | /sso/phone/smscode ⇒ 授权 code
|
||||
→ sso-portal 重定向: <app_url>?sso_code=<code>
|
||||
↓
|
||||
→ external-app ② GET /api/v1/auth/inner_get_user_info?code=<code> ⇒ { user, token }
|
||||
→ external-app 建立本地会话
|
||||
→ external-app /api/callback(服务端)③ POST /sso/access_token
|
||||
{ client_id, client_secret, code, grant_type, tenant } ⇒ access_token
|
||||
→ external-app 写 httpOnly Cookie,建立本地会话
|
||||
```
|
||||
|
||||
要点:
|
||||
- `client_secret` 只在 **external-app 服务端**出现,sso-portal 全程不持有。
|
||||
- 授权 `code` 一次性、短时效,**由后端保证**,sso-portal 不再自造、不再存内存。
|
||||
|
||||
---
|
||||
|
||||
## 4. sso-mock 需要改哪些文件
|
||||
## 4. sso-portal 需要改哪些文件
|
||||
|
||||
### 4.1 `app/api/login/route.ts` —— 从"伪造 code"改为"透传 auth/index"
|
||||
### 4.1 `app/api/login/route.ts` —— 改为透传 `/sso/*`,删除 `codeStore`
|
||||
|
||||
**现状**:调 basis 原始登录端点拿到 `user+access_token`,再 `codeStore.set()` 自造 code。
|
||||
**改为**:直接调 `/api/v1/auth/index`,把返回的 `code` 原样透传,**删除 codeStore 相关代码**。
|
||||
**现状**:调 basis 原始登录端点 → 拿 `user + access_token` → `codeStore.set()` 自造 code。
|
||||
**改为**:按 `type` 调对应 `/sso/*` 端点,把后端返回的授权 code 原样透传,**删除** `codeStore` / `generateCode`。
|
||||
|
||||
字段映射(前端解密后 body → 后端请求体):
|
||||
|
||||
| type | 前端字段 | 后端字段(`/sso/*`) |
|
||||
| -------- | -------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| account | `account`, `password`, `abbr`, `client_id`, `client_redirect_uri` | `account`, `password`, `tenant`(来自 `SSO_TENANT` env), `client_id`, `client_redirect_uri` |
|
||||
| phone | `phone`, `verify_code`, `tntkey`, `client_id`, `client_redirect_uri` | `phone`, `smscode`(=verify_code), `tenant`(来自 `SSO_TENANT` env), `client_id`, `client_redirect_uri` |
|
||||
|
||||
代码示意:
|
||||
|
||||
```ts
|
||||
// 三种方式统一映射到 auth/index
|
||||
const payloadMap = {
|
||||
account: { login_type: 'password', phone: body.phone, password: body.password },
|
||||
phone: { login_type: 'phone', phone: body.phone, verify_code: body.verify_code },
|
||||
feishu: { login_type: 'feishu', code: body.code, redirect_uri: body.redirect_uri },
|
||||
// ... decrypt() 不变,删除 import { codeStore } / generateCode ...
|
||||
|
||||
async function callSsoApi(path: string, body: object) {
|
||||
const res = await fetch(`${SHARED_API}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const text = await res.text()
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const decryptData = await req.json()
|
||||
const body = JSON.parse(decrypt(decryptData))
|
||||
const type = body?.type
|
||||
// tenant 由环境变量 SSO_TENANT 驱动(默认 cowarobot),ssoBody 显式使用、callSsoApi 纯透传
|
||||
const tenant = process.env.SSO_TENANT || 'cowarobot'
|
||||
|
||||
let ssoRes: any
|
||||
if (type === 'account') {
|
||||
ssoRes = await callSsoApi('/api/v1/basis/sso/account', {
|
||||
account: body.account,
|
||||
password: body.password,
|
||||
tenant,
|
||||
client_id: body.client_id,
|
||||
client_redirect_uri: body.client_redirect_uri,
|
||||
})
|
||||
} else if (type === 'phone') {
|
||||
ssoRes = await callSsoApi('/api/v1/basis/sso/phone/smscode', {
|
||||
phone: body.phone,
|
||||
smscode: body.verify_code,
|
||||
tenant,
|
||||
client_id: body.client_id,
|
||||
client_redirect_uri: body.client_redirect_uri,
|
||||
})
|
||||
} else {
|
||||
return NextResponse.json({ error: `不支持的登录方式: ${type}` })
|
||||
}
|
||||
|
||||
// ⚠️ 删除旧的 `if (res?.code !== 200)` 判断 —— 现在 code 是授权码字符串,不是业务状态码。
|
||||
// 兼容平铺 / 外壳两种响应;失败时回显后端 message。
|
||||
const authCode = ssoRes?.code ?? ssoRes?.data?.code
|
||||
if (!authCode || typeof authCode !== 'string') {
|
||||
return NextResponse.json({
|
||||
error: ssoRes?.message || ssoRes?.msg || '登录失败,后端未返回授权 code',
|
||||
})
|
||||
}
|
||||
return NextResponse.json({ code: authCode })
|
||||
}
|
||||
const res = await callBasisApi('/api/v1/auth/index', {
|
||||
...payloadMap[type],
|
||||
client_id: body.client_id, // = app_id
|
||||
client_redirect_uri: body.client_redirect_uri, // = app_url
|
||||
})
|
||||
// 直接透传 code,不再 codeStore.set / generateCode
|
||||
return NextResponse.json({ code: res.code })
|
||||
```
|
||||
|
||||
> 注意:`client_id` / `client_redirect_uri` 现在是**必填**(一次性 code 必须绑定应用)。前端 `postLogin` / `qr-login.tsx` 已经在带这两个字段。
|
||||
> 前端 `components/login/index.tsx` 的 `postLogin()` 已携带 `client_id` / `client_redirect_uri`,**无需改动**。`handleLoginSuccess` 已按 `{ code }` 跳转,与新透传结构一致。
|
||||
|
||||
### 4.2 `app/api/auth/verify/route.ts` —— 从"读内存"改为"代理 inner_get_user_info"
|
||||
### 4.2 `utils/code-store.ts` —— 删除
|
||||
|
||||
**现状**:从 `codeStore` 取 user、自造 mock token。
|
||||
**改为**:
|
||||
授权 code 的签发 / 校验 / 一次性 / 过期全部由后端负责,内存 store 不再需要。同步移除 `login/route.ts` 里所有 `import { codeStore }`。
|
||||
|
||||
### 4.3 `app/api/auth/verify/route.ts` —— 删除
|
||||
|
||||
external-app 改为直连后端 `/sso/access_token`(见 §5),sso-portal 不再提供 code 换用户信息的代理。
|
||||
|
||||
### 4.4 `app/api/getVerifyCode/route.ts` —— 不变
|
||||
|
||||
继续代理 `POST /api/v1/basis/user/phone/code`(验证码**发送**逻辑沿用)。
|
||||
|
||||
---
|
||||
|
||||
## 5. external-app 需要改哪些文件
|
||||
|
||||
### 5.1 `app/api/callback/route.ts` —— 改为直连后端 `/sso/access_token`
|
||||
|
||||
**现状**:把 `sso_code` POST 给 sso-portal `/api/auth/verify` 换用户信息。
|
||||
**改为**:服务端直接调后端 `/api/v1/basis/sso/access_token`,带 `client_secret`,拿 token 写 httpOnly Cookie。
|
||||
|
||||
代码示意:
|
||||
|
||||
```ts
|
||||
const BACKEND = (process.env.SSO_BACKEND_API || 'http://172.16.115.31:6610').replace(/\/+$/, '')
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { code } = await req.json()
|
||||
if (!code) return NextResponse.json({ error: 'code 不能为空' }, { status: 400 })
|
||||
const res = await fetch(
|
||||
`${SHARED_API}/api/v1/auth/inner_get_user_info?code=${encodeURIComponent(code)}`
|
||||
)
|
||||
return NextResponse.json(await res.json(), { status: res.status })
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: '缺少 code 参数' }, { status: 400 })
|
||||
}
|
||||
|
||||
const res = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id: process.env.NEXT_PUBLIC_SSO_APP_ID, // 6wds5qua3b76hu748zrjmall
|
||||
client_secret: process.env.SSO_CLIENT_SECRET, // ⚠️ 仅服务端 env,不下发浏览器
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
tenant: process.env.SSO_TENANT || 'cowarobot',
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data?.message || data?.msg || '换 token 失败' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
const token = data?.access_token ?? data?.data?.access_token
|
||||
const refreshToken = data?.refresh_token ?? data?.data?.refresh_token
|
||||
const user = data?.user ?? data?.data?.user
|
||||
|
||||
const response = NextResponse.json({ success: true })
|
||||
if (token) {
|
||||
response.cookies.set('token', token, { httpOnly: true, path: '/', maxAge: 86400 })
|
||||
}
|
||||
if (refreshToken) {
|
||||
response.cookies.set('refresh_token', refreshToken, {
|
||||
httpOnly: true, path: '/', maxAge: 86400 * 7,
|
||||
})
|
||||
}
|
||||
if (user) {
|
||||
response.cookies.set('user', JSON.stringify(user), { path: '/', maxAge: 86400 })
|
||||
}
|
||||
return response
|
||||
}
|
||||
```
|
||||
|
||||
> 或者让 external-app 直接调后端 ②,sso-mock 连这个代理都不用留。
|
||||
### 5.2 跳转链接 —— `app_id` 改为真实 client_id
|
||||
|
||||
### 4.3 `utils/code-store.ts` —— 删除
|
||||
|
||||
真实后端负责 code 的签发/校验/一次性/过期,内存 store 不再需要。同时移除 `route.ts` 里所有 `import { codeStore }`。
|
||||
|
||||
### 4.4 `app/api/getVerifyCode/route.ts` —— 换端点
|
||||
|
||||
`/api/v1/basis/user/phone/code` → `/api/v1/verify_code/send`(若后端统一到新契约)。
|
||||
|
||||
### 4.5(可选)应用信息驱动登录 tab
|
||||
|
||||
新增 `app/api/front/getAppInfo/route.ts` 代理 `③ by_client_id`,前端据 `providers` 动态渲染 tab(对齐 `ssofront/CloudLogin.tsx` 的 `asyncGetAppInfo`)。
|
||||
`app/DashboardPage.tsx` 跳 sso-portal 时 `app_id` 即 OAuth2 `client_id`,需改为 `6wds5qua3b76hu748zrjmall`,并把硬编码的 `localhost` 地址改为环境变量 `NEXT_PUBLIC_SSO_APP_ID` / `NEXT_PUBLIC_SSO_URL` / `NEXT_PUBLIC_APP_URL`(见 §6)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 环境变量
|
||||
## 6. 环境变量
|
||||
|
||||
```bash
|
||||
# .env.local
|
||||
NEXT_PUBLIC_SHARED_API_PATH=http://<真实后端>:<端口> # auth/index、inner_get_user_info 的宿主
|
||||
NEXT_PUBLIC_BASE_PATH= # 部署子路径(如有)
|
||||
```
|
||||
### sso-portal(`.env.development` / `.env.staging` / `.env.production`)
|
||||
|
||||
| 变量 | 说明 | dev / staging | production |
|
||||
| --------------------------------- | --------------------------------------------- | --------------------- | --------------------- |
|
||||
| `NEXT_PUBLIC_SHARED_API_PATH` | `/sso/*` 宿主 | `http://172.16.115.31:6610/` | `http://basis-app-svc:6610` |
|
||||
| `NEXT_PUBLIC_BASE_PATH` | 部署子路径,无则留空 | `""` | `""` |
|
||||
| `SSO_TENANT` | SSO 接口的租户标识(account / phone-smscode / access_token 共用) | `cowarobot` | `cowarobot` |
|
||||
|
||||
> sso-portal **不持有** `client_secret`(仅 external-app 服务端有);`SSO_TENANT` 驱动所有 SSO 接口的 `tenant` 字段。
|
||||
|
||||
### external-app
|
||||
|
||||
| 变量 | 端 | 说明 | 值 / 示例 |
|
||||
| -------------------------- | ------ | ----------------------------------------------------- | ---------------------------------- |
|
||||
| `NEXT_PUBLIC_SSO_APP_ID` | 两端 | OAuth2 `client_id`,作为 URL `app_id` 传给 sso-portal | `6wds5qua3b76hu748zrjmall` |
|
||||
| `NEXT_PUBLIC_SSO_URL` | 客户端 | sso-portal 登录页地址,客户端跳转 `/login` 用 | dev: `http://localhost:5501`,prod: `http://sso-portal-svc:5501` |
|
||||
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 `client_redirect_uri` | dev: `http://localhost:4000` |
|
||||
| `SSO_CLIENT_SECRET` | 服务端 | `/sso/access_token` 鉴权用,**仅服务端** | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` |
|
||||
| `SSO_BACKEND_API` | 服务端 | `/sso/access_token` 宿主 | dev: `http://172.16.115.31:6610`,prod: `http://basis-app-svc:6610` |
|
||||
| `SSO_TENANT` | 服务端 | 租户标识(与 sso-portal 的 `SSO_TENANT` 一致) | `cowarobot` |
|
||||
|
||||
> `SSO_CLIENT_SECRET` 含敏感信息,建议生产环境通过部署平台密钥注入,不入库;本地私有覆盖写入 `.env*.local`(已被 `.gitignore` 忽略)。`NEXT_PUBLIC_SSO_URL`(指向 sso-portal)用于客户端跳转 `/login`,故带 `NEXT_PUBLIC_` 前缀。
|
||||
|
||||
---
|
||||
|
||||
## 6. 迁移检查清单
|
||||
## 7. 迁移检查清单
|
||||
|
||||
- [ ] 后端就绪:`POST /api/v1/auth/index` 返回 `{ code }`
|
||||
- [ ] 后端就绪:`GET /api/v1/auth/inner_get_user_info` 返回用户信息
|
||||
- [ ] `login/route.ts` 改为调 auth/index 并透传 code,删 codeStore 逻辑
|
||||
- [ ] `auth/verify/route.ts` 改为代理 inner_get_user_info(或前端直连后端)
|
||||
- [ ] 删除 `utils/code-store.ts` 及其 import
|
||||
- [ ] `client_id` / `client_redirect_uri` 缺失时前端给出明确报错
|
||||
- [ ] (可选)接入 `by_client_id` 驱动登录 tab
|
||||
- [ ] 端到端验证:飞书回调只发 1 次 auth/index(已修复重复提交),换 code 成功,external-app 用 code 换到用户信息
|
||||
- [ ] 后端就绪:`/sso/account` 返回授权 code
|
||||
- [ ] 后端就绪:`/sso/phone/smscode` 返回授权 code
|
||||
- [ ] 后端就绪:`/sso/access_token` 返回 access_token
|
||||
- [ ] **确认 ①②③ 响应外壳**:`code` 是平铺授权码还是包在 `data.code`(决定解析方式)
|
||||
- [x] **`tenant` 取值已确认**:`cowarobot`(sso-portal 与 external-app 均用此值);**`grant_type`** 仍待确认(暂定 `authorization_code`)
|
||||
- [ ] sso-portal `login/route.ts`:改为透传 `/sso/account` / `/sso/phone/smscode`,删 `codeStore` / `generateCode`,去掉 `code !== 200` 判断
|
||||
- [ ] sso-portal 删除 `utils/code-store.ts`、`app/api/auth/verify/route.ts` 及相关 import
|
||||
- [ ] external-app `app/api/callback/route.ts`:改为服务端直连 `/sso/access_token`
|
||||
- [ ] external-app env:新增 `NEXT_PUBLIC_SSO_URL` / `SSO_CLIENT_SECRET` / `SSO_BACKEND_API` / `SSO_TENANT`,`NEXT_PUBLIC_SSO_APP_ID` 改为 `6wds5qua3b76hu748zrjmall`
|
||||
- [ ] `client_secret` 仅在 external-app 服务端 env,前端代码 / 网络请求中无泄露
|
||||
- [ ] 端到端:账号登录 → 拿 code → external-app 换 token → 建会话;手机登录同路径;验证码发送正常
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端侧现状(无需改动)
|
||||
## 8. 安全注意
|
||||
|
||||
- `client_secret` **绝不下发浏览器**;`/sso/access_token` 一律由 external-app 的服务端 route handler 调用。
|
||||
- `access_token` / `refresh_token` 用 **httpOnly Cookie** 存储,前端不可读。
|
||||
- 授权 `code` 一次性、短时效,由后端签发与校验,sso-portal 不再自造、不缓存。
|
||||
- `client_redirect_uri` 必须与 SSO 注册的回调地址一致,防止开放重定向。
|
||||
|
||||
---
|
||||
|
||||
## 9. 前端侧现状(无需改动)
|
||||
|
||||
登录组件已满足新契约的调用要求:
|
||||
|
||||
- `components/login/index.tsx` `postLogin()`:账号/手机登录已携带 `client_id` / `client_redirect_uri`。
|
||||
- `components/login/components/login/qr-login.tsx`:飞书回调已带 `code / redirect_uri / client_id / client_redirect_uri`,并已加**一次性提交守卫**(`feishuLoginTriggered` ref)防止同一飞书 code 被并发重复提交。
|
||||
- `components/login/index.tsx` `postLogin()`:账号 / 手机登录已携带 `client_id` / `client_redirect_uri`,`handleLoginSuccess` 已按 `{ code }` 重定向回 external-app。
|
||||
- 飞书扫码 tab 已暂时隐藏(`Tabs.Tab value="feishu"` 注释 + `Tabs.Panel` 以 `{false && ...}` 屏蔽),恢复时取消对应注释即可。
|
||||
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# build first
|
||||
FROM harbor.cowarobot.cn/docker.io/node:20-alpine
|
||||
RUN mkdir -p /app
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV production
|
||||
COPY ./.next/standalone ./standalone
|
||||
COPY ./.next/static /app/standalone/.next/static
|
||||
|
||||
ENV PORT 5501
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
EXPOSE 5501
|
||||
|
||||
CMD ["node", "./standalone/server.js"]
|
||||
277
INTEGRATION_GUIDE.md
Normal file
277
INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# SSO 接入指南
|
||||
|
||||
> 适用:任何需要接入本 SSO 体系的前端应用(Next.js / React / Vue / 其他均可参考)。
|
||||
> 参考实现:[`external-app/`](../external-app/)(Next.js 16,完整可运行示例)。
|
||||
> 协议:标准 **OAuth2 授权码流程**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置准备
|
||||
|
||||
向 SSO 管理员申请以下信息,**全部必填**:
|
||||
|
||||
| 字段 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `client_id` | OAuth2 应用标识(同时作为跳转 URL 的 `app_id`) | `6wds5qua3b76hu748zrjmall` |
|
||||
| `client_secret` | 服务端密钥,**⚠️ 仅服务端持有,绝不下发浏览器** | `oXwSxxrRUA0ijJ9N62qWI4oUrVJpLf5F` |
|
||||
| `tenant` | 租户标识 | `cowarobot` |
|
||||
| `callback_url` | 你的应用回调地址,**需与 SSO 注册完全一致** | `https://your-app.example.com/callback` |
|
||||
|
||||
还需确认两个地址:
|
||||
|
||||
| 地址 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `SSO_URL` | SSO 登录页地址(用于跳转 `/login`) | `https://sso.softtest.cowarobot.com` |
|
||||
| `BACKEND_API` | 后端 SSO 宿主(服务端调 `access_token`) | `http://basis-app-svc:6610` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体流程(OAuth2 授权码)
|
||||
|
||||
```
|
||||
[你的应用] [sso-portal] [后端 SSO]
|
||||
│ │ │
|
||||
│ ① 未登录,302 跳转 SSO │ │
|
||||
├──────── /login?app_id=...&app_url=... ────────────▶│ │
|
||||
│ │ │
|
||||
│ │ ② 用户在 SSO 页登录 │
|
||||
│ │ (账号/手机) │
|
||||
│ │ │
|
||||
│ ③ SSO 302 回跳,带一次性 sso_code │ │
|
||||
│◀──────── /callback?sso_code=xxx ───────────────────┤ │
|
||||
│ │ │
|
||||
│ ④ 服务端用 sso_code + client_secret 换 token │ │
|
||||
├──────────── POST /api/v1/basis/sso/access_token ───────────────────────────▶
|
||||
│◀─────────── { access_token, refresh_token, ... } ──────────────────────────┤
|
||||
│ │ │
|
||||
│ ⑤ 写 httpOnly Cookie,重定向首页 │ │
|
||||
│ → 已登录 ✓ │ │
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- **步骤 ④ 必须在你的服务端**完成——`client_secret` 不能出现在前端代码/网络请求中。
|
||||
- `sso_code` **一次性、短时效**,由后端保证,无需额外处理(不要重试同一 code)。
|
||||
- 步骤 ⑤ 写完 cookie 后即可视为登录成功。
|
||||
|
||||
---
|
||||
|
||||
## 3. 步骤详解
|
||||
|
||||
### 3.1 构造 SSO 登录跳转 URL
|
||||
|
||||
未登录时,跳转到 SSO:
|
||||
|
||||
```
|
||||
{SSO_URL}/login?app_id={client_id}&app_url={encodeURIComponent(callback_url)}
|
||||
```
|
||||
|
||||
> `app_url` 必须 `encodeURIComponent`。
|
||||
|
||||
### 3.2 接收回调
|
||||
|
||||
在 `callback_url` 对应的页面/路由接收 `sso_code`(URL query 参数)。
|
||||
|
||||
校验:
|
||||
- `sso_code` 缺失 → 提示错误。
|
||||
- 存在 → 进入 3.3。
|
||||
|
||||
### 3.3 服务端换 token(核心,敏感操作)
|
||||
|
||||
**服务端**调用后端:
|
||||
|
||||
```http
|
||||
POST {BACKEND_API}/api/v1/basis/sso/access_token
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"client_id": "<client_id>",
|
||||
"client_secret": "<client_secret>",
|
||||
"code": "<sso_code>",
|
||||
"grant_type": "authorization_code",
|
||||
"tenant": "<tenant>"
|
||||
}
|
||||
```
|
||||
|
||||
成功响应(当前契约,不含用户信息):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"access_token": "zfa1eIUz0cDUTHA3RVvjjWIL3SbvBu2g",
|
||||
"expires_in": "7200",
|
||||
"refresh_token": "Fpczl1EreLWw3BKhw6ejqa93SchN1rBF"
|
||||
},
|
||||
"msg": ""
|
||||
}
|
||||
```
|
||||
|
||||
> **关于用户信息**:标准 OAuth2 的 `access_token` 端点**只返回 token,不返回 user**。如需展示用户信息,需让后端补一个「用 access_token 换用户信息」的 userinfo 端点。当前 `external-app` 的用户信息卡片会显示「后端未返回用户详情」占位。
|
||||
|
||||
### 3.4 建立会话(写 Cookie)
|
||||
|
||||
| Cookie 名 | 内容 | 属性 |
|
||||
|-----------|------|------|
|
||||
| `token` | `access_token` | `httpOnly`, `path=/`, `maxAge=86400`(或与 `expires_in` 对齐) |
|
||||
| `refresh_token` | `refresh_token`(若有) | `httpOnly`, `path=/`, `maxAge=86400*7` |
|
||||
| `user` | JSON.stringify(user)(若有) | 非 `httpOnly`(用于客户端展示) |
|
||||
|
||||
### 3.5 登录态判定
|
||||
|
||||
**以 `token` cookie 为准**(拿到 `access_token` 即登录成功)。
|
||||
|
||||
- **服务端组件 / SSR**:直接读 `token` cookie 判定。
|
||||
- **客户端组件**:让服务端把 `authenticated: boolean` 标志传下来(**不要**在前端读 `httpOnly` cookie——读不到)。
|
||||
- 避免依赖 `user` cookie 判定登录(后端不返回 user 就会误判为未登录)。
|
||||
|
||||
### 3.6 登出
|
||||
|
||||
清空 `token` / `refresh_token` / `user` cookie(`maxAge=0`),然后跳转回 `{SSO_URL}/login?app_id=...&app_url=...`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键配置项(环境变量)
|
||||
|
||||
下表是 **Next.js 命名约定**(`NEXT_PUBLIC_` 前缀 = 客户端可见;无前缀 = 仅服务端)。其他框架按各自约定映射(例如 React + 独立后端,CLIENT_ID 可能由后端转发或注入 HTML)。
|
||||
|
||||
| 变量(Next.js) | 端 | 说明 |
|
||||
|----------------|----|------|
|
||||
| `NEXT_PUBLIC_SSO_URL` | 客户端 | SSO 登录页地址,构造跳转 URL 用 |
|
||||
| `NEXT_PUBLIC_APP_URL` | 客户端 | 本应用地址,作为 `client_redirect_uri` |
|
||||
| `NEXT_PUBLIC_SSO_APP_ID` | 两端 | OAuth2 `client_id`,同时作为 URL `app_id` |
|
||||
| `SSO_CLIENT_SECRET` | **仅服务端** | `client_secret`,**绝不**下发前端 |
|
||||
| `SSO_BACKEND_API` | 服务端 | 后端 SSO 宿主(调 `access_token`) |
|
||||
| `SSO_TENANT` | 服务端 | 租户标识(如 `cowarobot`) |
|
||||
|
||||
> ⚠️ `SSO_CLIENT_SECRET` 含敏感信息,生产建议通过部署平台密钥注入,**不要**入库。`.env*.local` 已被 `.gitignore` 忽略,本地私有覆盖请写入 `.env*.local`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全要求(必读)
|
||||
|
||||
1. **`client_secret` 绝不**出现在前端代码、URL、浏览器可读 cookie 中。
|
||||
2. `access_token` / `refresh_token` 用 **`httpOnly` Cookie**,路径 `/`。
|
||||
3. `redirect_uri` 必须与 SSO 注册**完全一致**(协议、域名、端口、路径都一致),否则换 token 失败。
|
||||
4. 授权 `sso_code` **一次性、短时效**,由后端保证,前端不要重试。
|
||||
5. 生产环境 Cookie 应设置 `Secure`(HTTPS)和合理的 `SameSite`(Lax/Strict)。
|
||||
6. 敏感日志(如 `access_token` 响应)生产环境应脱敏或关闭。
|
||||
|
||||
---
|
||||
|
||||
## 6. 参考实现:external-app(Next.js)
|
||||
|
||||
完整可运行示例位于 **sibling 仓库** [`../external-app/`](../external-app/):
|
||||
|
||||
```
|
||||
external-app/
|
||||
├── app/
|
||||
│ ├── page.tsx # 首页:服务端读 token cookie → authenticated → DashboardPage
|
||||
│ ├── DashboardPage.tsx # 登录后页面(client 组件,user 可为 null)
|
||||
│ ├── callback/
|
||||
│ │ └── page.tsx # 回调页:收 sso_code → POST /api/callback → 跳首页
|
||||
│ └── api/
|
||||
│ ├── callback/route.ts # 服务端:调 access_token → 写 httpOnly cookie
|
||||
│ └── logout/route.ts # 清 cookie
|
||||
├── .env.development
|
||||
├── .env.staging
|
||||
├── .env.production
|
||||
└── README.md
|
||||
```
|
||||
|
||||
**关键代码片段**(可直接对照阅读完整文件):
|
||||
|
||||
`app/page.tsx`(首页登录态判定):
|
||||
```ts
|
||||
const token = cookieStore.get('token')?.value
|
||||
const authenticated = !!token
|
||||
return <DashboardPage user={user} authenticated={authenticated} />
|
||||
```
|
||||
|
||||
`app/callback/page.tsx`(回调):
|
||||
```ts
|
||||
const code = searchParams.get('sso_code')
|
||||
await fetch('/api/callback', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
window.location.href = '/' // 成功 → 首页
|
||||
```
|
||||
|
||||
`app/api/callback/route.ts`(服务端换 token + 写 cookie):
|
||||
```ts
|
||||
const res = await fetch(`${BACKEND}/api/v1/basis/sso/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_id, client_secret, code,
|
||||
grant_type: 'authorization_code',
|
||||
tenant: SSO_TENANT,
|
||||
}),
|
||||
})
|
||||
const { access_token, refresh_token } = await res.json()
|
||||
response.cookies.set('token', access_token, { httpOnly: true, path: '/', maxAge: 86400 })
|
||||
response.cookies.set('refresh_token', refresh_token, { httpOnly: true, path: '/', maxAge: 86400*7 })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 其他框架适配要点
|
||||
|
||||
### React(非 Next.js,需独立 Node 后端)
|
||||
|
||||
- 跳转/回调与 Next.js 同理,但 `cookieStore` 换后端读取(如 Express `req.cookies`)。
|
||||
- 客户端组件判定登录 → 通过后端 SSR 渲染时注入 `authenticated`,或客户端用 `/api/me`(轻量探活)查。
|
||||
- 跨域时 Cookie 需 `SameSite=Lax/None; Secure`。
|
||||
|
||||
### Vue / 静态前端 + 独立后端
|
||||
|
||||
- 静态前端**不能**直接调 `access_token`(会暴露 `client_secret`),必须经后端中转。
|
||||
- 跳转/回调 HTML 端处理即可;后端提供换 token + 写 cookie 的接口。
|
||||
|
||||
### 跨域注意
|
||||
|
||||
- SSO 与你的应用若不同域:后端换 token 后写 Cookie 需正确 `Domain`/`Path`/`SameSite`。
|
||||
- `sso-portal` 与应用不同源时,跳转 URL 的 `app_url` 必须为**完整可公网访问的 URL**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
| 现象 | 可能原因 / 排查 |
|
||||
|------|----------------|
|
||||
| 拿到 `sso_code` 后**又跳回 SSO** | token cookie 未写入;服务端没读到 token;`authenticated` 未正确传给客户端 |
|
||||
| `access_token` 返回 401 | `client_secret` 错;`redirect_uri` 与注册不一致;`code` 已用过 / 过期 |
|
||||
| `redirect_uri` 报错 | 与 SSO 注册的回调地址完全一致(含 `http`/`https`、端口、尾部斜杠) |
|
||||
| `tenant` 报错 | `SSO_TENANT` 与后端约定不符(当前约定 `cowarobot`) |
|
||||
| 用户信息不展示 | 标准行为——`access_token` 不返回 user。后端补 userinfo 接口即可 |
|
||||
| 同一 code 报「已使用」 | 一次性,由后端保证;不要在客户端重试 |
|
||||
| `code !== 200` 误判 | 切到 OAuth2 流程后,`code` 字段含义变了(业务码 vs 授权码),**不要**再用 `code === 200` 判断成功 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端接口参考(速查)
|
||||
|
||||
| # | 用途 | 接口(POST) | 调用方 |
|
||||
|---|------|--------------|--------|
|
||||
| ① | 账号密码登录 → 授权 code | `/api/v1/basis/sso/account` | sso-portal |
|
||||
| ② | 手机验证码登录 → 授权 code | `/api/v1/basis/sso/phone/smscode` | sso-portal |
|
||||
| ③ | 授权 code → access_token | `/api/v1/basis/sso/access_token` | **你的应用服务端** |
|
||||
|
||||
③ 请求体:
|
||||
```json
|
||||
{
|
||||
"client_id": "...",
|
||||
"client_secret": "...",
|
||||
"code": "...",
|
||||
"grant_type": "authorization_code",
|
||||
"tenant": "cowarobot"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 一句话总结
|
||||
|
||||
> 跳转 `{SSO_URL}/login?app_id=...&app_url=...` → 接 `sso_code` → **服务端**用 `client_secret` 换 `access_token` → 写 `httpOnly` cookie → 完成。
|
||||
|
||||
照此即可接入。有问题对照 [`external-app/`](../external-app/) 实现,或查本仓库的 [`ADAPTATION_GUIDE.md`](./ADAPTATION_GUIDE.md)。
|
||||
32
README.md
32
README.md
@@ -1,6 +1,8 @@
|
||||
# Mock SSO
|
||||
# SSO Portal
|
||||
|
||||
基于 Next.js 16 + Mantine 8 的 Mock SSO 登录服务,登录页复用 uirefbase 的 UI 风格。
|
||||
基于 Next.js 16 + Mantine 8 的 SSO Portal 登录服务,登录页复用 uirefbase 的 UI 风格。
|
||||
|
||||
> 📘 **接入指南**:[INTEGRATION_GUIDE.md](./INTEGRATION_GUIDE.md) —— 面向其他前端应用的通用接入文档(OAuth2 授权码流程 / 环境变量 / 安全 / FAQ / 后端接口速查)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -18,6 +20,7 @@ pnpm dev # http://localhost:5501
|
||||
|------|------|------|
|
||||
| `NEXT_PUBLIC_BASE_PATH` | 部署子路径,无则留空 | `""` |
|
||||
| `NEXT_PUBLIC_SHARED_API_PATH` | 后端 basis 服务地址(登录 / 验证码 / 租户列表都走这里) | dev: `http://172.16.115.31:6610/`<br>prod: `http://basis-app-svc:6610` |
|
||||
| `SSO_TENANT` | SSO 接口的租户标识(account / phone-smscode / access_token 共用) | `cowarobot` |
|
||||
|
||||
> 无密钥,可安全入库。Next.js 会按 `NODE_ENV` 自动加载对应文件(`next dev` → development,`next build/start` → production)。
|
||||
|
||||
@@ -30,7 +33,7 @@ pnpm dev # http://localhost:5501
|
||||
| `user01` | `123456` | 测试用户 |
|
||||
| `zhangsan` | `123456` | 张三 |
|
||||
|
||||
手机号登录:任意 11 位手机号 + 任意 6 位验证码即可。
|
||||
手机号登录:验证码由真实后端 `/api/v1/basis/user/phone/code` 发送,需使用后端真实账号对应的手机号(账号同理以真实后端数据为准)。
|
||||
|
||||
## SSO 登录流程
|
||||
|
||||
@@ -48,24 +51,33 @@ http://localhost:5501/login?app_id=your_app_id&app_url=http%3A%2F%2Flocalhost%3A
|
||||
http://localhost:3000/callback?sso_code=xxxxx
|
||||
```
|
||||
|
||||
### 4. 外部应用用 code 换取用户信息
|
||||
### 4. 外部应用用 code 换取 access_token
|
||||
|
||||
外部应用拿到 `sso_code` 后,由其**服务端**直接调真实后端换 token(sso-portal 不再提供 code 换信息接口):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5501/api/auth/verify \
|
||||
curl -X POST http://172.16.115.31:6610/api/v1/basis/sso/access_token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"code": "your_sso_code"}'
|
||||
-d '{
|
||||
"client_id": "6wds5qua3b76hu748zrjmall",
|
||||
"client_secret": "<服务端密钥>",
|
||||
"code": "your_sso_code",
|
||||
"grant_type": "authorization_code",
|
||||
"tenant": "cowarobot"
|
||||
}'
|
||||
```
|
||||
|
||||
返回用户信息和 token。
|
||||
返回 `access_token`(及可选 `refresh_token` / `user`),外部应用据此建立会话。
|
||||
|
||||
## 直接访问
|
||||
|
||||
不带 `app_id` / `app_url` 参数访问 `/login`,登录成功后跳转到 `/dashboard` 管理后台。
|
||||
> 当前 sso-portal 为纯 SSO 透传模式:`/login` 必须带 `app_id` / `app_url` 参数,登录成功后回跳外部应用;不再支持无 SSO 参数的本地登录(缺参时后端登录接口会拒绝签发授权 code)。
|
||||
|
||||
## API
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/auth/login` | POST | 登录(返回 code 或 token) |
|
||||
| `/api/auth/verify` | POST | 验证 code 换取用户信息 |
|
||||
| `/api/login` | POST | 账号 / 手机登录,透传后端 `/sso/*` 返回授权 code |
|
||||
| `/api/getVerifyCode` | POST | 发送手机验证码(透传 `/api/v1/basis/user/phone/code`) |
|
||||
| `/api/front/getAbbrList` | GET | 租户简称列表 |
|
||||
| `/api/mock/users` | GET | 测试用户列表 |
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { codeStore } from '@/utils/code-store'
|
||||
|
||||
/**
|
||||
* SSO Code 验证接口
|
||||
*
|
||||
* 外部应用拿到 sso_code 后,调用此接口换取用户信息。
|
||||
* POST /api/auth/verify
|
||||
* Body: { code: string }
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { code } = await req.json()
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json({ error: 'code 不能为空' }, { status: 400 })
|
||||
}
|
||||
|
||||
const entry = codeStore.get(code)
|
||||
if (!entry) {
|
||||
return NextResponse.json({ error: 'code 无效或已过期' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (entry.expires < Date.now()) {
|
||||
codeStore.delete(code)
|
||||
return NextResponse.json({ error: 'code 已过期' }, { status: 401 })
|
||||
}
|
||||
|
||||
// 验证通过,删除已使用的 code(一次性)
|
||||
codeStore.delete(code)
|
||||
|
||||
// 返回用户信息和 token
|
||||
const token =
|
||||
'mock_token_' +
|
||||
Math.random().toString(36).substring(2) +
|
||||
'_' +
|
||||
Date.now()
|
||||
|
||||
return NextResponse.json({
|
||||
token,
|
||||
refresh_token: 'mock_refresh_' + Date.now(),
|
||||
user: entry.user,
|
||||
permissions: [
|
||||
{ object: '/dashboard', action: 'view' },
|
||||
{ object: '/settings', action: 'view' },
|
||||
],
|
||||
})
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || '验证失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ const SHARED_API = (
|
||||
|
||||
export async function GET() {
|
||||
const fetchUrl = `${SHARED_API}/api/v1/basis/tenant/base/listabbr`
|
||||
console.log('[sso-mock] getAbbrList:', fetchUrl)
|
||||
console.log('[sso-portal] getAbbrList:', fetchUrl)
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
@@ -24,7 +24,7 @@ export async function GET() {
|
||||
const data = await res.json()
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (err: any) {
|
||||
console.error('[sso-mock] getAbbrList error:', err.message)
|
||||
console.error('[sso-portal] getAbbrList error:', err.message)
|
||||
return NextResponse.json(
|
||||
{ message: err?.message || '获取租户信息失败' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -13,7 +13,7 @@ const SHARED_API = (
|
||||
export async function POST(req: Request) {
|
||||
const body = await req.json()
|
||||
const fetchUrl = `${SHARED_API}/api/v1/basis/user/phone/code`
|
||||
console.log('[sso-mock] getVerifyCode →', fetchUrl, '| phone:', body?.phone)
|
||||
console.log('[sso-portal] getVerifyCode →', fetchUrl, '| phone:', body?.phone)
|
||||
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
@@ -41,7 +41,7 @@ export async function POST(req: Request) {
|
||||
|
||||
return NextResponse.json(data, { status: 200 })
|
||||
} catch (err: any) {
|
||||
console.error('[sso-mock] getVerifyCode error:', err.message)
|
||||
console.error('[sso-portal] getVerifyCode error:', err.message)
|
||||
return NextResponse.json(
|
||||
{ code: 500, message: err?.message || '验证码发送失败' },
|
||||
{ status: 500 }
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { codeStore } from '@/utils/code-store'
|
||||
import CryptoJS from "crypto-js";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const CODE_CRYPTO_KEY = 'k#8Mp$2Qr&9Tz@4W'
|
||||
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(/\/+$/, '')
|
||||
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)
|
||||
const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY);
|
||||
return bytes.toString(CryptoJS.enc.Utf8);
|
||||
} catch {
|
||||
return encrypted
|
||||
return encrypted;
|
||||
}
|
||||
}
|
||||
|
||||
function generateCode(): string {
|
||||
return Math.random().toString(36).substring(2, 10) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
async function callBasisApi(path: string, body: object) {
|
||||
const url = `${SHARED_API}${path}`
|
||||
console.log('[sso-mock] →', url)
|
||||
/**
|
||||
* 调用真实后端 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' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
});
|
||||
|
||||
const text = await res.text()
|
||||
let data: any
|
||||
const text = await res.text();
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`)
|
||||
throw new Error(`后端返回非 JSON [${res.status}]: ${text.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
return data
|
||||
return { data, status: res.status };
|
||||
}
|
||||
|
||||
// ─── POST /api/login ───
|
||||
@@ -47,79 +49,82 @@ async function callBasisApi(path: string, body: object) {
|
||||
// req.json() 去掉外层引号得到加密字符串,decrypt 解密得到原始 JSON
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const decryptData = await req.json() // JSON 字符串 → 去掉引号 → 加密原文
|
||||
const body = JSON.parse(decrypt(decryptData))
|
||||
const decryptData = await req.json(); // JSON 字符串 → 去掉引号 → 加密原文
|
||||
const body = JSON.parse(decrypt(decryptData));
|
||||
|
||||
const type = body?.type
|
||||
console.log('[sso-mock] login | type:', type, '| body keys:', Object.keys(body || {}))
|
||||
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 })
|
||||
return NextResponse.json({
|
||||
error: "缺少 type 字段",
|
||||
debug_type: typeof type,
|
||||
});
|
||||
}
|
||||
|
||||
let res: any
|
||||
const client_id = body.client_id;
|
||||
const client_redirect_uri = body.client_redirect_uri;
|
||||
|
||||
if (type === 'account') {
|
||||
res = await callBasisApi('/api/v1/basis/login/account', {
|
||||
// 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,
|
||||
abbr: body.abbr,
|
||||
})
|
||||
} else if (type === 'phone') {
|
||||
res = await callBasisApi('/api/v1/basis/user/phone/login', {
|
||||
tenant: SSO_TENANT,
|
||||
client_id,
|
||||
client_redirect_uri,
|
||||
};
|
||||
} else if (type === "phone") {
|
||||
path = "/api/v1/basis/sso/phone/smscode";
|
||||
ssoBody = {
|
||||
phone: body.phone,
|
||||
verify_code: body.verify_code,
|
||||
tntkey: body.tntkey,
|
||||
})
|
||||
} else if (type === 'feishu') {
|
||||
res = await callBasisApi('/api/v1/basis/login/feishu', {
|
||||
code: body.code,
|
||||
redirect_uri: body.redirect_uri,
|
||||
tenant_abbr: body.tenant,
|
||||
})
|
||||
smscode: body.verify_code,
|
||||
tenant: SSO_TENANT,
|
||||
client_id,
|
||||
client_redirect_uri,
|
||||
};
|
||||
} else {
|
||||
console.error('[sso-mock] 未知 type:', type)
|
||||
return NextResponse.json({ error: `不支持的登录方式: ${type}` })
|
||||
console.error("[sso-portal] 未知 type:", type);
|
||||
return NextResponse.json({ error: `不支持的登录方式: ${type}` });
|
||||
}
|
||||
|
||||
console.log('[sso-mock] backend res.code:', res?.code)
|
||||
const { data: ssoRes, status } = await callSsoApi(path, ssoBody);
|
||||
console.log(
|
||||
"[sso-portal] backend status:",
|
||||
status,
|
||||
"| res:",
|
||||
JSON.stringify(ssoRes).slice(0, 300),
|
||||
);
|
||||
|
||||
// 后端业务码非 200 视为失败,直接返回错误,不生成 code
|
||||
if (res?.code !== 200) {
|
||||
const message = res?.message || res?.msg || '登录失败'
|
||||
console.log('[sso-mock] backend error:', message)
|
||||
return NextResponse.json({ error: message })
|
||||
// ⚠️ 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 });
|
||||
}
|
||||
|
||||
const user = res.data?.user
|
||||
if (!user || !user.account) {
|
||||
console.log('[sso-mock] backend returned 200 but no valid user')
|
||||
return NextResponse.json({ error: '后端未返回有效用户信息' })
|
||||
}
|
||||
|
||||
const tenant = user?.tntkey || body.abbr || 'coowa'
|
||||
const user_info = user
|
||||
const rbac = res?.data?.rbac || { data: [] }
|
||||
const token = res.data?.access_token || ''
|
||||
|
||||
const responseData: any = { tenant, user_info, rbac, token }
|
||||
|
||||
// SSO 模式:仅当 client_id + client_redirect_uri 都存在时生成 code
|
||||
const client_id = body.client_id
|
||||
const client_redirect_uri = body.client_redirect_uri
|
||||
if (client_id && client_redirect_uri) {
|
||||
const code = generateCode()
|
||||
codeStore.set(code, {
|
||||
user: user_info,
|
||||
expires: Date.now() + 5 * 60 * 1000,
|
||||
})
|
||||
responseData.code = code
|
||||
console.log('[sso-mock] code generated →', code)
|
||||
}
|
||||
|
||||
return NextResponse.json(responseData)
|
||||
console.log("[sso-portal] auth code →", authCode);
|
||||
return NextResponse.json({ code: authCode });
|
||||
} catch (err: any) {
|
||||
console.error('[sso-mock] login error:', err.message)
|
||||
return NextResponse.json({ error: err?.message || '登录失败' })
|
||||
console.error("[sso-portal] login error:", err.message);
|
||||
return NextResponse.json({ error: err?.message || "登录失败" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ import '@mantine/core/styles.layer.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Mock SSO',
|
||||
description: 'Mock SSO Login Provider',
|
||||
title: 'SSO Portal',
|
||||
description: 'SSO Login Portal',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
57
build.sh
Executable file
57
build.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
SHELL_FOLDER=$(cd "$(dirname "$0")";pwd)
|
||||
HOME_PATH=$(cd ~;pwd)
|
||||
|
||||
# 用法:
|
||||
# ./build.sh 预发布(默认 staging)
|
||||
# ./build.sh prod 生产
|
||||
TAG=stage_latest
|
||||
BUILD_FLAG=build:stage
|
||||
if [[ $1 == prod ]]; then
|
||||
TAG=prod_latest
|
||||
BUILD_FLAG=build:prod
|
||||
fi
|
||||
|
||||
cd $SHELL_FOLDER
|
||||
|
||||
# # 检查 git 状态
|
||||
# echo -e "${GREEN}Checking git status...${NC}"
|
||||
# git fetch
|
||||
# git merge
|
||||
# if [[ $? != 0 ]]; then echo -e "${RED}git merge error${NC}"; exit 100; fi
|
||||
|
||||
# BRANCH_NAME=`git branch | grep "*"`
|
||||
# echo -e "${GREEN}Current branch: ${BRANCH_NAME/* /}${NC}"
|
||||
|
||||
# 安装依赖
|
||||
echo -e "${GREEN}Installing dependencies...${NC}"
|
||||
pnpm install
|
||||
if [[ $? != 0 ]]; then echo -e "${RED}pnpm install failed${NC}"; exit 100; fi
|
||||
|
||||
# 执行构建
|
||||
echo -e "${GREEN}Building project...${NC}"
|
||||
pnpm run $BUILD_FLAG
|
||||
if [[ $? != 0 ]]; then echo -e "${RED}build error${NC}"; exit 100; fi
|
||||
|
||||
# Docker 构建和推送
|
||||
MAIN_NAME=web_sso_portal
|
||||
DOCKER_FILE=$SHELL_FOLDER/Dockerfile
|
||||
HARBOR_REPO=softrepo
|
||||
if [[ $ENV == prod ]]; then
|
||||
HARBOR_REPO=softpro
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Building docker image...${NC}"
|
||||
docker build -f $DOCKER_FILE -t $MAIN_NAME $SHELL_FOLDER
|
||||
if [[ $? != 0 ]]; then echo -e "${RED}build docker with error${NC}"; exit 100; fi
|
||||
|
||||
echo -e "${GREEN}Tagging and pushing docker image...${NC}"
|
||||
docker tag $MAIN_NAME harbor.cowarobot.cn/$HARBOR_REPO/$MAIN_NAME:$TAG
|
||||
docker push harbor.cowarobot.cn/$HARBOR_REPO/$MAIN_NAME:$TAG
|
||||
|
||||
echo -e "${GREEN}Build completed successfully${NC}"
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../util'
|
||||
|
||||
/**
|
||||
* 飞书二维码登录组件(sso-mock 版本)
|
||||
* 飞书二维码登录组件(sso-portal 版本)
|
||||
*
|
||||
* 流程:
|
||||
* 1. 加载飞书 SDK,渲染真实二维码
|
||||
@@ -25,7 +25,7 @@ const QrLogin = ({
|
||||
}: {
|
||||
onLoginSuccess: (res: any) => void
|
||||
}) => {
|
||||
// 飞书 OAuth 回调地址:始终指向 sso-mock 自己的 /login
|
||||
// 飞书 OAuth 回调地址:始终指向 sso-portal 自己的 /login
|
||||
const redirect_url =
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
@@ -47,7 +47,7 @@ const QrLogin = ({
|
||||
const renderCode = useCallback(async () => {
|
||||
await qr_load()
|
||||
// APP_ID: 飞书应用 ID
|
||||
// redirect_url: 飞书 OAuth 回调地址(sso-mock 自身)
|
||||
// redirect_url: 飞书 OAuth 回调地址(sso-portal 自身)
|
||||
// state: SSO 的 app_id(传给飞书,回调时原样返回)
|
||||
qr_login(APP_ID, redirect_url, ssoAppId)
|
||||
}, [redirect_url, ssoAppId])
|
||||
|
||||
@@ -1,361 +1,382 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { showErrorMsg, showSuccessMsg } from "@/utils/message";
|
||||
import {
|
||||
Box,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Paper,
|
||||
Text,
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
Flex,
|
||||
Alert,
|
||||
BackgroundImage,
|
||||
Tabs,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FloatingIndicator,
|
||||
Input,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core'
|
||||
import { showErrorMsg, showSuccessMsg } from '@/utils/message'
|
||||
import { useLoginStore } from './store'
|
||||
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 tabClasses from './Tab.module.css'
|
||||
import { DigitalIcon } from './components/icons/digital'
|
||||
import { encrypt, getQueryVariable, validatePhoneNumber } from './util'
|
||||
import QrLogin from './components/login/qr-login'
|
||||
} 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'
|
||||
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 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'
|
||||
if (typeof window !== "undefined" && getQueryVariable("code")) {
|
||||
return "feishu";
|
||||
}
|
||||
return 'account'
|
||||
})
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
|
||||
const controlsRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||
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
|
||||
}
|
||||
controlsRefs.current[val] = node;
|
||||
};
|
||||
|
||||
const { fingerprint, setFingerprint } = useLoginStore()
|
||||
const { fingerprint, setFingerprint } = useLoginStore();
|
||||
|
||||
// SSO 跳转参数
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const appId = isClient ? (getQueryVariable('app_id') as string) || '' : ''
|
||||
const isClient = typeof window !== "undefined";
|
||||
const appId = isClient ? (getQueryVariable("app_id") as string) || "" : "";
|
||||
const appUrl = isClient
|
||||
? decodeURIComponent((getQueryVariable('app_url') as string) || '')
|
||||
: ''
|
||||
? 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
|
||||
localStorage.setItem(appId, appUrl); // app_id -> app_url
|
||||
localStorage.setItem("sso_app_id", appId); // 记住当前 app_id
|
||||
}
|
||||
}, [appId, appUrl])
|
||||
}, [appId, appUrl]);
|
||||
|
||||
// 获取租户简称列表(与 uirefbase 一致)
|
||||
useEffect(() => {
|
||||
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/front/getAbbrList`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-type': 'application/json' },
|
||||
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 || {})
|
||||
setAddrMap(res?.data?.keys || {});
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
.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
|
||||
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
|
||||
hash = (hash << 5) - hash + raw.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
setFingerprint(Math.abs(hash).toString(36))
|
||||
}, [setFingerprint])
|
||||
setFingerprint(Math.abs(hash).toString(36));
|
||||
}, [setFingerprint]);
|
||||
|
||||
// ============ 登录成功后跳转回外部应用 ============
|
||||
const handleLoginSuccess = useCallback((res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res.error || '登录失败,请重试')
|
||||
return
|
||||
}
|
||||
const handleLoginSuccess = useCallback(
|
||||
(res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res.error || "登录失败,请重试");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res?.user_info?.account) {
|
||||
setError('登录失败:后端未返回有效用户信息')
|
||||
return
|
||||
}
|
||||
// if (!res?.user_info?.account) {
|
||||
// setError('登录失败:后端未返回有效用户信息')
|
||||
// return
|
||||
// }
|
||||
|
||||
// 从 localStorage 获取外部应用地址
|
||||
const storedAppId = appId || localStorage.getItem('sso_app_id') || ''
|
||||
const targetUrl =
|
||||
appUrl || (storedAppId ? localStorage.getItem(storedAppId) || '' : '')
|
||||
// 从 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
|
||||
}
|
||||
if (res?.code && targetUrl) {
|
||||
const separator = targetUrl.includes("?") ? "&" : "?";
|
||||
window.location.href = `${targetUrl}${separator}sso_code=${res.code}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 无外部应用地址 — sso-mock 不提供本地登录
|
||||
setError('缺少外部应用回调地址(app_url),无法完成 SSO 跳转')
|
||||
}, [appId, appUrl])
|
||||
// 无外部应用地址 — 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 effectiveAppId = appId || localStorage.getItem("sso_app_id") || "";
|
||||
const effectiveAppUrl =
|
||||
appUrl || (effectiveAppId ? localStorage.getItem(effectiveAppId) || '' : '')
|
||||
appUrl ||
|
||||
(effectiveAppId ? localStorage.getItem(effectiveAppId) || "" : "");
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/login`,
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/login`,
|
||||
{
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
...payload,
|
||||
fingerprint,
|
||||
...(effectiveAppId ? { client_id: effectiveAppId } : {}),
|
||||
...(effectiveAppUrl ? { client_redirect_uri: effectiveAppUrl } : {}),
|
||||
})
|
||||
)
|
||||
...(effectiveAppUrl
|
||||
? { client_redirect_uri: effectiveAppUrl }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
.then((r) => r.text())
|
||||
.then((t) => JSON.parse(t))
|
||||
.then((t) => JSON.parse(t));
|
||||
|
||||
return res
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// ============ Tab 1: 账户登录 ============
|
||||
const accountForm = useForm({
|
||||
initialValues: { username: '', password: '' },
|
||||
initialValues: { username: "", password: "" },
|
||||
validate: {
|
||||
username: (v) => (!v ? '账户名不能为空' : null),
|
||||
password: (v) => (!v ? '密码不能为空' : null),
|
||||
username: (v) => (!v ? "账户名不能为空" : null),
|
||||
password: (v) => (!v ? "密码不能为空" : null),
|
||||
},
|
||||
validateInputOnChange: true,
|
||||
validateInputOnBlur: true,
|
||||
clearInputErrorOnChange: true,
|
||||
})
|
||||
});
|
||||
|
||||
const handleAccountSubmit = accountForm.onSubmit(async (values) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await postLogin({
|
||||
account: values.username,
|
||||
password: values.password,
|
||||
abbr: fixedTenant,
|
||||
type: 'account',
|
||||
})
|
||||
handleLoginSuccess(res)
|
||||
type: "account",
|
||||
});
|
||||
handleLoginSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||||
setError(err instanceof Error ? err.message : "登录失败,请重试");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ============ Tab 2: 手机号登录 ============
|
||||
const [codeTime, setCodeTime] = useState(0)
|
||||
const [codeTime, setCodeTime] = useState(0);
|
||||
|
||||
const handleCountDown = () => {
|
||||
setCodeTime(60)
|
||||
setCodeTime(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeTime((t) => {
|
||||
if (t <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return t - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
return t - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleGetCode = async () => {
|
||||
if (!validatePhoneNumber(phoneForm.values.phone)) {
|
||||
setError('请输入正确的手机号')
|
||||
return
|
||||
setError("请输入正确的手机号");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tntkey = addrMap?.[fixedTenant] || ''
|
||||
const tntkey = addrMap?.[fixedTenant] || "";
|
||||
if (!tntkey) {
|
||||
showErrorMsg('请输入正确的租户')
|
||||
return
|
||||
showErrorMsg("请输入正确的租户");
|
||||
return;
|
||||
}
|
||||
const res: any = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/getVerifyCode`,
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/getVerifyCode`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
phone: phoneForm.values.phone.startsWith('+86')
|
||||
phone: phoneForm.values.phone.startsWith("+86")
|
||||
? phoneForm.values.phone
|
||||
: `+86${phoneForm.values.phone}`,
|
||||
tntkey,
|
||||
}),
|
||||
}
|
||||
).then((r) => r.json())
|
||||
},
|
||||
).then((r) => r.json());
|
||||
|
||||
if (res?.code === 200) {
|
||||
showSuccessMsg('验证码已发送, 请注意查收')
|
||||
handleCountDown()
|
||||
showSuccessMsg("验证码已发送, 请注意查收");
|
||||
handleCountDown();
|
||||
} else {
|
||||
showErrorMsg(res?.message || '验证码发送失败')
|
||||
showErrorMsg(res?.message || "验证码发送失败");
|
||||
}
|
||||
} catch {
|
||||
showSuccessMsg('验证码已发送')
|
||||
handleCountDown()
|
||||
showSuccessMsg("验证码已发送");
|
||||
handleCountDown();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const phoneForm = useForm({
|
||||
initialValues: { phone: '', code: '' },
|
||||
initialValues: { phone: "", code: "" },
|
||||
validate: {
|
||||
phone: (v) => {
|
||||
if (!v) return '手机号不能为空'
|
||||
if (v.length !== 11) return '手机号必须为11位'
|
||||
return null
|
||||
if (!v) return "手机号不能为空";
|
||||
if (v.length !== 11) return "手机号必须为11位";
|
||||
return null;
|
||||
},
|
||||
code: (v) => {
|
||||
if (!v) return '验证码不能为空'
|
||||
if (v.length !== 6) return '验证码必须为6位'
|
||||
return null
|
||||
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)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tntkey = addrMap?.[fixedTenant] || ''
|
||||
const tntkey = addrMap?.[fixedTenant] || "";
|
||||
if (!tntkey) {
|
||||
setError('请输入正确的租户')
|
||||
setLoading(false)
|
||||
return
|
||||
setError("请输入正确的租户");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const res = await postLogin({
|
||||
phone: values.phone.startsWith('+86')
|
||||
phone: values.phone.startsWith("+86")
|
||||
? values.phone
|
||||
: `+86${values.phone}`,
|
||||
verify_code: values.code,
|
||||
tntkey,
|
||||
type: 'phone',
|
||||
})
|
||||
handleLoginSuccess(res)
|
||||
type: "phone",
|
||||
});
|
||||
handleLoginSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||||
setError(err instanceof Error ? err.message : "登录失败,请重试");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ============ 错误提示 ============
|
||||
useEffect(() => {
|
||||
if (error) showErrorMsg(error)
|
||||
}, [error])
|
||||
if (error) showErrorMsg(error);
|
||||
}, [error]);
|
||||
|
||||
const bgImage = useMemo(
|
||||
() => (colorScheme === 'dark' ? DarkBgImage : LightBgImage),
|
||||
[colorScheme]
|
||||
)
|
||||
() => (colorScheme === "dark" ? DarkBgImage : LightBgImage),
|
||||
[colorScheme],
|
||||
);
|
||||
const stackBg2Image = useMemo(
|
||||
() => (colorScheme === 'dark' ? loginBg2Dark : loginBg2Light),
|
||||
[colorScheme]
|
||||
)
|
||||
() => (colorScheme === "dark" ? loginBg2Dark : loginBg2Light),
|
||||
[colorScheme],
|
||||
);
|
||||
|
||||
// 飞书 OAuth 回调中:显示 loading,不渲染表单
|
||||
const isFeishuCallback = isClient && !!getQueryVariable('code')
|
||||
const isFeishuCallback = isClient && !!getQueryVariable("code");
|
||||
if (isFeishuCallback) {
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={'100vh'}
|
||||
h={"100vh"}
|
||||
w="100%"
|
||||
bg={`url(${bgImage.src})`}
|
||||
bgsz="cover"
|
||||
bgp="center"
|
||||
bgr="no-repeat">
|
||||
bgr="no-repeat"
|
||||
>
|
||||
<Paper withBorder shadow="md" radius="md" p="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<LoadingOverlay visible />
|
||||
<Text size="sm" c="dimmed">正在验证飞书登录...</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
正在验证飞书登录...
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{/* QrLogin 仍在后台执行 feishuLogin */}
|
||||
<Box style={{ position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
|
||||
<Box
|
||||
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
|
||||
>
|
||||
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={'100vh'}
|
||||
h={"100vh"}
|
||||
w="100%"
|
||||
bg={`url(${bgImage.src})`}
|
||||
bgsz="cover"
|
||||
bgp="center"
|
||||
bgr="no-repeat">
|
||||
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%'}>
|
||||
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' }}>
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||
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 }}>
|
||||
style={{ zIndex: 11 }}
|
||||
>
|
||||
<BackgroundImage
|
||||
src={LogoDigImage.src}
|
||||
radius="xs"
|
||||
@@ -367,39 +388,59 @@ export default function LoginPage() {
|
||||
</Stack>
|
||||
|
||||
{/* 右侧登录表单 */}
|
||||
<Stack h={'100%'} w={380} p={'54px 32px'} gap={0}>
|
||||
<Flex mb={'2rem'} gap={8} justify="center" align={'center'}>
|
||||
<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 来源提示 */}
|
||||
{appId && (
|
||||
<Text size="xs" c="dimmed" ta="center" mb="sm">
|
||||
正在为应用 {appId} 进行登录认证
|
||||
</Text>
|
||||
{/* 缺少 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}>
|
||||
ref={setControlRef("account")}
|
||||
className={tabClasses.tab}
|
||||
>
|
||||
账户登录
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="phone"
|
||||
ref={setControlRef('phone')}
|
||||
className={tabClasses.tab}>
|
||||
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}
|
||||
@@ -411,24 +452,27 @@ export default function LoginPage() {
|
||||
<Tabs.Panel value="account">
|
||||
<form
|
||||
onSubmit={handleAccountSubmit}
|
||||
style={{ marginTop: '30px' }}>
|
||||
style={{ marginTop: "30px" }}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label="账户名"
|
||||
placeholder="请输入账户名"
|
||||
mt="md"
|
||||
{...accountForm.getInputProps('username')}
|
||||
disabled={missingSsoParams}
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<PasswordInput
|
||||
withAsterisk
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
mt="md"
|
||||
{...accountForm.getInputProps('password')}
|
||||
disabled={missingSsoParams}
|
||||
{...accountForm.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading || missingSsoParams}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Tabs.Panel>
|
||||
@@ -437,54 +481,60 @@ export default function LoginPage() {
|
||||
<Tabs.Panel value="phone">
|
||||
<form
|
||||
onSubmit={handlePhoneSubmit}
|
||||
style={{ marginTop: '30px' }}>
|
||||
style={{ marginTop: "30px" }}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
mt="md"
|
||||
{...phoneForm.getInputProps('phone')}
|
||||
disabled={missingSsoParams}
|
||||
{...phoneForm.getInputProps("phone")}
|
||||
/>
|
||||
<Input.Wrapper
|
||||
label="验证码"
|
||||
withAsterisk
|
||||
mt="md"
|
||||
error={phoneForm.errors.code}>
|
||||
error={phoneForm.errors.code}
|
||||
>
|
||||
<Flex align="center" gap={18}>
|
||||
<Input
|
||||
placeholder="请输入验证码"
|
||||
style={{ flex: 1 }}
|
||||
{...phoneForm.getInputProps('code')}
|
||||
disabled={missingSsoParams}
|
||||
{...phoneForm.getInputProps("code")}
|
||||
/>
|
||||
<Button
|
||||
style={{ flexShrink: 0 }}
|
||||
type="button"
|
||||
disabled={
|
||||
!validatePhoneNumber(phoneForm.values.phone) ||
|
||||
!!codeTime
|
||||
!!codeTime ||
|
||||
missingSsoParams
|
||||
}
|
||||
onClick={handleGetCode}>
|
||||
{codeTime !== 0 ? `${codeTime}秒后获取` : '获取验证码'}
|
||||
onClick={handleGetCode}
|
||||
>
|
||||
{codeTime !== 0 ? `${codeTime}秒后获取` : "获取验证码"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Input.Wrapper>
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading || missingSsoParams}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* 飞书扫码(真实二维码) */}
|
||||
<Tabs.Panel value="feishu">
|
||||
{isClient && (
|
||||
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
{/* 飞书扫码(真实二维码) - 暂时隐藏 */}
|
||||
{false && (
|
||||
<Tabs.Panel value="feishu">
|
||||
{isClient && <QrLogin onLoginSuccess={handleLoginSuccess} />}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "sso-mock",
|
||||
"name": "sso-portal",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 5501",
|
||||
"build": "next build",
|
||||
"build:stage": "env-cmd -f .env.staging next build",
|
||||
"build": "env-cmd -f .env.production next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -25,6 +26,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"env-cmd": "^11.0.0",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5"
|
||||
|
||||
73
pnpm-lock.yaml
generated
73
pnpm-lock.yaml
generated
@@ -54,6 +54,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
env-cmd:
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
postcss-preset-mantine:
|
||||
specifier: ^1.18.0
|
||||
version: 1.18.0(postcss@8.4.31)
|
||||
@@ -70,6 +73,11 @@ packages:
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@commander-js/extra-typings@13.1.0':
|
||||
resolution: {integrity: sha512-q5P52BYb1hwVWE6dtID7VvuJWrlfbCv4klj7BjUUOqMz4jbSZD4C9fJ9lRjL2jnBGTg+gDDlaXN51rkWcLk4fg==}
|
||||
peerDependencies:
|
||||
commander: ~13.1.0
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==}
|
||||
|
||||
@@ -371,6 +379,14 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
commander@13.1.0:
|
||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
|
||||
@@ -392,6 +408,11 @@ packages:
|
||||
dom-helpers@5.2.1:
|
||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||
|
||||
env-cmd@11.0.0:
|
||||
resolution: {integrity: sha512-gnG7H1PlwPqsGhFJNTv68lsDGyQdK+U9DwLVitcj1+wGq7LeOBgUzZd2puZ710bHcH9NfNeGWe2sbw7pdvAqDw==}
|
||||
engines: {node: '>=20.10.0'}
|
||||
hasBin: true
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -408,6 +429,9 @@ packages:
|
||||
resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -450,6 +474,10 @@ packages:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -569,6 +597,14 @@ packages:
|
||||
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
shebang-regex@3.0.0:
|
||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -664,6 +700,11 @@ packages:
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
zustand@5.0.14:
|
||||
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -686,6 +727,10 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@commander-js/extra-typings@13.1.0(commander@13.1.0)':
|
||||
dependencies:
|
||||
commander: 13.1.0
|
||||
|
||||
'@emnapi/runtime@1.11.2':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -909,6 +954,14 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
commander@13.1.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypto-js@4.2.0: {}
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
@@ -925,6 +978,12 @@ snapshots:
|
||||
'@babel/runtime': 7.29.7
|
||||
csstype: 3.2.3
|
||||
|
||||
env-cmd@11.0.0:
|
||||
dependencies:
|
||||
'@commander-js/extra-typings': 13.1.0(commander@13.1.0)
|
||||
commander: 13.1.0
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.5):
|
||||
@@ -933,6 +992,8 @@ snapshots:
|
||||
|
||||
get-nonce@1.0.1: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
klona@2.0.6: {}
|
||||
@@ -968,6 +1029,8 @@ snapshots:
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
@@ -1113,6 +1176,12 @@ snapshots:
|
||||
'@img/sharp-win32-x64': 0.34.5
|
||||
optional: true
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
shebang-regex@3.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
styled-jsx@5.1.6(react@19.2.0):
|
||||
@@ -1175,6 +1244,10 @@ snapshots:
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
zustand@5.0.14(@types/react@19.2.17)(react@19.2.0):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Mock SSO 认证码存储(内存)
|
||||
* 生产环境应使用 Redis 等持久化方案
|
||||
*/
|
||||
|
||||
interface MockUser {
|
||||
id: string
|
||||
account: string
|
||||
name: string
|
||||
phone: string
|
||||
avatar: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface CodeEntry {
|
||||
user: MockUser
|
||||
expires: number
|
||||
}
|
||||
|
||||
// 使用 globalThis 避免 Next.js 开发模式下 HMR 导致数据丢失
|
||||
const globalForCodeStore = globalThis as unknown as {
|
||||
__ssoCodeStore: Map<string, CodeEntry> | undefined
|
||||
}
|
||||
|
||||
if (!globalForCodeStore.__ssoCodeStore) {
|
||||
globalForCodeStore.__ssoCodeStore = new Map()
|
||||
}
|
||||
|
||||
export const codeStore = globalForCodeStore.__ssoCodeStore!
|
||||
|
||||
// 定时清理过期 code(仅在非 edge 环境)
|
||||
if (typeof setInterval !== 'undefined') {
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, val] of codeStore) {
|
||||
if (val.expires < now) codeStore.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
}
|
||||
|
||||
export type { MockUser, CodeEntry }
|
||||
Reference in New Issue
Block a user