commit 37f10118d3b1f43d09b744aec3cb5a70b30c7447 Author: zhangheng Date: Mon Jul 6 18:09:30 2026 +0800 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68801cd --- /dev/null +++ b/.gitignore @@ -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 diff --git a/ADAPTATION_GUIDE.md b/ADAPTATION_GUIDE.md new file mode 100644 index 0000000..7f7e668 --- /dev/null +++ b/ADAPTATION_GUIDE.md @@ -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": "", // 外部应用标识 + "client_redirect_uri": "", // 外部应用回调地址 + + // login_type=password + "phone": "+86138...", "password": "***", + // login_type=phone + "phone": "+86138...", "verify_code": "123456", + // login_type=feishu + "code": "<飞书授权码>", "redirect_uri": "" +} +``` + +响应: + +```json +{ "code": "一次性 sso_code(短时效、单次使用)" } +``` + +### ② code 换用户信息 — `GET /api/v1/auth/inner_get_user_info?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=` + +```json +{ "data": { "providers": ["feishu", "password", "wechat"], "display_name": "XX 系统" } } +``` + +--- + +## 3. 标准流程(切到真实后端后) + +``` +external-app + → 跳转 sso-mock: /login?app_id=&app_url= + ↓ 用户在 sso-mock 登录(账号/手机/飞书) + → sso-mock ① POST /api/v1/auth/index ⇒ { code } + → sso-mock 重定向: ?sso_code= + ↓ + → external-app ② GET /api/v1/auth/inner_get_user_info?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 被并发重复提交。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5056ca --- /dev/null +++ b/README.md @@ -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 | 测试用户列表 | diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts new file mode 100644 index 0000000..f134270 --- /dev/null +++ b/app/api/auth/verify/route.ts @@ -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 } + ) + } +} diff --git a/app/api/front/getAbbrList/route.ts b/app/api/front/getAbbrList/route.ts new file mode 100644 index 0000000..26e8fda --- /dev/null +++ b/app/api/front/getAbbrList/route.ts @@ -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 } + ) + } +} diff --git a/app/api/getVerifyCode/route.ts b/app/api/getVerifyCode/route.ts new file mode 100644 index 0000000..9cde1cc --- /dev/null +++ b/app/api/getVerifyCode/route.ts @@ -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 } + ) + } +} diff --git a/app/api/login/route.ts b/app/api/login/route.ts new file mode 100644 index 0000000..ef641ee --- /dev/null +++ b/app/api/login/route.ts @@ -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 || '登录失败' }) + } +} diff --git a/app/api/mock/users/route.ts b/app/api/mock/users/route.ts new file mode 100644 index 0000000..1bba71a --- /dev/null +++ b/app/api/mock/users/route.ts @@ -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 }) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..7ca3170 --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d336550 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + + {children} + + + + ) +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..c183dc8 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,9 @@ +'use client' + +import dynamic from 'next/dynamic' + +const LoginComponent = dynamic(() => import('@/components/login'), { ssr: false }) + +export default function LoginPage() { + return +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..cc325e2 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function Home() { + redirect('/login') +} diff --git a/app/theme.ts b/app/theme.ts new file mode 100644 index 0000000..28f749b --- /dev/null +++ b/app/theme.ts @@ -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) => { + 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'], + }, + }), + }, +}) diff --git a/components/login/Tab.module.css b/components/login/Tab.module.css new file mode 100644 index 0000000..13518c3 --- /dev/null +++ b/components/login/Tab.module.css @@ -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); + } + } +} diff --git a/components/login/components/icons/digital.tsx b/components/login/components/icons/digital.tsx new file mode 100644 index 0000000..e34019b --- /dev/null +++ b/components/login/components/icons/digital.tsx @@ -0,0 +1,36 @@ +export const DigitalIcon = () => { + return ( + + + + + + + + + + + + + ) +} diff --git a/components/login/components/images/dark-login-bg.png b/components/login/components/images/dark-login-bg.png new file mode 100644 index 0000000..1a92d7c Binary files /dev/null and b/components/login/components/images/dark-login-bg.png differ diff --git a/components/login/components/images/light-login-bg.png b/components/login/components/images/light-login-bg.png new file mode 100644 index 0000000..89178e0 Binary files /dev/null and b/components/login/components/images/light-login-bg.png differ diff --git a/components/login/components/images/login-bg2-dark.png b/components/login/components/images/login-bg2-dark.png new file mode 100644 index 0000000..d7acf4b Binary files /dev/null and b/components/login/components/images/login-bg2-dark.png differ diff --git a/components/login/components/images/login-bg2-light.png b/components/login/components/images/login-bg2-light.png new file mode 100644 index 0000000..c87ce4f Binary files /dev/null and b/components/login/components/images/login-bg2-light.png differ diff --git a/components/login/components/images/logo-dig.png b/components/login/components/images/logo-dig.png new file mode 100644 index 0000000..889fe88 Binary files /dev/null and b/components/login/components/images/logo-dig.png differ diff --git a/components/login/components/login/qr-login.tsx b/components/login/components/login/qr-login.tsx new file mode 100644 index 0000000..fe0bbc3 --- /dev/null +++ b/components/login/components/login/qr-login.tsx @@ -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 ( + +
+ + ) +} + +export default QrLogin diff --git a/components/login/index.tsx b/components/login/index.tsx new file mode 100644 index 0000000..6af39e6 --- /dev/null +++ b/components/login/index.tsx @@ -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(null) + const [addrMap, setAddrMap] = useState>({}) + const [activeTab, setActiveTab] = useState(() => { + // 飞书 OAuth 回调(URL 带 ?code=xxx)时自动切到飞书 tab + if (typeof window !== 'undefined' && getQueryVariable('code')) { + return 'feishu' + } + return 'account' + }) + const [rootRef, setRootRef] = useState(null) + const controlsRefs = useRef>({}) + 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 ( + + + + + 正在验证飞书登录... + + + {/* QrLogin 仍在后台执行 feishuLogin */} + + + + + ) + } + + return ( + + + + {/* 左侧装饰区域 */} + + + + + + + + + + {/* 右侧登录表单 */} + + + + {headerTitle} + + + {/* SSO 来源提示 */} + {appId && ( + + 正在为应用 {appId} 进行登录认证 + + )} + + + + + 账户登录 + + + 手机号登录 + + + 飞书扫码 + + + + + {/* 账户登录 */} + +
+ + + + + +
+ + {/* 手机号登录 */} + +
+ + + + + + + + + + +
+ + {/* 飞书扫码(真实二维码) */} + + {isClient && ( + + )} + +
+
+
+
+
+ ) +} diff --git a/components/login/store.ts b/components/login/store.ts new file mode 100644 index 0000000..f40052f --- /dev/null +++ b/components/login/store.ts @@ -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( + (set) => ({ + fingerprint: '', + setFingerprint: (fingerprint) => set({ fingerprint }), + }), + { name: 'login-store' } + ) +) diff --git a/components/login/util.ts b/components/login/util.ts new file mode 100644 index 0000000..de44a0b --- /dev/null +++ b/components/login/util.ts @@ -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((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 + } +} diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..1cc2c58 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,8 @@ +declare module '*.png' { + const value: { src: string } + export default value +} +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..4133a3f --- /dev/null +++ b/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { + output: 'standalone', +} + +export default nextConfig diff --git a/package.json b/package.json new file mode 100644 index 0000000..32daa4b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7689c4b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1181 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@mantine/core': + specifier: ^8.3.6 + version: 8.3.18(@mantine/hooks@8.3.18(react@19.2.0))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mantine/form': + specifier: ^8.3.6 + version: 8.3.18(react@19.2.0) + '@mantine/hooks': + specifier: ^8.3.6 + version: 8.3.18(react@19.2.0) + '@mantine/notifications': + specifier: 8.3.18 + version: 8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.0))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.18(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tabler/icons-react': + specifier: ^3.35.0 + version: 3.44.0(react@19.2.0) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + next: + specifier: 16.0.1 + version: 16.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: + specifier: 19.2.0 + version: 19.2.0 + react-dom: + specifier: 19.2.0 + version: 19.2.0(react@19.2.0) + zustand: + specifier: ^5.0.8 + version: 5.0.14(@types/react@19.2.17)(react@19.2.0) + devDependencies: + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 + '@types/node': + specifier: ^20 + version: 20.19.43 + '@types/react': + specifier: ^19 + version: 19.2.17 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.17) + postcss-preset-mantine: + specifier: ^1.18.0 + version: 1.18.0(postcss@8.4.31) + postcss-simple-vars: + specifier: ^7.0.1 + version: 7.0.1(postcss@8.4.31) + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@mantine/core@8.3.18': + resolution: {integrity: sha512-9tph1lTVogKPjTx02eUxDUOdXacPzK62UuSqb4TdGliI54/Xgxftq0Dfqu6XuhCxn9J5MDJaNiLDvL/1KRkYqA==} + peerDependencies: + '@mantine/hooks': 8.3.18 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + + '@mantine/form@8.3.18': + resolution: {integrity: sha512-r5OGLJWTkmIruFjRZRZy9oA7maNYlyt50jB4Pmd2X5360WOmJLd4KH8MFhHZQC7vN+z8/rmBl3t3XGAR2I8xig==} + peerDependencies: + react: ^18.x || ^19.x + + '@mantine/hooks@8.3.18': + resolution: {integrity: sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw==} + peerDependencies: + react: ^18.x || ^19.x + + '@mantine/notifications@8.3.18': + resolution: {integrity: sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw==} + peerDependencies: + '@mantine/core': 8.3.18 + '@mantine/hooks': 8.3.18 + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + + '@mantine/store@8.3.18': + resolution: {integrity: sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg==} + peerDependencies: + react: ^18.x || ^19.x + + '@next/env@16.0.1': + resolution: {integrity: sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==} + + '@next/swc-darwin-arm64@16.0.1': + resolution: {integrity: sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.0.1': + resolution: {integrity: sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.0.1': + resolution: {integrity: sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.0.1': + resolution: {integrity: sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.0.1': + resolution: {integrity: sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.0.1': + resolution: {integrity: sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.0.1': + resolution: {integrity: sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.0.1': + resolution: {integrity: sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tabler/icons-react@3.44.0': + resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.44.0': + resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} + + '@types/crypto-js@4.2.2': + resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001802: + resolution: {integrity: sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@16.0.1: + resolution: {integrity: sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==} + engines: {node: '>=20.9.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-mixins@12.1.2: + resolution: {integrity: sha512-90pSxmZVfbX9e5xCv7tI5RV1mnjdf16y89CJKbf/hD7GyOz1FCxcYMl8ZYA8Hc56dbApTKKmU9HfvgfWdCxlwg==} + engines: {node: ^20.0 || ^22.0 || >=24.0} + peerDependencies: + postcss: ^8.2.14 + + postcss-nested@7.0.2: + resolution: {integrity: sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-preset-mantine@1.18.0: + resolution: {integrity: sha512-sP6/s1oC7cOtBdl4mw/IRKmKvYTuzpRrH/vT6v9enMU/EQEQ31eQnHcWtFghOXLH87AAthjL/Q75rLmin1oZoA==} + peerDependencies: + postcss: '>=8.0.0' + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss-simple-vars@7.0.1: + resolution: {integrity: sha512-5GLLXaS8qmzHMOjVxqkk1TZPf1jMqesiI7qLhnlyERalG0sMbHIbJqrcnrpmZdKCLglHnRHoEBB61RtGTsj++A==} + engines: {node: '>=14.0'} + peerDependencies: + postcss: ^8.2.1 + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-number-format@5.4.5: + resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + sugarss@5.0.1: + resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.3.3 + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/runtime@7.29.7': {} + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@floating-ui/react@0.27.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@floating-ui/utils': 0.2.11 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.11': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.0))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mantine/hooks': 8.3.18(react@19.2.0) + clsx: 2.1.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-number-format: 5.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.0) + react-textarea-autosize: 8.5.9(@types/react@19.2.17)(react@19.2.0) + type-fest: 4.41.0 + transitivePeerDependencies: + - '@types/react' + + '@mantine/form@8.3.18(react@19.2.0)': + dependencies: + fast-deep-equal: 3.1.3 + klona: 2.0.6 + react: 19.2.0 + + '@mantine/hooks@8.3.18(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@mantine/notifications@8.3.18(@mantine/core@8.3.18(@mantine/hooks@8.3.18(react@19.2.0))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@mantine/hooks@8.3.18(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@mantine/core': 8.3.18(@mantine/hooks@8.3.18(react@19.2.0))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@mantine/hooks': 8.3.18(react@19.2.0) + '@mantine/store': 8.3.18(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + react-transition-group: 4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + + '@mantine/store@8.3.18(react@19.2.0)': + dependencies: + react: 19.2.0 + + '@next/env@16.0.1': {} + + '@next/swc-darwin-arm64@16.0.1': + optional: true + + '@next/swc-darwin-x64@16.0.1': + optional: true + + '@next/swc-linux-arm64-gnu@16.0.1': + optional: true + + '@next/swc-linux-arm64-musl@16.0.1': + optional: true + + '@next/swc-linux-x64-gnu@16.0.1': + optional: true + + '@next/swc-linux-x64-musl@16.0.1': + optional: true + + '@next/swc-win32-arm64-msvc@16.0.1': + optional: true + + '@next/swc-win32-x64-msvc@16.0.1': + optional: true + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tabler/icons-react@3.44.0(react@19.2.0)': + dependencies: + '@tabler/icons': 3.44.0 + react: 19.2.0 + + '@tabler/icons@3.44.0': {} + + '@types/crypto-js@4.2.2': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001802: {} + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + crypto-js@4.2.0: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + detect-libc@2.1.2: + optional: true + + detect-node-es@1.1.0: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.7 + csstype: 3.2.3 + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + get-nonce@1.0.1: {} + + js-tokens@4.0.0: {} + + klona@2.0.6: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + nanoid@3.3.15: {} + + next@16.0.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@next/env': 16.0.1 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001802 + postcss: 8.4.31 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + styled-jsx: 5.1.6(react@19.2.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.0.1 + '@next/swc-darwin-x64': 16.0.1 + '@next/swc-linux-arm64-gnu': 16.0.1 + '@next/swc-linux-arm64-musl': 16.0.1 + '@next/swc-linux-x64-gnu': 16.0.1 + '@next/swc-linux-x64-musl': 16.0.1 + '@next/swc-win32-arm64-msvc': 16.0.1 + '@next/swc-win32-x64-msvc': 16.0.1 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + object-assign@4.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss-js@4.1.0(postcss@8.4.31): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.31 + + postcss-mixins@12.1.2(postcss@8.4.31): + dependencies: + postcss: 8.4.31 + postcss-js: 4.1.0(postcss@8.4.31) + postcss-simple-vars: 7.0.1(postcss@8.4.31) + sugarss: 5.0.1(postcss@8.4.31) + tinyglobby: 0.2.17 + + postcss-nested@7.0.2(postcss@8.4.31): + dependencies: + postcss: 8.4.31 + postcss-selector-parser: 7.1.4 + + postcss-preset-mantine@1.18.0(postcss@8.4.31): + dependencies: + postcss: 8.4.31 + postcss-mixins: 12.1.2(postcss@8.4.31) + postcss-nested: 7.0.2(postcss@8.4.31) + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-simple-vars@7.0.1(postcss@8.4.31): + dependencies: + postcss: 8.4.31 + + postcss@8.4.31: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-number-format@5.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.0): + dependencies: + react: 19.2.0 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.0): + dependencies: + react: 19.2.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.0) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.0) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.0): + dependencies: + get-nonce: 1.0.1 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-textarea-autosize@8.5.9(@types/react@19.2.17)(react@19.2.0): + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.0 + use-composed-ref: 1.4.0(@types/react@19.2.17)(react@19.2.0) + use-latest: 1.3.0(@types/react@19.2.17)(react@19.2.0) + transitivePeerDependencies: + - '@types/react' + + react-transition-group@4.4.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@babel/runtime': 7.29.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + react@19.2.0: {} + + scheduler@0.27.0: {} + + semver@7.8.5: + optional: true + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + source-map-js@1.2.1: {} + + styled-jsx@5.1.6(react@19.2.0): + dependencies: + client-only: 0.0.1 + react: 19.2.0 + + sugarss@5.0.1(postcss@8.4.31): + dependencies: + postcss: 8.4.31 + + tabbable@6.5.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.0): + dependencies: + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-composed-ref@1.4.0(@types/react@19.2.17)(react@19.2.0): + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.17 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.17)(react@19.2.0): + dependencies: + react: 19.2.0 + optionalDependencies: + '@types/react': 19.2.17 + + use-latest@1.3.0(@types/react@19.2.17)(react@19.2.0): + dependencies: + react: 19.2.0 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.17)(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + util-deprecate@1.0.2: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.0): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.0 diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..8887354 --- /dev/null +++ b/postcss.config.mjs @@ -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 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e7ff3a2 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} diff --git a/utils/code-store.ts b/utils/code-store.ts new file mode 100644 index 0000000..5f9c6f7 --- /dev/null +++ b/utils/code-store.ts @@ -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 | 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 } diff --git a/utils/message.tsx b/utils/message.tsx new file mode 100644 index 0000000..18053d8 --- /dev/null +++ b/utils/message.tsx @@ -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: , + 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: , + styles: { + ...styles, + root: { + ...styles.root, + transform: 'translateX(-75%)', + }, + }, + ...config, + style: { + width, + ...(config && 'style' in config && typeof config.style === 'object' + ? (config.style as object) + : {}), + }, + }) +} diff --git a/utils/session.ts b/utils/session.ts new file mode 100644 index 0000000..9051027 --- /dev/null +++ b/utils/session.ts @@ -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() +}