chore: initial commit of sso-mock SSO login service
Next.js + Mantine mock SSO service providing account/password, phone verify-code, and Feishu QR login. Issues a one-time sso_code that the external app exchanges for user info. Includes: - Feishu callback fix: guard against duplicate/concurrent /api/login submissions of the same single-use authorization code (feishuLogin once-guard + memoized onLoginSuccess to stop effect re-fire). - ADAPTATION_GUIDE.md: how to switch from the in-memory code store to the real backend auth/index + inner_get_user_info endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (never commit env; provide .env.example as a template instead)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
174
ADAPTATION_GUIDE.md
Normal file
174
ADAPTATION_GUIDE.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# SSO-Mock → 真实后端 适配指南
|
||||||
|
|
||||||
|
> 目标:当后端提供真实 SSO 接口后,把 sso-mock 从「自己伪造一次性 code」切换为「纯透传真实后端」。
|
||||||
|
> 参考实现:生产前端 `ssofront`(`src/apis/login/index.ts`)已经在用这套契约,本指南以它为准。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 结论:需要几个接口?
|
||||||
|
|
||||||
|
核心是 **2 个**(你已想到),完全对齐生产还需 **1 个辅助 + 2 个已有代理**:
|
||||||
|
|
||||||
|
| # | 用途 | 后端接口(生产实测) | 必须 | 备注 |
|
||||||
|
|---|------|----------------------|------|------|
|
||||||
|
| ① | 登录 → 返回一次性 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` | ⭕ | 多租户选择(现已代理) |
|
||||||
|
|
||||||
|
> **一句话**:后端把 ①② 做出来,sso-mock 就能删掉内存 `codeStore` 与假 `/api/auth/verify`,变成纯代理。③ 是"完全对齐生产"的锦上添花。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 接口契约(字段级)
|
||||||
|
|
||||||
|
### ① 登录换 code — `POST /api/v1/auth/index`
|
||||||
|
|
||||||
|
请求体(三种方式共用,按 `login_type` 带不同字段):
|
||||||
|
|
||||||
|
```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 回调>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": "一次性 sso_code(短时效、单次使用)" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### ② code 换用户信息 — `GET /api/v1/auth/inner_get_user_info?code=<sso_code>`
|
||||||
|
|
||||||
|
响应(`Login.UserInfo`,节选):
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"token": "...", "refresh_token": "...",
|
||||||
|
"user": {
|
||||||
|
"id": 1, "name": "张三", "phone": "...",
|
||||||
|
"permissions": { "/dashboard": ["view"] },
|
||||||
|
"feishu": { "avatar_url": "..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ③ 应用信息 — `GET /api/v1/application/by_client_id?client_id=<app_id>`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "data": { "providers": ["feishu", "password", "wechat"], "display_name": "XX 系统" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 标准流程(切到真实后端后)
|
||||||
|
|
||||||
|
```
|
||||||
|
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>
|
||||||
|
↓
|
||||||
|
→ external-app ② GET /api/v1/auth/inner_get_user_info?code=<code> ⇒ { user, token }
|
||||||
|
→ external-app 建立本地会话
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. sso-mock 需要改哪些文件
|
||||||
|
|
||||||
|
### 4.1 `app/api/login/route.ts` —— 从"伪造 code"改为"透传 auth/index"
|
||||||
|
|
||||||
|
**现状**:调 basis 原始登录端点拿到 `user+access_token`,再 `codeStore.set()` 自造 code。
|
||||||
|
**改为**:直接调 `/api/v1/auth/index`,把返回的 `code` 原样透传,**删除 codeStore 相关代码**。
|
||||||
|
|
||||||
|
```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 },
|
||||||
|
}
|
||||||
|
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` 已经在带这两个字段。
|
||||||
|
|
||||||
|
### 4.2 `app/api/auth/verify/route.ts` —— 从"读内存"改为"代理 inner_get_user_info"
|
||||||
|
|
||||||
|
**现状**:从 `codeStore` 取 user、自造 mock token。
|
||||||
|
**改为**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 或者让 external-app 直接调后端 ②,sso-mock 连这个代理都不用留。
|
||||||
|
|
||||||
|
### 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`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env.local
|
||||||
|
NEXT_PUBLIC_SHARED_API_PATH=http://<真实后端>:<端口> # auth/index、inner_get_user_info 的宿主
|
||||||
|
NEXT_PUBLIC_BASE_PATH= # 部署子路径(如有)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 迁移检查清单
|
||||||
|
|
||||||
|
- [ ] 后端就绪:`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 换到用户信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 前端侧现状(无需改动)
|
||||||
|
|
||||||
|
登录组件已满足新契约的调用要求:
|
||||||
|
|
||||||
|
- `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 被并发重复提交。
|
||||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Mock SSO
|
||||||
|
|
||||||
|
基于 Next.js 16 + Mantine 8 的 Mock SSO 登录服务,登录页复用 uirefbase 的 UI 风格。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev # http://localhost:5501
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试账号
|
||||||
|
|
||||||
|
| 账号 | 密码 | 姓名 |
|
||||||
|
|------|------|------|
|
||||||
|
| `admin` | `123456` | 管理员 |
|
||||||
|
| `user01` | `123456` | 测试用户 |
|
||||||
|
| `zhangsan` | `123456` | 张三 |
|
||||||
|
|
||||||
|
手机号登录:任意 11 位手机号 + 任意 6 位验证码即可。
|
||||||
|
|
||||||
|
## SSO 登录流程
|
||||||
|
|
||||||
|
### 1. 外部应用发起 SSO 请求
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:5501/login?app_id=your_app_id&app_url=http%3A%2F%2Flocalhost%3A3000%2Fcallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 用户在此页面登录
|
||||||
|
|
||||||
|
### 3. 登录成功后自动跳转
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000/callback?sso_code=xxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 外部应用用 code 换取用户信息
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5501/api/auth/verify \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"code": "your_sso_code"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
返回用户信息和 token。
|
||||||
|
|
||||||
|
## 直接访问
|
||||||
|
|
||||||
|
不带 `app_id` / `app_url` 参数访问 `/login`,登录成功后跳转到 `/dashboard` 管理后台。
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/auth/login` | POST | 登录(返回 code 或 token) |
|
||||||
|
| `/api/auth/verify` | POST | 验证 code 换取用户信息 |
|
||||||
|
| `/api/mock/users` | GET | 测试用户列表 |
|
||||||
54
app/api/auth/verify/route.ts
Normal file
54
app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/api/front/getAbbrList/route.ts
Normal file
33
app/api/front/getAbbrList/route.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const SHARED_API = (
|
||||||
|
process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610'
|
||||||
|
).replace(/\/+$/, '')
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const fetchUrl = `${SHARED_API}/api/v1/basis/tenant/base/listabbr`
|
||||||
|
console.log('[sso-mock] getAbbrList:', fetchUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 500, message: `获取租户信息失败 (${res.status})` },
|
||||||
|
{ status: res.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
return NextResponse.json(data, { status: 200 })
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[sso-mock] getAbbrList error:', err.message)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: err?.message || '获取租户信息失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/getVerifyCode/route.ts
Normal file
50
app/api/getVerifyCode/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理 uirefbase 的发送验证码接口
|
||||||
|
* uirefbase 路由: POST /api/front/protect/sendVerifyCode
|
||||||
|
* 真实后端: POST /api/v1/basis/user/phone/code
|
||||||
|
* 与 uirefbase 的 requestBasis 保持一致:直接 fetch,无 SDK
|
||||||
|
*/
|
||||||
|
const SHARED_API = (
|
||||||
|
process.env.NEXT_PUBLIC_SHARED_API_PATH || 'http://172.16.115.31:6610'
|
||||||
|
).replace(/\/+$/, '')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(fetchUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = await res.text()
|
||||||
|
let data: any = {}
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
data = { message: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status !== 200) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 500, message: data?.message || data?.msg || '验证码发送失败' },
|
||||||
|
{ status: res.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, { status: 200 })
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[sso-mock] getVerifyCode error:', err.message)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ code: 500, message: err?.message || '验证码发送失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/api/login/route.ts
Normal file
125
app/api/login/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import CryptoJS from 'crypto-js'
|
||||||
|
import { codeStore } from '@/utils/code-store'
|
||||||
|
|
||||||
|
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(/\/+$/, '')
|
||||||
|
|
||||||
|
function decrypt(encrypted: string): string {
|
||||||
|
try {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(encrypted, CODE_CRYPTO_KEY)
|
||||||
|
return bytes.toString(CryptoJS.enc.Utf8)
|
||||||
|
} catch {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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-mock] login | type:', type, '| body keys:', Object.keys(body || {}))
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return NextResponse.json({ error: '缺少 type 字段', debug_type: typeof type })
|
||||||
|
}
|
||||||
|
|
||||||
|
let res: any
|
||||||
|
|
||||||
|
if (type === 'account') {
|
||||||
|
res = await callBasisApi('/api/v1/basis/login/account', {
|
||||||
|
account: body.account,
|
||||||
|
password: body.password,
|
||||||
|
abbr: body.abbr,
|
||||||
|
})
|
||||||
|
} else if (type === 'phone') {
|
||||||
|
res = await callBasisApi('/api/v1/basis/user/phone/login', {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.error('[sso-mock] 未知 type:', type)
|
||||||
|
return NextResponse.json({ error: `不支持的登录方式: ${type}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[sso-mock] backend res.code:', res?.code)
|
||||||
|
|
||||||
|
// 后端业务码非 200 视为失败,直接返回错误,不生成 code
|
||||||
|
if (res?.code !== 200) {
|
||||||
|
const message = res?.message || res?.msg || '登录失败'
|
||||||
|
console.log('[sso-mock] backend error:', 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)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[sso-mock] login error:', err.message)
|
||||||
|
return NextResponse.json({ error: err?.message || '登录失败' })
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/api/mock/users/route.ts
Normal file
15
app/api/mock/users/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 用户列表接口(用于测试)
|
||||||
|
* GET /api/mock/users
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
const users = [
|
||||||
|
{ account: 'admin', password: '123456', name: '管理员', phone: '13800138000', roles: ['admin'] },
|
||||||
|
{ account: 'user01', password: '123456', name: '测试用户', phone: '13900139000', roles: ['user'] },
|
||||||
|
{ account: 'zhangsan', password: '123456', name: '张三', phone: '13700137000', roles: ['user', 'editor'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
return NextResponse.json({ users })
|
||||||
|
}
|
||||||
16
app/globals.css
Normal file
16
app/globals.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/layout.tsx
Normal file
37
app/layout.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import {
|
||||||
|
ColorSchemeScript,
|
||||||
|
MantineProvider,
|
||||||
|
mantineHtmlProps,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { Notifications } from '@mantine/notifications'
|
||||||
|
import { theme } from './theme'
|
||||||
|
import './globals.css'
|
||||||
|
import '@mantine/core/styles.css'
|
||||||
|
import '@mantine/core/styles.layer.css'
|
||||||
|
import '@mantine/notifications/styles.css'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Mock SSO',
|
||||||
|
description: 'Mock SSO Login Provider',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh" {...mantineHtmlProps}>
|
||||||
|
<head>
|
||||||
|
<ColorSchemeScript defaultColorScheme="light" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<MantineProvider defaultColorScheme="light" theme={theme}>
|
||||||
|
<Notifications />
|
||||||
|
{children}
|
||||||
|
</MantineProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
app/login/page.tsx
Normal file
9
app/login/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import dynamic from 'next/dynamic'
|
||||||
|
|
||||||
|
const LoginComponent = dynamic(() => import('@/components/login'), { ssr: false })
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return <LoginComponent />
|
||||||
|
}
|
||||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
176
app/theme.ts
Normal file
176
app/theme.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTheme,
|
||||||
|
Input,
|
||||||
|
MantineColorsTuple,
|
||||||
|
type MantineTheme,
|
||||||
|
} from '@mantine/core'
|
||||||
|
|
||||||
|
export const logoBlue = '#0080FF'
|
||||||
|
export const logoBlueHover = '#006EE6'
|
||||||
|
|
||||||
|
const brandColors: MantineColorsTuple = [
|
||||||
|
'#EAF4FF',
|
||||||
|
'#D9EBFF',
|
||||||
|
'#B6D8FF',
|
||||||
|
'#8EC2FF',
|
||||||
|
'#63AAFF',
|
||||||
|
'#3393FF',
|
||||||
|
logoBlue,
|
||||||
|
logoBlueHover,
|
||||||
|
'#005FC6',
|
||||||
|
'#004897',
|
||||||
|
]
|
||||||
|
|
||||||
|
const greyColors: MantineColorsTuple = [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#F7F8F9',
|
||||||
|
'#F1F2F4',
|
||||||
|
'#DCDFE4',
|
||||||
|
'#B5BBC2',
|
||||||
|
'#8F979F',
|
||||||
|
'#5D6872',
|
||||||
|
'#47535F',
|
||||||
|
'#1B2A38',
|
||||||
|
'#0F151B',
|
||||||
|
]
|
||||||
|
|
||||||
|
const darkColors: MantineColorsTuple = [
|
||||||
|
'#FFFFFF',
|
||||||
|
'#EBEEF7',
|
||||||
|
'#D3D4D8',
|
||||||
|
'#7E858F',
|
||||||
|
'#3C4859',
|
||||||
|
'#2B3648',
|
||||||
|
'#212A39',
|
||||||
|
'#1A222D',
|
||||||
|
'#151C24',
|
||||||
|
'#0D0E12',
|
||||||
|
]
|
||||||
|
|
||||||
|
const successColors: MantineColorsTuple = [
|
||||||
|
'#E6FCF5',
|
||||||
|
'#C3FAE8',
|
||||||
|
'#96F2D7',
|
||||||
|
'#5BE7B6',
|
||||||
|
'#38D9A9',
|
||||||
|
'#20C997',
|
||||||
|
'#0EA879',
|
||||||
|
'#07815B',
|
||||||
|
'#076B3F',
|
||||||
|
'#043E22',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
primaryColor: 'brand',
|
||||||
|
cursorType: 'pointer',
|
||||||
|
colors: {
|
||||||
|
brand: brandColors,
|
||||||
|
success: successColors,
|
||||||
|
grey: greyColors,
|
||||||
|
dark: darkColors,
|
||||||
|
},
|
||||||
|
defaultRadius: 'md',
|
||||||
|
radius: {
|
||||||
|
xs: '2px',
|
||||||
|
sm: '4px',
|
||||||
|
md: '8px',
|
||||||
|
lg: '12px',
|
||||||
|
xl: '16px',
|
||||||
|
xxl: '24px',
|
||||||
|
},
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
fontSizes: {
|
||||||
|
xs: '0.75rem',
|
||||||
|
sm: '0.875rem',
|
||||||
|
md: '1rem',
|
||||||
|
lg: '1.125rem',
|
||||||
|
xl: '1.25rem',
|
||||||
|
xxl: '1.5rem',
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
fontWeight: '700',
|
||||||
|
sizes: {
|
||||||
|
h1: { fontSize: '2.25rem', lineHeight: '1.2' },
|
||||||
|
h2: { fontSize: '1.875rem', lineHeight: '1.3' },
|
||||||
|
h3: { fontSize: '1.5rem', lineHeight: '1.35' },
|
||||||
|
h4: { fontSize: '1.25rem', lineHeight: '1.4' },
|
||||||
|
h5: { fontSize: '1.125rem', lineHeight: '1.45' },
|
||||||
|
h6: { fontSize: '1rem', lineHeight: '1.5' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
xs: '8px',
|
||||||
|
sm: '12px',
|
||||||
|
md: '16px',
|
||||||
|
lg: '24px',
|
||||||
|
xl: '32px',
|
||||||
|
xxl: '48px',
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
xs: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||||
|
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||||
|
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||||
|
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
xs: '36em',
|
||||||
|
sm: '48em',
|
||||||
|
md: '62em',
|
||||||
|
lg: '75em',
|
||||||
|
xl: '88em',
|
||||||
|
},
|
||||||
|
defaultGradient: {
|
||||||
|
from: 'brand',
|
||||||
|
to: 'success',
|
||||||
|
deg: 45,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Button: {
|
||||||
|
styles: (_theme: MantineTheme, props: Record<string, unknown>) => {
|
||||||
|
const variant = props.variant
|
||||||
|
if (variant === 'page-ghost') {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
outline: 'none',
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid rgba(71, 81, 105, 0.2)',
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
color: '#1D2129',
|
||||||
|
paddingInline: 16,
|
||||||
|
'&:hover': { backgroundColor: '#F7F8FA' },
|
||||||
|
},
|
||||||
|
label: { fontSize: 12, lineHeight: '18px', fontWeight: 400 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (variant === 'page-primary') {
|
||||||
|
return {
|
||||||
|
root: {
|
||||||
|
outline: 'none',
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: logoBlue,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
paddingInline: 16,
|
||||||
|
'&:hover': { backgroundColor: logoBlueHover },
|
||||||
|
},
|
||||||
|
label: { fontSize: 12, lineHeight: '18px', fontWeight: 500 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { root: { outline: 'none' } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
InputWrapper: Input.Wrapper.extend({
|
||||||
|
defaultProps: {
|
||||||
|
inputWrapperOrder: ['label', 'input', 'description', 'error'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
32
components/login/Tab.module.css
Normal file
32
components/login/Tab.module.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
.list {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--mantine-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
border-bottom: 2px solid var(--mantine-color-blue-6);
|
||||||
|
top: 2px;
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border-color: var(--mantine-color-blue-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
z-index: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 100ms ease;
|
||||||
|
color: var(--mantine-color-gray-7);
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
color: var(--mantine-color-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
color: var(--mantine-color-dark-1);
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
color: var(--mantine-color-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
components/login/components/icons/digital.tsx
Normal file
36
components/login/components/icons/digital.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export const DigitalIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" rx="6.4" fill="#1F65FF" />
|
||||||
|
<path
|
||||||
|
d="M18.8274 10.2358C17.1727 9.51456 15.2092 9.56087 13.526 10.5327C11.533 11.6833 10.4672 13.8231 10.5906 15.9739C9.43935 15.5275 8.1034 15.5877 6.95081 16.2531C6.41165 16.5644 5.96713 16.9766 5.62734 17.4522C4.91523 13.1111 6.89492 8.58834 10.9301 6.25862C14.0914 4.43344 17.7876 4.37384 20.8768 5.77385C21.2809 5.98109 21.6159 6.32477 21.8599 6.74734C22.5348 7.91635 22.1343 9.41116 20.9653 10.0861C20.2877 10.4772 19.5008 10.5072 18.8275 10.2356L18.8274 10.2358Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M27.1744 17.0864C26.7514 20.333 24.8747 23.3531 21.8203 25.1165C17.7851 27.4463 12.8784 26.8994 9.47497 24.1121C10.0567 24.0556 10.636 23.8768 11.1751 23.5655C12.3277 22.9 13.0478 21.7732 13.2368 20.5529C15.0378 21.7352 17.4238 21.8821 19.4168 20.7314C21.0984 19.7605 22.12 18.0854 22.324 16.2936C22.4243 15.5728 22.8441 14.9041 23.5231 14.5121C24.6921 13.8372 26.1869 14.2377 26.8619 15.4067C27.1674 15.9358 27.2528 16.4847 27.1744 17.0864Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.71583 17.3525C8.60142 16.8412 9.64534 16.852 10.4907 17.2896C11.1047 17.6075 11.6765 17.627 12.181 17.5259C12.3868 17.4776 12.5799 17.4028 12.7535 17.3026C13.3325 16.9683 13.7899 16.4562 13.9352 15.5682C13.9365 15.5428 13.9373 15.5167 13.9394 15.4914C14.0025 14.7188 14.4317 13.9883 15.1537 13.5713C16.3227 12.8966 17.817 13.2976 18.4919 14.4665C19.1666 15.6354 18.7668 17.1301 17.5981 17.805C16.8759 18.2218 16.0287 18.2283 15.328 17.8966C15.3057 17.8861 15.2842 17.8738 15.2621 17.8625C14.4195 17.5433 13.7469 17.6831 13.1674 18.0176C12.9954 18.1169 12.8343 18.2445 12.6905 18.3967C12.4263 18.6954 12.2051 19.0654 12.1036 19.5384C12.0736 19.6801 12.0535 19.8289 12.0464 19.9841C12.0437 20.0434 12.0389 20.1025 12.0325 20.1613C11.9371 21.0467 11.4348 21.8764 10.6045 22.3558L10.6032 22.3555L10.6037 22.3563C9.22241 23.1534 7.45586 22.6807 6.65814 21.2998C6.5878 21.178 6.53153 21.0511 6.48071 20.9242C6.43658 20.8141 6.39903 20.7031 6.36924 20.5905C6.2986 20.323 6.26795 20.0511 6.27515 19.781C6.27878 19.6429 6.28994 19.5056 6.31305 19.3701C6.34384 19.19 6.39063 19.013 6.4546 18.8418C6.61633 18.4088 6.88218 18.0125 7.24107 17.6943C7.31295 17.6306 7.38849 17.5698 7.46774 17.5127C7.5468 17.4557 7.62961 17.4023 7.71583 17.3525Z"
|
||||||
|
fill="url(#paint0_linear_3567_2455)"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="paint0_linear_3567_2455"
|
||||||
|
x1="5.9905"
|
||||||
|
y1="21.684"
|
||||||
|
x2="19.3253"
|
||||||
|
y2="13.9852"
|
||||||
|
gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stopColor="#FF6C6C" />
|
||||||
|
<stop offset="1" stopColor="#FFF1CE" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
BIN
components/login/components/images/dark-login-bg.png
Normal file
BIN
components/login/components/images/dark-login-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
BIN
components/login/components/images/light-login-bg.png
Normal file
BIN
components/login/components/images/light-login-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 MiB |
BIN
components/login/components/images/login-bg2-dark.png
Normal file
BIN
components/login/components/images/login-bg2-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
components/login/components/images/login-bg2-light.png
Normal file
BIN
components/login/components/images/login-bg2-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
BIN
components/login/components/images/logo-dig.png
Normal file
BIN
components/login/components/images/logo-dig.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 190 KiB |
131
components/login/components/login/qr-login.tsx
Normal file
131
components/login/components/login/qr-login.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { Flex } from '@mantine/core'
|
||||||
|
import { showErrorMsg } from '@/utils/message'
|
||||||
|
import { useLoginStore } from '../../store'
|
||||||
|
import {
|
||||||
|
getQueryVariable,
|
||||||
|
qr_load,
|
||||||
|
qr_login,
|
||||||
|
encrypt,
|
||||||
|
APP_ID,
|
||||||
|
TENANT,
|
||||||
|
} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 飞书二维码登录组件(sso-mock 版本)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 加载飞书 SDK,渲染真实二维码
|
||||||
|
* 2. 用户扫码后飞书 OAuth 回调到 /login?code=xxx&state=sso_app_id
|
||||||
|
* 3. 用 code 调用 /api/login?type=feishu 完成真实认证
|
||||||
|
* 4. 登录成功后跳转回外部应用(带 mock sso_code)
|
||||||
|
*/
|
||||||
|
const QrLogin = ({
|
||||||
|
onLoginSuccess,
|
||||||
|
}: {
|
||||||
|
onLoginSuccess: (res: any) => void
|
||||||
|
}) => {
|
||||||
|
// 飞书 OAuth 回调地址:始终指向 sso-mock 自己的 /login
|
||||||
|
const redirect_url =
|
||||||
|
window.location.protocol +
|
||||||
|
'//' +
|
||||||
|
window.location.host +
|
||||||
|
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/login`
|
||||||
|
|
||||||
|
// state 参数:传递 SSO 的 app_id,回调后用它查找 app_url
|
||||||
|
// 飞书 OAuth 回调后 URL 里不再有 app_id,从 localStorage 取
|
||||||
|
const ssoAppId =
|
||||||
|
(getQueryVariable('app_id') as string) ||
|
||||||
|
(typeof window !== 'undefined' ? localStorage.getItem('sso_app_id') || '' : '')
|
||||||
|
|
||||||
|
const fingerprint = useLoginStore((state) => state.fingerprint)
|
||||||
|
|
||||||
|
// 飞书 authorization code 只能用一次:守卫,防止重复渲染/StrictMode 重复提交
|
||||||
|
const feishuLoginTriggered = useRef(false)
|
||||||
|
|
||||||
|
// 渲染二维码
|
||||||
|
const renderCode = useCallback(async () => {
|
||||||
|
await qr_load()
|
||||||
|
// APP_ID: 飞书应用 ID
|
||||||
|
// redirect_url: 飞书 OAuth 回调地址(sso-mock 自身)
|
||||||
|
// state: SSO 的 app_id(传给飞书,回调时原样返回)
|
||||||
|
qr_login(APP_ID, redirect_url, ssoAppId)
|
||||||
|
}, [redirect_url, ssoAppId])
|
||||||
|
|
||||||
|
// 飞书扫码成功后,用返回的 code 调用真实后端登录
|
||||||
|
const feishuLogin = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const feishuCode = getQueryVariable('code')
|
||||||
|
if (!feishuCode) return
|
||||||
|
|
||||||
|
// 同一个 code 只提交一次,避免并发请求撞上飞书“code 已使用”而报错
|
||||||
|
if (feishuLoginTriggered.current) return
|
||||||
|
feishuLoginTriggered.current = true
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/login`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(
|
||||||
|
encrypt(
|
||||||
|
JSON.stringify({
|
||||||
|
code: feishuCode,
|
||||||
|
redirect_uri: redirect_url,
|
||||||
|
tenant: TENANT,
|
||||||
|
type: 'feishu',
|
||||||
|
fingerprint,
|
||||||
|
// SSO 参数:从 state(即 app_id)关联的 localStorage 取 app_url
|
||||||
|
...(ssoAppId
|
||||||
|
? {
|
||||||
|
client_id: ssoAppId,
|
||||||
|
client_redirect_uri: localStorage.getItem(ssoAppId) || '',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then((r) => r.text())
|
||||||
|
.then((t) => JSON.parse(t))
|
||||||
|
|
||||||
|
if (res?.error) {
|
||||||
|
showErrorMsg(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoginSuccess(res)
|
||||||
|
} catch (err: any) {
|
||||||
|
showErrorMsg(err?.message || '飞书登录失败')
|
||||||
|
}
|
||||||
|
}, [fingerprint, redirect_url, ssoAppId, onLoginSuccess])
|
||||||
|
|
||||||
|
// 初始化二维码
|
||||||
|
useEffect(() => {
|
||||||
|
void renderCode()
|
||||||
|
}, [renderCode])
|
||||||
|
|
||||||
|
// 如果 URL 带有 ?code=xxx(飞书 OAuth 回调),自动执行登录
|
||||||
|
useEffect(() => {
|
||||||
|
if (getQueryVariable('code') && fingerprint) {
|
||||||
|
void feishuLogin()
|
||||||
|
}
|
||||||
|
}, [feishuLogin, fingerprint])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" w={'100%'} h={'100%'}>
|
||||||
|
<div
|
||||||
|
id={'login_container'}
|
||||||
|
style={{
|
||||||
|
width: '200px',
|
||||||
|
height: '210px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
scale: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QrLogin
|
||||||
490
components/login/index.tsx
Normal file
490
components/login/index.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useForm } from '@mantine/form'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
LoadingOverlay,
|
||||||
|
Stack,
|
||||||
|
Flex,
|
||||||
|
BackgroundImage,
|
||||||
|
Tabs,
|
||||||
|
FloatingIndicator,
|
||||||
|
Input,
|
||||||
|
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'
|
||||||
|
|
||||||
|
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) || '')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
// 持久化 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-mock 不提供本地登录
|
||||||
|
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 来源提示 */}
|
||||||
|
{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>
|
||||||
|
<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"
|
||||||
|
{...accountForm.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
withAsterisk
|
||||||
|
label="密码"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
mt="md"
|
||||||
|
{...accountForm.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* 手机号登录 */}
|
||||||
|
<Tabs.Panel value="phone">
|
||||||
|
<form
|
||||||
|
onSubmit={handlePhoneSubmit}
|
||||||
|
style={{ marginTop: '30px' }}>
|
||||||
|
<LoadingOverlay visible={loading} />
|
||||||
|
<TextInput
|
||||||
|
withAsterisk
|
||||||
|
label="手机号"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
mt="md"
|
||||||
|
{...phoneForm.getInputProps('phone')}
|
||||||
|
/>
|
||||||
|
<Input.Wrapper
|
||||||
|
label="验证码"
|
||||||
|
withAsterisk
|
||||||
|
mt="md"
|
||||||
|
error={phoneForm.errors.code}>
|
||||||
|
<Flex align="center" gap={18}>
|
||||||
|
<Input
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
{...phoneForm.getInputProps('code')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
!validatePhoneNumber(phoneForm.values.phone) ||
|
||||||
|
!!codeTime
|
||||||
|
}
|
||||||
|
onClick={handleGetCode}>
|
||||||
|
{codeTime !== 0 ? `${codeTime}秒后获取` : '获取验证码'}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Input.Wrapper>
|
||||||
|
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||||||
|
{loading ? '登录中...' : '登录'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
{/* 飞书扫码(真实二维码) */}
|
||||||
|
<Tabs.Panel value="feishu">
|
||||||
|
{isClient && (
|
||||||
|
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||||||
|
)}
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
</Flex>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
components/login/store.ts
Normal file
17
components/login/store.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface LoginState {
|
||||||
|
fingerprint: string
|
||||||
|
setFingerprint: (fingerprint: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLoginStore = create(
|
||||||
|
persist<LoginState>(
|
||||||
|
(set) => ({
|
||||||
|
fingerprint: '',
|
||||||
|
setFingerprint: (fingerprint) => set({ fingerprint }),
|
||||||
|
}),
|
||||||
|
{ name: 'login-store' }
|
||||||
|
)
|
||||||
|
)
|
||||||
100
components/login/util.ts
Normal file
100
components/login/util.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
|
const CODE_CRYPTO_KEY = 'k#8Mp$2Qr&9Tz@4W'
|
||||||
|
|
||||||
|
export const APP_ID = 'cli_a56577acb2f3d00c' // 飞书应用 ID(与 uirefbase 相同)
|
||||||
|
export const TENANT = 'coowa'
|
||||||
|
|
||||||
|
export const validatePhoneNumber = (str: string) => {
|
||||||
|
const reg = /^[1][3,4,5,6,7,8,9][0-9]{9}$/
|
||||||
|
return reg.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 url 中的某个参数值 */
|
||||||
|
export const getQueryVariable = (variable: string) => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const query = window.location.search.substring(1)
|
||||||
|
const vars = query.split('&')
|
||||||
|
for (let i = 0; i < vars.length; i++) {
|
||||||
|
const pair = vars[i].split('=')
|
||||||
|
if (pair[0] === variable) {
|
||||||
|
return pair[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载飞书二维码 SDK */
|
||||||
|
export function qr_load() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// 如果已加载则跳过
|
||||||
|
if ((window as any).QRLogin) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.type = 'text/javascript'
|
||||||
|
script.src =
|
||||||
|
'https://sf3-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.2.js'
|
||||||
|
script.onerror = reject
|
||||||
|
script.onload = () => resolve()
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 初始化飞书二维码 */
|
||||||
|
export async function qr_login(
|
||||||
|
appId: string,
|
||||||
|
redirect_url: string,
|
||||||
|
state: string
|
||||||
|
) {
|
||||||
|
const gotoUrl = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(
|
||||||
|
redirect_url
|
||||||
|
)}&response_type=code&state=${encodeURIComponent(state)}`
|
||||||
|
|
||||||
|
const QRLogin = (window as any).QRLogin
|
||||||
|
const QRLoginObj = QRLogin({
|
||||||
|
id: 'login_container',
|
||||||
|
goto: gotoUrl,
|
||||||
|
style: `width: 270px;height: 270px;border: 0;border-radius:12px;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(at 47% 33%, hsl(212.37, 72%, 59%) 0, transparent 59%),
|
||||||
|
radial-gradient(at 82% 65%, hsl(198.00, 100%, 50%) 0, transparent 55%);`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleMessage = function (event: any) {
|
||||||
|
const origin = event.origin
|
||||||
|
if (
|
||||||
|
QRLoginObj.matchOrigin(origin) &&
|
||||||
|
window.location.href.indexOf('login') > -1
|
||||||
|
) {
|
||||||
|
const loginTmpCode = event.data
|
||||||
|
window.location.href = `${gotoUrl}&tmp_code=${loginTmpCode}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.addEventListener !== 'undefined') {
|
||||||
|
window.addEventListener('message', handleMessage, false)
|
||||||
|
} else if (typeof (window as any).attachEvent !== 'undefined') {
|
||||||
|
;(window as any).attachEvent('onmessage', handleMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加密 — 与 uirefbase 客户端保持一致 */
|
||||||
|
export const encrypt = (data: string) => {
|
||||||
|
try {
|
||||||
|
return CryptoJS.AES.encrypt(data, CODE_CRYPTO_KEY).toString()
|
||||||
|
} catch (error) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解密 */
|
||||||
|
export const decrypt = (data: string) => {
|
||||||
|
try {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(data, CODE_CRYPTO_KEY)
|
||||||
|
return bytes.toString(CryptoJS.enc.Utf8)
|
||||||
|
} catch (error) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
8
global.d.ts
vendored
Normal file
8
global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
declare module '*.png' {
|
||||||
|
const value: { src: string }
|
||||||
|
export default value
|
||||||
|
}
|
||||||
|
declare module '*.module.css' {
|
||||||
|
const classes: Record<string, string>
|
||||||
|
export default classes
|
||||||
|
}
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "sso-mock",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 5501",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/core": "^8.3.6",
|
||||||
|
"@mantine/form": "^8.3.6",
|
||||||
|
"@mantine/hooks": "^8.3.6",
|
||||||
|
"@mantine/notifications": "8.3.18",
|
||||||
|
"@tabler/icons-react": "^3.35.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"crypto-js": "^4.2.0",
|
||||||
|
"next": "16.0.1",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"zustand": "^5.0.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
1181
pnpm-lock.yaml
generated
Normal file
1181
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
postcss.config.mjs
Normal file
16
postcss.config.mjs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
41
utils/code-store.ts
Normal file
41
utils/code-store.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 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 }
|
||||||
77
utils/message.tsx
Normal file
77
utils/message.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { IconCheck, IconX } from '@tabler/icons-react'
|
||||||
|
import { notifications } from '@mantine/notifications'
|
||||||
|
|
||||||
|
const getNotificationWidth = (text: string) => {
|
||||||
|
const length = Math.max(1, text?.length || 0)
|
||||||
|
const width = Math.min(Math.max(length * 14 + 80, 180), 520)
|
||||||
|
return `${width}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSharedStyles = (text: string) => {
|
||||||
|
const width = getNotificationWidth(text)
|
||||||
|
const offset = `calc((100% - ${width}) / 2)`
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
styles: {
|
||||||
|
root: {
|
||||||
|
top: 20,
|
||||||
|
left: offset,
|
||||||
|
transform: 'translateX(-75%)',
|
||||||
|
width,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showSuccessMsg = (text: string, config?: object) => {
|
||||||
|
const { width, styles } = getSharedStyles(text)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: text,
|
||||||
|
position: 'top-center',
|
||||||
|
withCloseButton: false,
|
||||||
|
color: '#52c41a',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <IconCheck />,
|
||||||
|
styles,
|
||||||
|
...config,
|
||||||
|
style: {
|
||||||
|
width,
|
||||||
|
...(config && 'style' in config && typeof config.style === 'object'
|
||||||
|
? (config.style as object)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showErrorMsg = (text: string, config?: object) => {
|
||||||
|
const { width, styles } = getSharedStyles(text)
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: text,
|
||||||
|
position: 'top-center',
|
||||||
|
withCloseButton: false,
|
||||||
|
color: '#ff4d4f',
|
||||||
|
autoClose: 5000,
|
||||||
|
icon: <IconX size={14} />,
|
||||||
|
styles: {
|
||||||
|
...styles,
|
||||||
|
root: {
|
||||||
|
...styles.root,
|
||||||
|
transform: 'translateX(-75%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
style: {
|
||||||
|
width,
|
||||||
|
...(config && 'style' in config && typeof config.style === 'object'
|
||||||
|
? (config.style as object)
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
56
utils/session.ts
Normal file
56
utils/session.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import CryptoJS from 'crypto-js'
|
||||||
|
|
||||||
|
const SECRET_KEY = 'mock-sso-secret-key-2024'
|
||||||
|
const TOKEN_KEY = 'sso_token'
|
||||||
|
const USER_KEY = 'sso_user'
|
||||||
|
|
||||||
|
export const encrypt = (data: string) => {
|
||||||
|
try {
|
||||||
|
return CryptoJS.AES.encrypt(data, SECRET_KEY).toString()
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decrypt = (data: string) => {
|
||||||
|
try {
|
||||||
|
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY)
|
||||||
|
return bytes.toString(CryptoJS.enc.Utf8)
|
||||||
|
} catch {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setAuth = (token: string, user: object) => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.setItem(TOKEN_KEY, encrypt(token))
|
||||||
|
localStorage.setItem(USER_KEY, encrypt(JSON.stringify(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getToken = (): string | null => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const encrypted = localStorage.getItem(TOKEN_KEY)
|
||||||
|
if (!encrypted) return null
|
||||||
|
return decrypt(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUser = (): object | null => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
const encrypted = localStorage.getItem(USER_KEY)
|
||||||
|
if (!encrypted) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(decrypt(encrypted))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearAuth = () => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
localStorage.removeItem(TOKEN_KEY)
|
||||||
|
localStorage.removeItem(USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAuthenticated = (): boolean => {
|
||||||
|
return !!getToken()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user