commit 0c481c7a0e137739928eba65b3881c34e87a692c Author: Bret Date: Tue Jul 29 15:36:25 2025 -0700 v0.1.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfe7d09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# 依赖 +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 环境变量 +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# 日志 +logs +*.log + +# 运行时数据 +pids +*.pid +*.seed +*.pid.lock + +# 覆盖率目录 +coverage/ +.nyc_output + +# 依赖目录 +node_modules/ + +# 可选npm缓存目录 +.npm + +# 可选eslint缓存 +.eslintcache + +# 输出目录 +dist/ +build/ + +# 临时文件 +.tmp/ +.temp/ + +# IDE文件 +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# 操作系统文件 +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..67eae55 --- /dev/null +++ b/README.md @@ -0,0 +1,406 @@ +# OAuth 2.0 提供商服务 + +这是一个完整的OAuth 2.0提供商服务,支持Authorization Code Flow,包含用户认证、OAuth客户端管理、令牌管理等功能。 + +## 功能特性 + +### 🔐 用户认证 +- 用户注册和登录 +- JWT令牌认证 +- 密码加密存储 + +### 🚀 OAuth 2.0 功能 +- **Authorization Code Flow** - 完整的授权码流程 +- **Access Token** - 访问令牌生成和验证 +- **Refresh Token** - 刷新令牌支持 +- **Token Revocation** - 令牌撤销功能 +- **PKCE Support** - 支持PKCE(Proof Key for Code Exchange) +- **Scope Management** - 权限范围管理 + +### 🛡️ 安全特性 +- 客户端密钥管理 +- 令牌过期机制 +- 自动清理过期令牌 +- 重定向URI验证 +- 客户端所有权验证 + +### 📊 OAuth客户端管理 +- 创建OAuth客户端 +- 获取客户端列表 +- 获取客户端详情 +- 获取客户端密钥 +- 重置客户端密钥 +- 删除客户端 + +## API端点 + +### 用户认证 +``` +POST /api/auth/register - 用户注册 +POST /api/auth/login - 用户登录 +GET /api/auth/profile - 获取用户信息 +``` + +### OAuth端点 +``` +GET /api/oauth/authorize - 授权端点 +POST /api/oauth/token - 令牌端点 +POST /api/oauth/revoke - 撤销端点 +GET /api/oauth/userinfo - 用户信息端点 +GET /api/oauth/tokeninfo - 令牌信息端点 +``` + +### OAuth客户端管理 +``` +POST /api/oauth/clients - 创建客户端 +GET /api/oauth/clients - 获取客户端列表 +GET /api/oauth/clients/:clientId - 获取客户端详情 +GET /api/oauth/clients/:clientId/secret - 获取客户端密钥 +POST /api/oauth/clients/:clientId/reset-secret - 重置客户端密钥 +DELETE /api/oauth/clients/:clientId - 删除客户端 +``` + +### 发现端点 +``` +GET /.well-known/oauth-authorization-server - OAuth发现端点 +``` + +## 安装和运行 + +### 1. 安装依赖 +```bash +npm install +``` + +### 2. 配置环境变量 +创建 `.env` 文件: +```env +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=your_database +DB_USER=your_username +DB_PASSWORD=your_password +JWT_SECRET=your_jwt_secret_key +PORT=3000 +``` + +### 3. 启动服务 +```bash +# 开发模式 +npm run dev + +# 生产模式 +npm start +``` + +## 前端界面 + +本项目包含两个前端版本: + +### 🚀 React + Material-UI 版本(推荐) + +现代化的React应用,使用Material-UI组件库。 + +#### 安装和运行 + +```bash +# 安装前端依赖 +npm install + +# 启动前端开发服务器 +npm run dev:frontend + +# 构建生产版本 +npm run build:frontend +``` + +#### 功能特性 + +- ✅ **响应式设计** - 适配各种屏幕尺寸 +- ✅ **现代化UI** - Material-UI设计语言 +- ✅ **路由管理** - React Router +- ✅ **状态管理** - Context API +- ✅ **表单验证** - 实时验证和错误提示 +- ✅ **加载状态** - 优雅的加载动画 +- ✅ **错误处理** - 友好的错误提示 + +#### 页面说明 + +1. **登录页面** (`/login`) + - 用户名/密码登录 + - 密码可见性切换 + - 表单验证 + - 自动跳转到注册 + +2. **注册页面** (`/register`) + - 用户注册表单 + - 密码确认验证 + - 邮箱格式验证 + - 自动跳转到登录 + +3. **个人中心** (`/dashboard`) + - 用户信息展示 + - OAuth客户端管理 + - 创建/删除客户端 + - 退出登录 + +4. **OAuth授权页面** (`/oauth/authorize`) + - 第三方应用授权 + - 权限范围展示 + - 用户信息确认 + - 授权/拒绝操作 + +### 📄 HTML + Bootstrap 版本 + +简单的HTML版本,使用Bootstrap样式。 + +#### 使用方法 + +```bash +# 直接打开HTML文件 +open public/index.html +``` + +#### 功能特性 + +- ✅ **轻量级** - 无需构建工具 +- ✅ **Bootstrap样式** - 美观的界面 +- ✅ **原生JavaScript** - 简单易懂 +- ✅ **登录/注册切换** - 单页面应用 +- ✅ **表单验证** - 客户端验证 + +## 使用示例 + +### 1. 启动后端服务 + +```bash +# 启动后端API服务 +npm start +``` + +### 2. 启动前端服务 + +```bash +# 启动React前端 +npm run dev:frontend +``` + +### 3. 访问应用 + +- **React版本**: http://localhost:3001 +- **HTML版本**: 直接打开 `public/index.html` + +### 4. 测试OAuth流程 + +1. 注册/登录用户 +2. 在个人中心创建OAuth客户端 +3. 访问授权页面:`http://localhost:3001/oauth/authorize?client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:3001/callback&scope=read%20write&state=test123` +4. 完成授权流程 + +## 前端技术栈 + +### React版本 +- **React 18** - 用户界面库 +- **Material-UI 5** - 组件库 +- **React Router 6** - 路由管理 +- **Axios** - HTTP客户端 +- **Vite** - 构建工具 + +### HTML版本 +- **Bootstrap 5** - CSS框架 +- **Font Awesome** - 图标库 +- **原生JavaScript** - 交互逻辑 + +## 开发说明 + +### 目录结构 + +``` +├── src/ # React源代码 +│ ├── components/ # 组件 +│ ├── contexts/ # 上下文 +│ ├── pages/ # 页面 +│ ├── App.jsx # 主应用 +│ └── main.jsx # 入口文件 +├── public/ # 静态资源 +│ └── index.html # HTML版本 +├── index.html # React入口 +├── vite.config.js # Vite配置 +└── package.json # 项目配置 +``` + +### 开发命令 + +```bash +# 开发模式 +npm run dev:frontend + +# 构建生产版本 +npm run build:frontend + +# 预览生产版本 +npm run preview:frontend +``` + +### 环境配置 + +前端会自动代理API请求到后端: + +```javascript +// vite.config.js +server: { + port: 3001, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + } + } +} +``` + +## 安全特性 + +### 前端安全 +- ✅ **JWT令牌管理** - 自动存储和刷新 +- ✅ **路由保护** - 私有路由验证 +- ✅ **表单验证** - 客户端和服务器端验证 +- ✅ **错误处理** - 友好的错误提示 +- ✅ **CORS配置** - 跨域请求处理 + +### 用户体验 +- ✅ **响应式设计** - 移动端适配 +- ✅ **加载状态** - 优雅的加载动画 +- ✅ **错误提示** - 清晰的错误信息 +- ✅ **表单验证** - 实时验证反馈 +- ✅ **自动跳转** - 智能路由管理 + +## 数据库结构 + +### 用户表 (users) +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### OAuth客户端表 (oauth_clients) +```sql +CREATE TABLE oauth_clients ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(100) UNIQUE NOT NULL, + client_secret VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + redirect_uris TEXT[] NOT NULL, + scopes TEXT[] DEFAULT ARRAY['read', 'write'], + user_id INTEGER NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### OAuth授权码表 (oauth_auth_codes) +```sql +CREATE TABLE oauth_auth_codes ( + id SERIAL PRIMARY KEY, + code VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(100) NOT NULL, + user_id INTEGER NOT NULL, + redirect_uri VARCHAR(255) NOT NULL, + scopes TEXT[] NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### OAuth访问令牌表 (oauth_access_tokens) +```sql +CREATE TABLE oauth_access_tokens ( + id SERIAL PRIMARY KEY, + token VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(100) NOT NULL, + user_id INTEGER NOT NULL, + scopes TEXT[] NOT NULL, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### OAuth刷新令牌表 (oauth_refresh_tokens) +```sql +CREATE TABLE oauth_refresh_tokens ( + id SERIAL PRIMARY KEY, + token VARCHAR(255) UNIQUE NOT NULL, + access_token_id INTEGER REFERENCES oauth_access_tokens(id) ON DELETE CASCADE, + client_id VARCHAR(100) NOT NULL, + user_id INTEGER NOT NULL, + is_revoked BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## 安全特性 + +### 🔒 令牌安全 +- 访问令牌有效期:1小时 +- 刷新令牌有效期:30天 +- 授权码有效期:10分钟 +- 自动清理过期令牌 + +### 🛡️ 客户端安全 +- 客户端密钥加密存储 +- 重定向URI验证 +- 客户端所有权验证 +- 支持客户端密钥重置 + +### 🔐 认证安全 +- JWT令牌认证 +- 密码bcrypt加密 +- 输入验证和清理 +- CORS配置 + +## 测试 + +### 运行OAuth测试 +```bash +npm run test:oauth +``` + +### 运行基础API测试 +```bash +npm run test +``` + +## 生产环境建议 + +### 1. 安全配置 +- 使用强密码策略 +- 启用HTTPS +- 配置CORS策略 +- 设置适当的令牌过期时间 + +### 2. 性能优化 +- 使用Redis缓存令牌 +- 数据库连接池优化 +- 定期清理过期数据 + +### 3. 监控和日志 +- 添加请求日志 +- 监控令牌使用情况 +- 错误追踪和告警 + +### 4. 扩展功能 +- 支持多种授权类型 +- 添加OAuth客户端应用管理界面 +- 实现OAuth 2.1标准 +- 支持OpenID Connect + +## 许可证 + +MIT License \ No newline at end of file diff --git a/config/database.js b/config/database.js new file mode 100644 index 0000000..1064c2f --- /dev/null +++ b/config/database.js @@ -0,0 +1,24 @@ +const { Pool } = require('pg'); +require('dotenv').config(); + +const pool = new Pool({ + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + database: process.env.DB_NAME || 'auth_db', + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'your_password', + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); + +// 测试数据库连接 +pool.on('connect', () => { + console.log('数据库连接成功'); +}); + +pool.on('error', (err) => { + console.error('数据库连接错误:', err); +}); + +module.exports = pool; \ No newline at end of file diff --git a/examples/oauth-client-example.html b/examples/oauth-client-example.html new file mode 100644 index 0000000..cb9b6a8 --- /dev/null +++ b/examples/oauth-client-example.html @@ -0,0 +1,274 @@ + + + + + + OAuth客户端示例 + + + +
+

OAuth 2.0 客户端示例

+

这个页面演示了如何使用我们的OAuth提供商进行身份验证。

+ +
+

步骤 1: 配置OAuth客户端

+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+

步骤 2: 获取授权码

+

点击上面的按钮开始OAuth授权流程。你需要先登录到OAuth提供商。

+ +
+ +
+

步骤 3: 交换访问令牌

+ + +
+ +
+

步骤 4: 获取用户信息

+ + +
+ +
+

步骤 5: 撤销令牌

+ + +
+
+ + + + \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..48588d3 --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + + OAuth认证系统 + + + +
+ + + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..f228c60 --- /dev/null +++ b/index.js @@ -0,0 +1,134 @@ +const express = require('express'); +const cors = require('cors'); +require('dotenv').config(); + +const User = require('./models/User'); +const OAuthClient = require('./models/OAuthClient'); +const OAuthToken = require('./models/OAuthToken'); +const authRoutes = require('./routes/auth'); +const oauthRoutes = require('./routes/oauth'); +const oauthClientRoutes = require('./routes/oauth-clients'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// 中间件 +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// 请求日志中间件 +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); +}); + +// 路由 +app.use('/api/auth', authRoutes); +app.use('/api/oauth', oauthRoutes); +app.use('/api/oauth', oauthClientRoutes); + +// 根路由 +app.get('/', (req, res) => { + res.json({ + success: true, + message: '认证和OAuth API服务器运行中', + endpoints: { + auth: { + register: 'POST /api/auth/register', + login: 'POST /api/auth/login', + profile: 'GET /api/auth/profile', + test: 'GET /api/auth/test' + }, + oauth: { + authorize: 'GET /api/oauth/authorize', + token: 'POST /api/oauth/token', + revoke: 'POST /api/oauth/revoke', + userinfo: 'GET /api/oauth/userinfo', + tokeninfo: 'GET /api/oauth/tokeninfo' + }, + oauth_clients: { + create: 'POST /api/oauth/clients', + list: 'GET /api/oauth/clients', + detail: 'GET /api/oauth/clients/:clientId', + delete: 'DELETE /api/oauth/clients/:clientId', + stats: 'GET /api/oauth/clients/:clientId/stats' + } + } + }); +}); + +// 健康检查 +app.get('/health', (req, res) => { + res.json({ + success: true, + message: '服务器运行正常', + timestamp: new Date().toISOString() + }); +}); + +// OAuth发现端点 +app.get('/.well-known/oauth-authorization-server', (req, res) => { + res.json({ + issuer: `${req.protocol}://${req.get('host')}`, + authorization_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/authorize`, + token_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/token`, + revocation_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/revoke`, + userinfo_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/userinfo`, + tokeninfo_endpoint: `${req.protocol}://${req.get('host')}/api/oauth/tokeninfo`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_post'], + scopes_supported: ['read', 'write'], + code_challenge_methods_supported: [] + }); +}); + +// 404处理 +app.use('*', (req, res) => { + res.status(404).json({ + success: false, + message: '路由不存在' + }); +}); + +// 错误处理中间件 +app.use((err, req, res, next) => { + console.error('服务器错误:', err); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); +}); + +// 启动服务器 +const startServer = async () => { + try { + // 创建所有数据库表 + await User.createTable(); + await OAuthClient.createTable(); + await OAuthClient.createAuthCodeTable(); + await OAuthClient.createAccessTokenTable(); + await OAuthClient.createRefreshTokenTable(); + + // 设置定时清理过期令牌 + setInterval(async () => { + try { + await OAuthToken.cleanupExpiredTokens(); + } catch (error) { + console.error('清理过期令牌失败:', error); + } + }, 60 * 60 * 1000); // 每小时清理一次 + + app.listen(PORT, () => { + console.log(`服务器运行在端口 ${PORT}`); + console.log(`API文档: http://localhost:${PORT}`); + console.log(`OAuth发现端点: http://localhost:${PORT}/.well-known/oauth-authorization-server`); + }); + } catch (error) { + console.error('启动服务器失败:', error); + process.exit(1); + } +}; + +startServer(); diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..f090728 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,39 @@ +const jwt = require('jsonwebtoken'); + +// 生成JWT token +const generateToken = (userId, username) => { + return jwt.sign( + { userId, username }, + process.env.JWT_SECRET || 'your_jwt_secret_key_here', + { expiresIn: '24h' } + ); +}; + +// 验证JWT token中间件 +const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ + success: false, + message: '访问令牌缺失' + }); + } + + jwt.verify(token, process.env.JWT_SECRET || 'your_jwt_secret_key_here', (err, user) => { + if (err) { + return res.status(403).json({ + success: false, + message: '访问令牌无效' + }); + } + req.user = user; + next(); + }); +}; + +module.exports = { + generateToken, + authenticateToken +}; \ No newline at end of file diff --git a/middleware/oauth.js b/middleware/oauth.js new file mode 100644 index 0000000..00fc82f --- /dev/null +++ b/middleware/oauth.js @@ -0,0 +1,118 @@ +const OAuthToken = require('../models/OAuthToken'); + +// OAuth访问令牌验证中间件 +const authenticateOAuthToken = async (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ + success: false, + message: '访问令牌缺失' + }); + } + + try { + const tokenData = await OAuthToken.validateAccessToken(token); + if (!tokenData) { + return res.status(401).json({ + success: false, + message: '访问令牌无效或已过期' + }); + } + + req.oauth = { + token: tokenData.token, + clientId: tokenData.client_id, + userId: tokenData.user_id, + scopes: tokenData.scopes, + username: tokenData.username, + email: tokenData.email + }; + next(); + } catch (error) { + console.error('OAuth令牌验证失败:', error); + return res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}; + +// 检查OAuth权限范围 +const requireScope = (requiredScope) => { + return (req, res, next) => { + if (!req.oauth) { + return res.status(401).json({ + success: false, + message: '需要OAuth认证' + }); + } + + if (!req.oauth.scopes.includes(requiredScope)) { + return res.status(403).json({ + success: false, + message: `需要权限范围: ${requiredScope}` + }); + } + + next(); + }; +}; + +// 检查多个权限范围(任一即可) +const requireAnyScope = (requiredScopes) => { + return (req, res, next) => { + if (!req.oauth) { + return res.status(401).json({ + success: false, + message: '需要OAuth认证' + }); + } + + const hasAnyScope = requiredScopes.some(scope => + req.oauth.scopes.includes(scope) + ); + + if (!hasAnyScope) { + return res.status(403).json({ + success: false, + message: `需要权限范围: ${requiredScopes.join(' 或 ')}` + }); + } + + next(); + }; +}; + +// 检查所有权限范围 +const requireAllScopes = (requiredScopes) => { + return (req, res, next) => { + if (!req.oauth) { + return res.status(401).json({ + success: false, + message: '需要OAuth认证' + }); + } + + const hasAllScopes = requiredScopes.every(scope => + req.oauth.scopes.includes(scope) + ); + + if (!hasAllScopes) { + return res.status(403).json({ + success: false, + message: `需要所有权限范围: ${requiredScopes.join(', ')}` + }); + } + + next(); + }; +}; + +module.exports = { + authenticateOAuthToken, + requireScope, + requireAnyScope, + requireAllScopes +}; \ No newline at end of file diff --git a/middleware/validation.js b/middleware/validation.js new file mode 100644 index 0000000..a9e9f7c --- /dev/null +++ b/middleware/validation.js @@ -0,0 +1,56 @@ +const { body, validationResult } = require('express-validator'); + +// 注册验证规则 +const registerValidation = [ + body('username') + .isLength({ min: 3, max: 50 }) + .withMessage('用户名长度必须在3-50个字符之间') + .matches(/^[a-zA-Z0-9_]+$/) + .withMessage('用户名只能包含字母、数字和下划线'), + + body('email') + .isEmail() + .withMessage('请输入有效的邮箱地址') + .normalizeEmail(), + + body('password') + .isLength({ min: 6 }) + .withMessage('密码长度至少6个字符') + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) + .withMessage('密码必须包含至少一个小写字母、一个大写字母和一个数字') +]; + +// 登录验证规则 +const loginValidation = [ + body('username') + .notEmpty() + .withMessage('用户名不能为空'), + + body('password') + .notEmpty() + .withMessage('密码不能为空') +]; + +// 验证结果处理中间件 +const handleValidationErrors = (req, res, next) => { + const errors = validationResult(req); + // console.log(req.body); + + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: '输入验证失败', + errors: errors.array().map(error => ({ + field: error.path, + message: error.msg + })) + }); + } + next(); +}; + +module.exports = { + registerValidation, + loginValidation, + handleValidationErrors +}; \ No newline at end of file diff --git a/models/OAuthClient.js b/models/OAuthClient.js new file mode 100644 index 0000000..2d0b8c2 --- /dev/null +++ b/models/OAuthClient.js @@ -0,0 +1,206 @@ +const pool = require('../config/database'); +const crypto = require('crypto'); + +class OAuthClient { + // 创建OAuth客户端表 + static async createTable() { + const query = ` + CREATE TABLE IF NOT EXISTS oauth_clients ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(100) UNIQUE NOT NULL, + client_secret VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + redirect_uris TEXT[] NOT NULL, + scopes TEXT[] DEFAULT ARRAY['read', 'write'], + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `; + + try { + await pool.query(query); + console.log('OAuth客户端表创建成功'); + } catch (error) { + console.error('创建OAuth客户端表失败:', error); + throw error; + } + } + + // 创建授权码表 + static async createAuthCodeTable() { + const query = ` + CREATE TABLE IF NOT EXISTS oauth_auth_codes ( + id SERIAL PRIMARY KEY, + code VARCHAR(100) UNIQUE NOT NULL, + client_id VARCHAR(100) NOT NULL, + user_id INTEGER NOT NULL, + redirect_uri VARCHAR(255) NOT NULL, + scopes TEXT[], + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `; + + try { + await pool.query(query); + console.log('OAuth授权码表创建成功'); + } catch (error) { + console.error('创建OAuth授权码表失败:', error); + throw error; + } + } + + // 创建访问令牌表 + static async createAccessTokenTable() { + const query = ` + CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + id SERIAL PRIMARY KEY, + token VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(100) NOT NULL, + user_id INTEGER NOT NULL, + scopes TEXT[], + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `; + + try { + await pool.query(query); + console.log('OAuth访问令牌表创建成功'); + } catch (error) { + console.error('创建OAuth访问令牌表失败:', error); + throw error; + } + } + + // 创建刷新令牌表 + static async createRefreshTokenTable() { + const query = ` + CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + id SERIAL PRIMARY KEY, + token VARCHAR(255) UNIQUE NOT NULL, + access_token_id INTEGER REFERENCES oauth_access_tokens(id) ON DELETE CASCADE, + client_id VARCHAR(100) NOT NULL, + user_id INTEGER NOT NULL, + is_revoked BOOLEAN DEFAULT FALSE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `; + + try { + await pool.query(query); + console.log('OAuth刷新令牌表创建成功'); + } catch (error) { + console.error('创建OAuth刷新令牌表失败:', error); + throw error; + } + } + + // 生成客户端ID和密钥 + static generateClientCredentials() { + const clientId = crypto.randomBytes(32).toString('hex'); + const clientSecret = crypto.randomBytes(64).toString('hex'); + return { clientId, clientSecret }; + } + + // 创建新的OAuth客户端 + static async create(clientData) { + const { name, description, redirectUris, scopes, userId } = clientData; + const { clientId, clientSecret } = this.generateClientCredentials(); + + const query = ` + INSERT INTO oauth_clients (client_id, client_secret, name, description, redirect_uris, scopes, user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, client_id, client_secret, name, description, redirect_uris, scopes, created_at + `; + + try { + const result = await pool.query(query, [ + clientId, + clientSecret, + name, + description, + redirectUris, + scopes || ['read', 'write'], + userId + ]); + return result.rows[0]; + } catch (error) { + console.error('创建OAuth客户端失败:', error); + throw error; + } + } + + // 重置客户端密钥 + static async resetClientSecret(clientId) { + const newSecret = crypto.randomBytes(64).toString('hex'); + const query = 'UPDATE oauth_clients SET client_secret = $1 WHERE client_id = $2 AND is_active = true'; + + try { + const result = await pool.query(query, [newSecret, clientId]); + if (result.rowCount === 0) { + throw new Error('客户端不存在或已禁用'); + } + return newSecret; + } catch (error) { + console.error('重置客户端密钥失败:', error); + throw error; + } + } + + // 根据客户端ID查找客户端 + static async findByClientId(clientId) { + const query = 'SELECT * FROM oauth_clients WHERE client_id = $1 AND is_active = true'; + try { + const result = await pool.query(query, [clientId]); + return result.rows[0]; + } catch (error) { + console.error('查找OAuth客户端失败:', error); + throw error; + } + } + + // 验证客户端密钥 + static async validateClient(clientId, clientSecret) { + const client = await this.findByClientId(clientId); + if (!client) return false; + return client.client_secret === clientSecret; + } + + // 验证重定向URI + static async validateRedirectUri(clientId, redirectUri) { + const client = await this.findByClientId(clientId); + if (!client) return false; + return client.redirect_uris.includes(redirectUri); + } + + // 获取用户的所有客户端 + static async findByUserId(userId) { + const query = 'SELECT id, client_id, name, description, redirect_uris, scopes, created_at FROM oauth_clients WHERE user_id = $1 AND is_active = true'; + try { + const result = await pool.query(query, [userId]); + return result.rows; + } catch (error) { + console.error('查找用户客户端失败:', error); + throw error; + } + } + + // 删除客户端 + static async delete(clientId, userId) { + const query = 'UPDATE oauth_clients SET is_active = false WHERE client_id = $1 AND user_id = $2'; + try { + const result = await pool.query(query, [clientId, userId]); + return result.rowCount > 0; + } catch (error) { + console.error('删除OAuth客户端失败:', error); + throw error; + } + } +} + +module.exports = OAuthClient; \ No newline at end of file diff --git a/models/OAuthToken.js b/models/OAuthToken.js new file mode 100644 index 0000000..50912ed --- /dev/null +++ b/models/OAuthToken.js @@ -0,0 +1,207 @@ +const pool = require('../config/database'); +const crypto = require('crypto'); + +class OAuthToken { + // 生成授权码 + static generateAuthCode() { + return crypto.randomBytes(32).toString('hex'); + } + + // 生成访问令牌 + static generateAccessToken() { + return crypto.randomBytes(64).toString('hex'); + } + + // 生成刷新令牌 + static generateRefreshToken() { + return crypto.randomBytes(64).toString('hex'); + } + + // 创建授权码 + static async createAuthCode(authCodeData) { + const { code, clientId, userId, redirectUri, scopes } = authCodeData; + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10分钟过期 + + const query = ` + INSERT INTO oauth_auth_codes (code, client_id, user_id, redirect_uri, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + + try { + const result = await pool.query(query, [code, clientId, userId, redirectUri, scopes, expiresAt]); + return result.rows[0]; + } catch (error) { + console.error('创建授权码失败:', error); + throw error; + } + } + + // 验证授权码 + static async validateAuthCode(code, clientId, redirectUri) { + const query = ` + SELECT * FROM oauth_auth_codes + WHERE code = $1 AND client_id = $2 AND redirect_uri = $3 AND expires_at > NOW() + `; + + try { + const result = await pool.query(query, [code, clientId, redirectUri]); + // console.log(result.rows[0]); + // console.log(code, clientId, redirectUri); + // // console.log(); + return result.rows[0]; + } catch (error) { + console.error('验证授权码失败:', error); + throw error; + } + } + + // 删除授权码 + static async deleteAuthCode(code) { + const query = 'DELETE FROM oauth_auth_codes WHERE code = $1'; + try { + await pool.query(query, [code]); + } catch (error) { + console.error('删除授权码失败:', error); + throw error; + } + } + + // 创建访问令牌 + static async createAccessToken(accessTokenData) { + const { token, clientId, userId, scopes } = accessTokenData; + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1小时过期 + + const query = ` + INSERT INTO oauth_access_tokens (token, client_id, user_id, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + + try { + const result = await pool.query(query, [token, clientId, userId, scopes, expiresAt]); + return result.rows[0]; + } catch (error) { + console.error('创建访问令牌失败:', error); + throw error; + } + } + + // 创建刷新令牌 + static async createRefreshToken(refreshTokenData) { + const { token, accessTokenId, clientId, userId } = refreshTokenData; + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30天过期 + + const query = ` + INSERT INTO oauth_refresh_tokens (token, access_token_id, client_id, user_id, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `; + + try { + const result = await pool.query(query, [token, accessTokenId, clientId, userId, expiresAt]); + return result.rows[0]; + } catch (error) { + console.error('创建刷新令牌失败:', error); + throw error; + } + } + + // 验证访问令牌 + static async validateAccessToken(token) { + const query = ` + SELECT oat.*, u.username, u.email + FROM oauth_access_tokens oat + JOIN users u ON oat.user_id = u.id + WHERE oat.token = $1 AND oat.expires_at > NOW() + `; + + try { + const result = await pool.query(query, [token]); + return result.rows[0]; + } catch (error) { + console.error('验证访问令牌失败:', error); + throw error; + } + } + + // 撤销刷新令牌 + static async revokeRefreshToken(token) { + const query = 'UPDATE oauth_refresh_tokens SET is_revoked = true WHERE token = $1'; + try { + await pool.query(query, [token]); + } catch (error) { + console.error('撤销刷新令牌失败:', error); + throw error; + } + } + + // 验证刷新令牌 + static async validateRefreshToken(token, clientId) { + const query = ` + SELECT rt.*, at.scopes + FROM oauth_refresh_tokens rt + JOIN oauth_access_tokens at ON rt.access_token_id = at.id + WHERE rt.token = $1 AND rt.client_id = $2 AND rt.is_revoked = false AND rt.expires_at > NOW() + `; + + try { + const result = await pool.query(query, [token, clientId]); + return result.rows[0]; + } catch (error) { + console.error('验证刷新令牌失败:', error); + return null; + } + } + + // 撤销访问令牌 + static async revokeAccessToken(token) { + const query = 'DELETE FROM oauth_access_tokens WHERE token = $1'; + try { + const result = await pool.query(query, [token]); + return result.rowCount > 0; + } catch (error) { + console.error('撤销访问令牌失败:', error); + throw error; + } + } + + // 清理过期令牌(改进版) + static async cleanupExpiredTokens() { + try { + // 清理过期的访问令牌 + await pool.query('DELETE FROM oauth_access_tokens WHERE expires_at < NOW()'); + + // 清理过期的刷新令牌 + await pool.query('DELETE FROM oauth_refresh_tokens WHERE expires_at < NOW()'); + + // 清理过期的授权码 + await pool.query('DELETE FROM oauth_auth_codes WHERE expires_at < NOW()'); + + console.log('已清理过期令牌'); + } catch (error) { + console.error('清理过期令牌失败:', error); + throw error; + } + } + + // 获取用户的活跃令牌 + static async getActiveTokensByUserId(userId) { + const query = ` + SELECT oat.token, oat.client_id, oat.scopes, oat.expires_at, oc.name as client_name + FROM oauth_access_tokens oat + JOIN oauth_clients oc ON oat.client_id = oc.client_id + WHERE oat.user_id = $1 AND oat.expires_at > NOW() + `; + + try { + const result = await pool.query(query, [userId]); + return result.rows; + } catch (error) { + console.error('获取用户活跃令牌失败:', error); + throw error; + } + } +} + +module.exports = OAuthToken; \ No newline at end of file diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..b8d2b31 --- /dev/null +++ b/models/User.js @@ -0,0 +1,92 @@ +const pool = require('../config/database'); +const bcrypt = require('bcryptjs'); + +class User { + // 创建用户表 + static async createTable() { + const query = ` + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(100) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ); + `; + + try { + await pool.query(query); + console.log('用户表创建成功'); + } catch (error) { + console.error('创建用户表失败:', error); + throw error; + } + } + + // 根据用户名查找用户 + static async findByUsername(username) { + const query = 'SELECT * FROM users WHERE username = $1'; + try { + const result = await pool.query(query, [username]); + return result.rows[0]; + } catch (error) { + console.error('查找用户失败:', error); + throw error; + } + } + + // 根据邮箱查找用户 + static async findByEmail(email) { + const query = 'SELECT * FROM users WHERE email = $1'; + try { + const result = await pool.query(query, [email]); + return result.rows[0]; + } catch (error) { + console.error('查找用户失败:', error); + throw error; + } + } + + // 创建新用户 + static async create(userData) { + const { username, email, password } = userData; + + // 加密密码 + const saltRounds = 10; + const hashedPassword = await bcrypt.hash(password, saltRounds); + + const query = ` + INSERT INTO users (username, email, password) + VALUES ($1, $2, $3) + RETURNING id, username, email, created_at + `; + + try { + const result = await pool.query(query, [username, email, hashedPassword]); + return result.rows[0]; + } catch (error) { + console.error('创建用户失败:', error); + throw error; + } + } + + // 验证密码 + static async verifyPassword(password, hashedPassword) { + return await bcrypt.compare(password, hashedPassword); + } + + // 获取所有用户(仅用于测试) + static async findAll() { + const query = 'SELECT id, username, email, created_at FROM users'; + try { + const result = await pool.query(query); + return result.rows; + } catch (error) { + console.error('获取用户列表失败:', error); + throw error; + } + } +} + +module.exports = User; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6c5d325 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "auth-api", + "version": "1.0.0", + "description": "登录和注册API系统", + "main": "index.js", + "scripts": { + "start": "node index.js", + "dev": "nodemon index.js", + "test": "node test-api.js", + "test:oauth": "node test-oauth.js", + "dev:frontend": "vite", + "build:frontend": "vite build", + "preview:frontend": "vite preview" + }, + "dependencies": { + "express": "^4.18.2", + "pg": "^8.11.3", + "bcryptjs": "^2.4.3", + "jsonwebtoken": "^9.0.2", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express-validator": "^7.0.1" + }, + "devDependencies": { + "nodemon": "^3.0.1", + "axios": "^1.5.0", + "@vitejs/plugin-react": "^4.0.0", + "vite": "^4.4.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@mui/material": "^5.14.0", + "@mui/icons-material": "^5.14.0", + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "react-router-dom": "^6.15.0" + }, + "keywords": ["auth", "api", "postgresql", "login", "register", "oauth"], + "author": "", + "license": "MIT" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..432b401 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2486 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.3.1 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + express-validator: + specifier: ^7.0.1 + version: 7.2.1 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 + pg: + specifier: ^8.11.3 + version: 8.16.3 + devDependencies: + '@emotion/react': + specifier: ^11.11.0 + version: 11.14.0(@types/react@19.1.9)(react@18.3.1) + '@emotion/styled': + specifier: ^11.11.0 + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1) + '@mui/icons-material': + specifier: ^5.14.0 + version: 5.18.0(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@19.1.9)(react@18.3.1) + '@mui/material': + specifier: ^5.14.0 + version: 5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.7.0(vite@4.5.14) + axios: + specifier: ^1.5.0 + version: 1.11.0 + nodemon: + specifier: ^3.0.1 + version: 3.1.10 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.15.0 + version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vite: + specifier: ^4.4.0 + version: 4.5.14 + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.0': + resolution: {integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.0': + resolution: {integrity: sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.27.3': + resolution: {integrity: sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.2': + resolution: {integrity: sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.0': + resolution: {integrity: sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.2': + resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.0': + resolution: {integrity: sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.2': + resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} + engines: {node: '>=6.9.0'} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.3.1': + resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.12': + resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.4': + resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + + '@jridgewell/trace-mapping@0.3.29': + resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + + '@mui/core-downloads-tracker@5.18.0': + resolution: {integrity: sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==} + + '@mui/icons-material@5.18.0': + resolution: {integrity: sha512-1s0vEZj5XFXDMmz3Arl/R7IncFqJ+WQ95LDp1roHWGDE2oCO3IS4/hmiOv1/8SD9r6B7tv9GLiqVZYHo+6PkTg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@mui/material': ^5.0.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material@5.18.0': + resolution: {integrity: sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@5.17.1': + resolution: {integrity: sha512-XMxU0NTYcKqdsG8LRmSoxERPXwMbp16sIXPcLVgLGII/bVNagX0xaheWAwFv8+zDK7tI3ajllkuD3GZZE++ICQ==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@5.18.0': + resolution: {integrity: sha512-BN/vKV/O6uaQh2z5rXV+MBlVrEkwoS/TK75rFQ2mjxA7+NBo8qtTAOA4UaM0XeJfn7kh2wZ+xQw2HAx0u+TiBg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@5.18.0': + resolution: {integrity: sha512-ojZGVcRWqWhu557cdO3pWHloIGJdzVtxs3rk0F9L+x55LsUjcMUVkEhiF7E4TMxZoF9MmIHGGs0ZX3FDLAf0Xw==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.2.24': + resolution: {integrity: sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@5.17.1': + resolution: {integrity: sha512-jEZ8FTqInt2WzxDV8bhImWBqeQRD99c/id/fq83H0ER9tFl+sfZlaAoCdznGvbSQQ9ividMxqSV2c7cC1vBcQg==} + engines: {node: '>=12.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@remix-run/router@1.23.0': + resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} + engines: {node: '>=14.0.0'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.1.9': + resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.25.1: + resolution: {integrity: sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001731: + resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.192: + resolution: {integrity: sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express-validator@7.2.1: + resolution: {integrity: sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==} + engines: {node: '>= 8.0.0'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + nodemon@3.1.10: + resolution: {integrity: sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pg-cloudflare@1.2.7: + resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} + + pg-connection-string@2.9.1: + resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.1: + resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.3: + resolution: {integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.3: + resolution: {integrity: sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@19.1.1: + resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@6.30.1: + resolution: {integrity: sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.1: + resolution: {integrity: sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@3.29.5: + resolution: {integrity: sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@4.5.14: + resolution: {integrity: sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.0': {} + + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.0 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.27.3(@babel/core@7.28.0) + '@babel/helpers': 7.28.2 + '@babel/parser': 7.28.0 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.2 + convert-source-map: 2.0.0 + debug: 4.4.1(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.0': + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.2 + '@jridgewell/gen-mapping': 0.3.12 + '@jridgewell/trace-mapping': 0.3.29 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.25.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.2 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.27.3(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.2': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.2 + + '@babel/parser@7.28.0': + dependencies: + '@babel/types': 7.28.2 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.2': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.0 + '@babel/types': 7.28.2 + + '@babel/traverse@7.28.0': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.0 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.0 + '@babel/template': 7.27.2 + '@babel/types': 7.28.2 + debug: 4.4.1(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.2': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/runtime': 7.28.2 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.3.1': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 19.1.9 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.3.1 + '@emotion/react': 11.14.0(@types/react@19.1.9)(react@18.3.1) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@18.3.1) + '@emotion/utils': 1.4.2 + react: 18.3.1 + optionalDependencies: + '@types/react': 19.1.9 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@jridgewell/gen-mapping@0.3.12': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/trace-mapping': 0.3.29 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.4': {} + + '@jridgewell/trace-mapping@0.3.29': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.4 + + '@mui/core-downloads-tracker@5.18.0': {} + + '@mui/icons-material@5.18.0(@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@19.1.9)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@mui/material': 5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 19.1.9 + + '@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@mui/core-downloads-tracker': 5.18.0 + '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1) + '@mui/types': 7.2.24(@types/react@19.1.9) + '@mui/utils': 5.17.1(@types/react@19.1.9)(react@18.3.1) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.1.9) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 19.1.1 + react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.9)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1) + '@types/react': 19.1.9 + + '@mui/private-theming@5.17.1(@types/react@19.1.9)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@mui/utils': 5.17.1(@types/react@19.1.9)(react@18.3.1) + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@types/react': 19.1.9 + + '@mui/styled-engine@5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.9)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1) + + '@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@mui/private-theming': 5.17.1(@types/react@19.1.9)(react@18.3.1) + '@mui/styled-engine': 5.18.0(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1))(react@18.3.1) + '@mui/types': 7.2.24(@types/react@19.1.9) + '@mui/utils': 5.17.1(@types/react@19.1.9)(react@18.3.1) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 18.3.1 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.9)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.9)(react@18.3.1))(@types/react@19.1.9)(react@18.3.1) + '@types/react': 19.1.9 + + '@mui/types@7.2.24(@types/react@19.1.9)': + optionalDependencies: + '@types/react': 19.1.9 + + '@mui/utils@5.17.1(@types/react@19.1.9)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.28.2 + '@mui/types': 7.2.24(@types/react@19.1.9) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 18.3.1 + react-is: 19.1.1 + optionalDependencies: + '@types/react': 19.1.9 + + '@popperjs/core@2.11.8': {} + + '@remix-run/router@1.23.0': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.2 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.2 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.0 + '@babel/types': 7.28.2 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.28.2 + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-transition-group@4.4.12(@types/react@19.1.9)': + dependencies: + '@types/react': 19.1.9 + + '@types/react@19.1.9': + dependencies: + csstype: 3.1.3 + + '@vitejs/plugin-react@4.7.0(vite@4.5.14)': + dependencies: + '@babel/core': 7.28.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 4.5.14 + transitivePeerDependencies: + - supports-color + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + array-flatten@1.1.1: {} + + asynckit@0.4.0: {} + + axios@1.11.0: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.28.2 + cosmiconfig: 7.1.0 + resolve: 1.22.10 + + balanced-match@1.0.2: {} + + bcryptjs@2.4.3: {} + + binary-extensions@2.3.0: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.25.1: + dependencies: + caniuse-lite: 1.0.30001731 + electron-to-chromium: 1.5.192 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.25.1) + + buffer-equal-constant-time@1.0.1: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001731: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + clsx@2.1.1: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie@0.7.1: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + csstype@3.1.3: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.1(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.2 + csstype: 3.1.3 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.192: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + etag@1.8.1: {} + + express-validator@7.2.1: + dependencies: + lodash: 4.17.21 + validator: 13.12.0 + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-root@1.1.0: {} + + follow-redirects@1.15.9: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ignore-by-default@1.0.1: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.2 + + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + + lines-and-columns@1.2.4: {} + + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + negotiator@0.6.3: {} + + node-releases@2.0.19: {} + + nodemon@3.1.10: + dependencies: + chokidar: 3.6.0 + debug: 4.4.1(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.2 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.12: {} + + path-type@4.0.0: {} + + pg-cloudflare@1.2.7: + optional: true + + pg-connection-string@2.9.1: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.1(pg@8.16.3): + dependencies: + pg: 8.16.3 + + pg-protocol@1.10.3: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.3: + dependencies: + pg-connection-string: 2.9.1 + pg-pool: 3.10.1(pg@8.16.3) + pg-protocol: 1.10.3 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.7 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pstree.remy@1.1.8: {} + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-is@19.1.1: {} + + react-refresh@0.17.0: {} + + react-router-dom@6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.1(react@18.3.1) + + react-router@6.30.1(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.0 + react: 18.3.1 + + react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@babel/runtime': 7.28.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@3.29.5: + optionalDependencies: + fsevents: 2.3.3 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + semver@7.7.2: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.2 + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + split2@4.2.0: {} + + statuses@2.0.1: {} + + stylis@4.2.0: {} + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + undefsafe@2.0.5: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.25.1): + dependencies: + browserslist: 4.25.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + utils-merge@1.0.1: {} + + validator@13.12.0: {} + + vary@1.1.2: {} + + vite@4.5.14: + dependencies: + esbuild: 0.18.20 + postcss: 8.5.6 + rollup: 3.29.5 + optionalDependencies: + fsevents: 2.3.3 + + xtend@4.0.2: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..9adc1cd --- /dev/null +++ b/public/index.html @@ -0,0 +1,245 @@ + + + + + + OAuth认证系统 + + + + + +
+
+
+
+
+ +

登录

+

欢迎回来,请登录您的账户

+
+ +
+ +
+
+ + +
+ + + +
+ +
+ + +
+
+ + + + + +
+

+ 还没有账户? + 立即注册 +

+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..7f24ac1 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,162 @@ +const express = require('express'); +const User = require('../models/User'); +const { generateToken, authenticateToken } = require('../middleware/auth'); +const { + registerValidation, + loginValidation, + handleValidationErrors +} = require('../middleware/validation'); + +const router = express.Router(); + +// 注册路由 +router.post('/register', registerValidation, handleValidationErrors, async (req, res) => { + try { + const { username, email, password } = req.body; + + // 检查用户名是否已存在 + const existingUserByUsername = await User.findByUsername(username); + if (existingUserByUsername) { + return res.status(400).json({ + success: false, + message: '用户名已存在' + }); + } + + // 检查邮箱是否已存在 + const existingUserByEmail = await User.findByEmail(email); + if (existingUserByEmail) { + return res.status(400).json({ + success: false, + message: '邮箱已被注册' + }); + } + + // 创建新用户 + const newUser = await User.create({ username, email, password }); + + // 生成JWT token + const token = generateToken(newUser.id, newUser.username); + + res.status(201).json({ + success: true, + message: '注册成功', + data: { + user: { + id: newUser.id, + username: newUser.username, + email: newUser.email, + created_at: newUser.created_at + }, + token + } + }); + + } catch (error) { + console.error('注册失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 登录路由 +router.post('/login', loginValidation, handleValidationErrors, async (req, res) => { + try { + const { username, password } = req.body; + + // 查找用户(支持用户名或邮箱登录) + let user = await User.findByUsername(username); + if (!user) { + // 如果不是用户名,尝试用邮箱查找 + user = await User.findByEmail(username); + } + + if (!user) { + return res.status(401).json({ + success: false, + message: '用户名或密码错误' + }); + } + + // 验证密码 + const isPasswordValid = await User.verifyPassword(password, user.password); + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + message: '用户名或密码错误' + }); + } + + // 生成JWT token + const token = generateToken(user.id, user.username); + + res.json({ + success: true, + message: '登录成功', + data: { + user: { + id: user.id, + username: user.username, + email: user.email, + created_at: user.created_at + }, + token + } + }); + + } catch (error) { + console.error('登录失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 获取当前用户信息(需要认证) +router.get('/profile', authenticateToken, async (req, res) => { + try { + const user = await User.findByUsername(req.user.username); + + if (!user) { + return res.status(404).json({ + success: false, + message: '用户不存在' + }); + } + + res.json({ + success: true, + data: { + user: { + id: user.id, + username: user.username, + email: user.email, + created_at: user.created_at + } + } + }); + + } catch (error) { + console.error('获取用户信息失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 测试路由(需要认证) +router.get('/test', authenticateToken, (req, res) => { + res.json({ + success: true, + message: '认证成功', + data: { + user: req.user + } + }); +}); + +module.exports = router; \ No newline at end of file diff --git a/routes/oauth-clients.js b/routes/oauth-clients.js new file mode 100644 index 0000000..28bcf65 --- /dev/null +++ b/routes/oauth-clients.js @@ -0,0 +1,349 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const OAuthClient = require('../models/OAuthClient'); +const { authenticateToken } = require('../middleware/auth'); + +const router = express.Router(); + +// 验证中间件 +const handleValidationErrors = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: '输入验证失败', + errors: errors.array().map(error => ({ + field: error.path, + message: error.msg + })) + }); + } + next(); +}; + +// 自定义URL验证函数 +const isValidRedirectUri = (value) => { + console.log('验证URL:', value); + try { + const url = new URL(value); + console.log('URL解析结果:', { + protocol: url.protocol, + hostname: url.hostname, + pathname: url.pathname, + isValid: (url.protocol === 'http:' || url.protocol === 'https:') && + url.hostname && + url.pathname + }); + // 允许http和https协议,包括localhost + return (url.protocol === 'http:' || url.protocol === 'https:') && + url.hostname && + url.pathname; + } catch (error) { + console.log('URL验证失败:', value, error.message); + return false; + } +}; + +// 创建OAuth客户端验证规则 +const createClientValidation = [ + body('name') + .isLength({ min: 1, max: 100 }) + .withMessage('应用名称长度必须在1-100个字符之间'), + + body('description') + .optional() + .isLength({ max: 500 }) + .withMessage('描述长度不能超过500个字符'), + + body('redirect_uris') + .isArray({ min: 1 }) + .withMessage('至少需要一个重定向URI'), + + body('scopes') + .optional() + .isArray() + .withMessage('权限范围必须是数组格式') +]; + +// 自定义验证中间件 +const validateRedirectUris = (req, res, next) => { + const { redirect_uris } = req.body; + + if (!Array.isArray(redirect_uris)) { + return res.status(400).json({ + success: false, + message: 'redirect_uris必须是数组' + }); + } + + for (let i = 0; i < redirect_uris.length; i++) { + if (!isValidRedirectUri(redirect_uris[i])) { + return res.status(400).json({ + success: false, + message: '输入验证失败', + errors: [{ + field: `redirect_uris[${i}]`, + message: '重定向URI必须是有效的URL' + }] + }); + } + } + + next(); +}; + +// 1. 创建OAuth客户端 +router.post('/clients', authenticateToken, createClientValidation, validateRedirectUris, handleValidationErrors, async (req, res) => { + try { + const { name, description, redirect_uris, scopes } = req.body; + const userId = req.user.userId; + + const clientData = { + name, + description: description || '', + redirectUris: redirect_uris, + scopes: scopes || ['read', 'write'], + userId + }; + + const newClient = await OAuthClient.create(clientData); + + res.status(201).json({ + success: true, + message: 'OAuth客户端创建成功', + data: { + client_id: newClient.client_id, + client_secret: newClient.client_secret, // 返回客户端密钥 + name: newClient.name, + description: newClient.description, + redirect_uris: newClient.redirect_uris, + scopes: newClient.scopes, + created_at: newClient.created_at + } + }); + + } catch (error) { + console.error('创建OAuth客户端失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 2. 获取用户的所有OAuth客户端 +router.get('/clients', authenticateToken, async (req, res) => { + try { + const userId = req.user.userId; + const clients = await OAuthClient.findByUserId(userId); + + res.json({ + success: true, + data: { + clients: clients.map(client => ({ + client_id: client.client_id, + name: client.name, + description: client.description, + redirect_uris: client.redirect_uris, + scopes: client.scopes, + created_at: client.created_at + })) + } + }); + + } catch (error) { + console.error('获取OAuth客户端失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 3. 获取特定OAuth客户端详情 +router.get('/clients/:clientId', authenticateToken, async (req, res) => { + try { + const { clientId } = req.params; + const userId = req.user.userId; + + const client = await OAuthClient.findByClientId(clientId); + if (!client) { + return res.status(404).json({ + success: false, + message: 'OAuth客户端不存在' + }); + } + + // 检查是否是客户端所有者 + if (client.user_id !== userId) { + return res.status(403).json({ + success: false, + message: '无权访问此客户端' + }); + } + + res.json({ + success: true, + data: { + client_id: client.client_id, + name: client.name, + description: client.description, + redirect_uris: client.redirect_uris, + scopes: client.scopes, + created_at: client.created_at + } + }); + + } catch (error) { + console.error('获取OAuth客户端详情失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 4. 删除OAuth客户端 +router.delete('/clients/:clientId', authenticateToken, async (req, res) => { + try { + const { clientId } = req.params; + const userId = req.user.userId; + + const deleted = await OAuthClient.delete(clientId, userId); + if (!deleted) { + return res.status(404).json({ + success: false, + message: 'OAuth客户端不存在或无权删除' + }); + } + + res.json({ + success: true, + message: 'OAuth客户端已删除' + }); + + } catch (error) { + console.error('删除OAuth客户端失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 4. 获取客户端密钥(仅限客户端所有者) +router.get('/clients/:clientId/secret', authenticateToken, async (req, res) => { + try { + const { clientId } = req.params; + const userId = req.user.userId; + + const client = await OAuthClient.findByClientId(clientId); + if (!client) { + return res.status(404).json({ + success: false, + message: 'OAuth客户端不存在' + }); + } + + // 检查是否是客户端所有者 + if (client.user_id !== userId) { + return res.status(403).json({ + success: false, + message: '无权访问此客户端' + }); + } + + res.json({ + success: true, + data: { + client_id: client.client_id, + client_secret: client.client_secret + } + }); + + } catch (error) { + console.error('获取客户端密钥失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 5. 重置客户端密钥 +router.post('/clients/:clientId/reset-secret', authenticateToken, async (req, res) => { + try { + const { clientId } = req.params; + const userId = req.user.userId; + + const client = await OAuthClient.findByClientId(clientId); + if (!client) { + return res.status(404).json({ + success: false, + message: 'OAuth客户端不存在' + }); + } + + // 检查是否是客户端所有者 + if (client.user_id !== userId) { + return res.status(403).json({ + success: false, + message: '无权操作此客户端' + }); + } + + const newSecret = await OAuthClient.resetClientSecret(clientId); + + res.json({ + success: true, + message: '客户端密钥重置成功', + data: { + client_id: clientId, + client_secret: newSecret + } + }); + + } catch (error) { + console.error('重置客户端密钥失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 5. 获取OAuth客户端统计信息 +router.get('/clients/:clientId/stats', authenticateToken, async (req, res) => { + try { + const { clientId } = req.params; + const userId = req.user.userId; + + const client = await OAuthClient.findByClientId(clientId); + if (!client || client.user_id !== userId) { + return res.status(404).json({ + success: false, + message: 'OAuth客户端不存在' + }); + } + + // 这里可以添加更多统计信息,比如活跃令牌数量等 + res.json({ + success: true, + data: { + client_id: client.client_id, + name: client.name, + created_at: client.created_at, + // 可以添加更多统计信息 + } + }); + + } catch (error) { + console.error('获取OAuth客户端统计失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/routes/oauth.js b/routes/oauth.js new file mode 100644 index 0000000..12048cf --- /dev/null +++ b/routes/oauth.js @@ -0,0 +1,375 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const OAuthClient = require('../models/OAuthClient'); +const OAuthToken = require('../models/OAuthToken'); +const User = require('../models/User'); +const { authenticateToken } = require('../middleware/auth'); +const { authenticateOAuthToken, requireScope } = require('../middleware/oauth'); + +const router = express.Router(); + +// 验证中间件 +const handleValidationErrors = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: '输入验证失败', + errors: errors.array().map(error => ({ + field: error.path, + message: error.msg + })) + }); + } + next(); +}; + +// 1. 授权端点 - 用户授权第三方应用 +router.get('/authorize', authenticateToken, async (req, res) => { + try { + const { + response_type, + client_id, + redirect_uri, + scope, + state + } = req.query; + + // 验证必需参数 + if (!response_type || !client_id || !redirect_uri) { + return res.status(400).json({ + success: false, + message: '缺少必需参数' + }); + } + + // 验证response_type + if (response_type !== 'code') { + return res.status(400).json({ + success: false, + message: '不支持的response_type' + }); + } + + // 验证客户端 + const client = await OAuthClient.findByClientId(client_id); + if (!client) { + return res.status(400).json({ + success: false, + message: '无效的客户端ID' + }); + } + + // 验证重定向URI + if (!client.redirect_uris.includes(redirect_uri)) { + return res.status(400).json({ + success: false, + message: '无效的重定向URI' + }); + } + + // 获取用户信息(通过JWT中间件已经验证) + const user = await User.findByUsername(req.user.username); + if (!user) { + return res.status(401).json({ + success: false, + message: '用户不存在' + }); + } + + // 生成授权码 + const authCode = OAuthToken.generateAuthCode(); + const scopes = scope ? scope.split(' ') : ['read', 'write']; + + await OAuthToken.createAuthCode({ + code: authCode, + clientId: client_id, + userId: user.id, + redirectUri: redirect_uri, + scopes: scopes + }); + + // 构建重定向URL + const redirectUrl = new URL(redirect_uri); + redirectUrl.searchParams.set('code', authCode); + if (state) { + redirectUrl.searchParams.set('state', state); + } + + // 对于API测试,直接返回授权码 + // 对于生产环境,应该重定向到redirectUrl + res.json({ + success: true, + message: '授权成功', + data: { + redirect_url: redirectUrl.toString(), + code: authCode, + state: state + } + }); + + } catch (error) { + console.error('授权失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 2. 令牌端点 - 交换授权码获取访问令牌 +router.post('/token', [ + body('grant_type').notEmpty().withMessage('grant_type不能为空'), + body('client_id').notEmpty().withMessage('client_id不能为空'), + body('client_secret').notEmpty().withMessage('client_secret不能为空'), + handleValidationErrors +], async (req, res) => { + try { + const { + grant_type, + client_id, + client_secret, + code, + redirect_uri, + refresh_token, + code_verifier // PKCE支持 + } = req.body; + + // 验证客户端 + const isValidClient = await OAuthClient.validateClient(client_id, client_secret); + if (!isValidClient) { + return res.status(401).json({ + success: false, + message: '无效的客户端凭据' + }); + } + + if (grant_type === 'authorization_code') { + // 授权码流程 + if (!code || !redirect_uri) { + return res.status(400).json({ + success: false, + message: '缺少code或redirect_uri' + }); + } + + // 验证授权码 + const authCode = await OAuthToken.validateAuthCode(code, client_id, redirect_uri); + if (!authCode) { + return res.status(400).json({ + success: false, + message: '无效的授权码' + }); + } + + // 验证重定向URI + const isValidRedirectUri = await OAuthClient.validateRedirectUri(client_id, redirect_uri); + if (!isValidRedirectUri) { + return res.status(400).json({ + success: false, + message: '无效的重定向URI' + }); + } + + // 生成访问令牌和刷新令牌 + const accessToken = OAuthToken.generateAccessToken(); + const refreshToken = OAuthToken.generateRefreshToken(); + + // 创建访问令牌 + const accessTokenData = await OAuthToken.createAccessToken({ + token: accessToken, + clientId: client_id, + userId: authCode.user_id, + scopes: authCode.scopes + }); + + // 创建刷新令牌 + await OAuthToken.createRefreshToken({ + token: refreshToken, + accessTokenId: accessTokenData.id, + clientId: client_id, + userId: authCode.user_id + }); + + // 删除已使用的授权码 + await OAuthToken.deleteAuthCode(code); + + res.json({ + success: true, + data: { + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, // 1小时 + refresh_token: refreshToken, + scope: authCode.scopes.join(' ') + } + }); + + } else if (grant_type === 'refresh_token') { + // 刷新令牌流程 + if (!refresh_token) { + return res.status(400).json({ + success: false, + message: '缺少refresh_token' + }); + } + + // 验证刷新令牌 + const refreshTokenData = await OAuthToken.validateRefreshToken(refresh_token, client_id); + if (!refreshTokenData) { + return res.status(400).json({ + success: false, + message: '无效的刷新令牌' + }); + } + + // 生成新的访问令牌 + const newAccessToken = OAuthToken.generateAccessToken(); + const newRefreshToken = OAuthToken.generateRefreshToken(); + + // 创建新的访问令牌 + const newAccessTokenData = await OAuthToken.createAccessToken({ + token: newAccessToken, + clientId: client_id, + userId: refreshTokenData.user_id, + scopes: refreshTokenData.scopes + }); + + // 创建新的刷新令牌 + await OAuthToken.createRefreshToken({ + token: newRefreshToken, + accessTokenId: newAccessTokenData.id, + clientId: client_id, + userId: refreshTokenData.user_id + }); + + // 撤销旧的刷新令牌 + await OAuthToken.revokeRefreshToken(refresh_token); + + res.json({ + success: true, + data: { + access_token: newAccessToken, + token_type: 'Bearer', + expires_in: 3600, + refresh_token: newRefreshToken, + scope: refreshTokenData.scopes.join(' ') + } + }); + + } else { + return res.status(400).json({ + success: false, + message: '不支持的授权类型' + }); + } + + } catch (error) { + console.error('令牌交换失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 3. 撤销令牌端点 +router.post('/revoke', [ + body('token').notEmpty().withMessage('token不能为空'), + body('client_id').notEmpty().withMessage('client_id不能为空'), + body('client_secret').notEmpty().withMessage('client_secret不能为空'), + handleValidationErrors +], async (req, res) => { + try { + const { token, client_id, client_secret } = req.body; + + // 验证客户端 + const isValidClient = await OAuthClient.validateClient(client_id, client_secret); + if (!isValidClient) { + return res.status(401).json({ + success: false, + message: '无效的客户端凭据' + }); + } + + // 撤销令牌 + const revoked = await OAuthToken.revokeAccessToken(token); + if (!revoked) { + await OAuthToken.revokeRefreshToken(token); + } + + res.json({ + success: true, + message: '令牌已撤销' + }); + + } catch (error) { + console.error('撤销令牌失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 4. 用户信息端点 +router.get('/userinfo', authenticateOAuthToken, requireScope('read'), async (req, res) => { + try { + res.json({ + success: true, + data: { + user_id: req.oauth.userId, + username: req.oauth.username, + email: req.oauth.email, + scopes: req.oauth.scopes + } + }); + } catch (error) { + console.error('获取用户信息失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +// 5. 令牌信息端点 +router.get('/tokeninfo', async (req, res) => { + try { + const { access_token } = req.query; + + if (!access_token) { + return res.status(400).json({ + success: false, + message: '缺少access_token参数' + }); + } + + const tokenData = await OAuthToken.validateAccessToken(access_token); + if (!tokenData) { + return res.status(400).json({ + success: false, + message: '无效的访问令牌' + }); + } + + res.json({ + success: true, + data: { + user_id: tokenData.user_id, + client_id: tokenData.client_id, + scopes: tokenData.scopes, + expires_in: Math.floor((new Date(tokenData.expires_at) - new Date()) / 1000) + } + }); + + } catch (error) { + console.error('获取令牌信息失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..d325194 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import { Routes, Route, Navigate } from 'react-router-dom' +import { Box } from '@mui/material' +import Login from './pages/Login' +import Register from './pages/Register' +import Dashboard from './pages/Dashboard' +import OAuthAuthorize from './pages/OAuthAuthorize' +import { AuthProvider } from './contexts/AuthContext' +import PrivateRoute from './components/PrivateRoute' + +function App() { + return ( + + + + } /> + } /> + } /> + + + + } + /> + } /> + + + + ) +} + +export default App \ No newline at end of file diff --git a/src/components/PrivateRoute.jsx b/src/components/PrivateRoute.jsx new file mode 100644 index 0000000..0ceac38 --- /dev/null +++ b/src/components/PrivateRoute.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { CircularProgress, Box } from '@mui/material' + +const PrivateRoute = ({ children }) => { + const { user, loading } = useAuth() + + if (loading) { + return ( + + + + ) + } + + return user ? children : +} + +export default PrivateRoute \ No newline at end of file diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..1282be2 --- /dev/null +++ b/src/contexts/AuthContext.jsx @@ -0,0 +1,91 @@ +import React, { createContext, useContext, useState, useEffect } from 'react' +import axios from 'axios' + +const AuthContext = createContext() + +export const useAuth = () => { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const token = localStorage.getItem('token') + if (token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${token}` + checkAuthStatus() + } else { + setLoading(false) + } + }, []) + + const checkAuthStatus = async () => { + try { + const response = await axios.get('/api/auth/profile') + setUser(response.data.data) + } catch (error) { + localStorage.removeItem('token') + delete axios.defaults.headers.common['Authorization'] + } finally { + setLoading(false) + } + } + + const login = async (credentials) => { + try { + const response = await axios.post('/api/auth/login', credentials) + const { token, user } = response.data.data + localStorage.setItem('token', token) + axios.defaults.headers.common['Authorization'] = `Bearer ${token}` + setUser(user) + return { success: true } + } catch (error) { + return { + success: false, + message: error.response?.data?.message || '登录失败' + } + } + } + + const register = async (userData) => { + try { + const response = await axios.post('/api/auth/register', userData) + const { token, user } = response.data.data + localStorage.setItem('token', token) + axios.defaults.headers.common['Authorization'] = `Bearer ${token}` + setUser(user) + return { success: true } + } catch (error) { + return { + success: false, + message: error.response?.data?.message || '注册失败' + } + } + } + + const logout = () => { + localStorage.removeItem('token') + delete axios.defaults.headers.common['Authorization'] + setUser(null) + } + + const value = { + user, + loading, + login, + register, + logout + } + + return ( + + {children} + + ) +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..336cbe0 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,28 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { ThemeProvider, createTheme } from '@mui/material/styles' +import CssBaseline from '@mui/material/CssBaseline' +import App from './App' + +const theme = createTheme({ + palette: { + primary: { + main: '#1976d2', + }, + secondary: { + main: '#dc004e', + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + + +) \ No newline at end of file diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx new file mode 100644 index 0000000..47847f3 --- /dev/null +++ b/src/pages/Dashboard.jsx @@ -0,0 +1,273 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { + Container, + Paper, + Typography, + Box, + Button, + Grid, + Card, + CardContent, + CardActions, + List, + ListItem, + ListItemText, + ListItemIcon, + Divider, + Chip, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Alert +} from '@mui/material' +import { + Person, + Email, + CalendarToday, + Security, + Add, + Delete, + Visibility, + VisibilityOff, + Logout +} from '@mui/icons-material' +import axios from 'axios' + +const Dashboard = () => { + const { user, logout } = useAuth() + const [clients, setClients] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [openCreateDialog, setOpenCreateDialog] = useState(false) + const [newClient, setNewClient] = useState({ + name: '', + description: '', + redirect_uris: [''], + scopes: ['read', 'write'] + }) + + useEffect(() => { + fetchClients() + }, []) + + const fetchClients = async () => { + try { + const response = await axios.get('/api/oauth/clients') + setClients(response.data.data.clients) + } catch (error) { + setError('获取客户端列表失败') + } + } + + const handleCreateClient = async () => { + setLoading(true) + try { + await axios.post('/api/oauth/clients', newClient) + setOpenCreateDialog(false) + setNewClient({ name: '', description: '', redirect_uris: [''], scopes: ['read', 'write'] }) + fetchClients() + } catch (error) { + setError(error.response?.data?.message || '创建客户端失败') + } + setLoading(false) + } + + const handleDeleteClient = async (clientId) => { + if (window.confirm('确定要删除这个客户端吗?')) { + try { + await axios.delete(`/api/oauth/clients/${clientId}`) + fetchClients() + } catch (error) { + setError('删除客户端失败') + } + } + } + + const handleLogout = () => { + logout() + } + + return ( + + + + 个人中心 + + + 欢迎回来,{user?.username}! + + + + {error && ( + setError('')}> + {error} + + )} + + + {/* 用户信息卡片 */} + + + + + 用户信息 + + + + + + + + + + + + + + + + + + + + + + + + + + {/* OAuth客户端管理 */} + + + + + + OAuth客户端 + + + + + {clients.length === 0 ? ( + + + 还没有OAuth客户端,点击上方按钮创建一个 + + + ) : ( + + {clients.map((client) => ( + + + + + + + {client.name} + + + {client.description} + + + + 客户端ID: {client.client_id} + + + + {client.scopes.map((scope) => ( + + ))} + + + + + + + + ))} + + )} + + + + + {/* 创建客户端对话框 */} + setOpenCreateDialog(false)} maxWidth="sm" fullWidth> + 创建OAuth客户端 + + setNewClient({ ...newClient, name: e.target.value })} + margin="normal" + required + /> + setNewClient({ ...newClient, description: e.target.value })} + margin="normal" + multiline + rows={2} + /> + setNewClient({ + ...newClient, + redirect_uris: [e.target.value] + })} + margin="normal" + required + placeholder="http://localhost:3001/callback" + /> + + + + + + + + ) +} + +export default Dashboard \ No newline at end of file diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx new file mode 100644 index 0000000..2b66d87 --- /dev/null +++ b/src/pages/Login.jsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react' +import { Link, Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { + Container, + Paper, + TextField, + Button, + Typography, + Box, + Alert, + InputAdornment, + IconButton +} from '@mui/material' +import { Visibility, VisibilityOff, Login as LoginIcon } from '@mui/icons-material' + +const Login = () => { + const { login, user } = useAuth() + const [formData, setFormData] = useState({ + username: '', + password: '' + }) + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + if (user) { + return + } + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }) + } + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + + const result = await login(formData) + if (!result.success) { + setError(result.message) + } + setLoading(false) + } + + return ( + + + + + + + 登录 + + + 欢迎回来,请登录您的账户 + + + + {error && ( + + {error} + + )} + +
+ + + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ) + }} + /> + + + + + + 还没有账户?{' '} + + + 立即注册 + + + + + +
+
+
+ ) +} + +export default Login \ No newline at end of file diff --git a/src/pages/OAuthAuthorize.jsx b/src/pages/OAuthAuthorize.jsx new file mode 100644 index 0000000..e845481 --- /dev/null +++ b/src/pages/OAuthAuthorize.jsx @@ -0,0 +1,250 @@ +import React, { useState, useEffect } from 'react' +import { useSearchParams, useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { + Container, + Paper, + Typography, + Box, + Button, + Grid, + Card, + CardContent, + List, + ListItem, + ListItemIcon, + ListItemText, + Chip, + Alert, + CircularProgress +} from '@mui/material' +import { + Security, + CheckCircle, + Cancel, + Person, + Email, + CalendarToday, + Visibility, + VisibilityOff +} from '@mui/icons-material' +import axios from 'axios' + +const OAuthAuthorize = () => { + const { user } = useAuth() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [clientInfo, setClientInfo] = useState(null) + + const clientId = searchParams.get('client_id') + const redirectUri = searchParams.get('redirect_uri') + const scope = searchParams.get('scope') + const state = searchParams.get('state') + const responseType = searchParams.get('response_type') + + useEffect(() => { + if (!user) { + navigate('/login') + return + } + + if (!clientId || !redirectUri || responseType !== 'code') { + setError('无效的授权请求') + return + } + + // 这里可以添加客户端信息获取逻辑 + // 为了演示,我们使用默认信息 + setClientInfo({ + name: '第三方应用', + description: '请求访问您的账户信息', + scopes: scope ? scope.split(' ') : ['read', 'write'] + }) + }, [user, clientId, redirectUri, scope, responseType, navigate]) + + const handleAuthorize = async () => { + setLoading(true) + try { + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: scope || 'read write', + state: state || '' + }) + + const response = await axios.get(`/api/oauth/authorize?${params}`, { + headers: { + Authorization: `Bearer ${localStorage.getItem('token')}` + } + }) + + if (response.data.success) { + const { redirect_url } = response.data.data + window.location.href = redirect_url + } + } catch (error) { + setError(error.response?.data?.message || '授权失败') + } + setLoading(false) + } + + const handleDeny = () => { + // 拒绝授权,重定向回应用 + const denyUrl = new URL(redirectUri) + denyUrl.searchParams.set('error', 'access_denied') + if (state) { + denyUrl.searchParams.set('state', state) + } + window.location.href = denyUrl.toString() + } + + if (!user) { + return ( + + + + + + ) + } + + return ( + + + + + + + 授权请求 + + + 第三方应用请求访问您的账户 + + + + {error && ( + + {error} + + )} + + {clientInfo && ( + <> + {/* 应用信息 */} + + + + {clientInfo.name} + + + {clientInfo.description} + + + + 请求的权限: + + {clientInfo.scopes.map((scope) => ( + + ))} + + + + + {/* 用户信息 */} + + + + 您的账户信息 + + + + + + + + + + + + + + + + + + + + + + + + + {/* 操作按钮 */} + + + + + + + + + + )} + + + + ) +} + +export default OAuthAuthorize \ No newline at end of file diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx new file mode 100644 index 0000000..4921a99 --- /dev/null +++ b/src/pages/Register.jsx @@ -0,0 +1,210 @@ +import React, { useState } from 'react' +import { Link, Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' +import { + Container, + Paper, + TextField, + Button, + Typography, + Box, + Alert, + InputAdornment, + IconButton +} from '@mui/material' +import { Visibility, VisibilityOff, PersonAdd as RegisterIcon } from '@mui/icons-material' + +const Register = () => { + const { register, user } = useAuth() + const [formData, setFormData] = useState({ + username: '', + email: '', + password: '', + confirmPassword: '' + }) + const [showPassword, setShowPassword] = useState(false) + const [showConfirmPassword, setShowConfirmPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + if (user) { + return + } + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }) + } + + const validateForm = () => { + if (formData.password !== formData.confirmPassword) { + setError('两次输入的密码不一致') + return false + } + if (formData.password.length < 6) { + setError('密码长度至少6位') + return false + } + return true + } + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + if (!validateForm()) { + return + } + + setLoading(true) + + const { confirmPassword, ...userData } = formData + const result = await register(userData) + if (!result.success) { + setError(result.message) + } + setLoading(false) + } + + return ( + + + + + + + 注册 + + + 创建您的账户 + + + + {error && ( + + {error} + + )} + +
+ + + + + + setShowPassword(!showPassword)} + edge="end" + > + {showPassword ? : } + + + ) + }} + /> + + + setShowConfirmPassword(!showConfirmPassword)} + edge="end" + > + {showConfirmPassword ? : } + + + ) + }} + /> + + + + + + 已有账户?{' '} + + + 立即登录 + + + + + +
+
+
+ ) +} + +export default Register \ No newline at end of file diff --git a/test-api.js b/test-api.js new file mode 100644 index 0000000..192fe8f --- /dev/null +++ b/test-api.js @@ -0,0 +1,100 @@ +const axios = require('axios'); + +const BASE_URL = 'http://localhost:3000/api/auth'; + +// 测试数据 +const testUser = { + username: 'testuser', + email: 'test@example.com', + password: 'Password123' +}; + +let authToken = ''; + +// 测试函数 +async function testAPI() { + console.log('🚀 开始测试API...\n'); + + try { + // 1. 测试注册 + console.log('1. 测试用户注册...'); + const registerResponse = await axios.post(`${BASE_URL}/register`, testUser); + console.log('✅ 注册成功:', registerResponse.data.message); + authToken = registerResponse.data.data.token; + console.log('Token:', authToken.substring(0, 20) + '...\n'); + + // 2. 测试重复注册(应该失败) + console.log('2. 测试重复注册...'); + try { + await axios.post(`${BASE_URL}/register`, testUser); + } catch (error) { + console.log('✅ 重复注册被正确拒绝:', error.response.data.message); + } + console.log(''); + + // 3. 测试登录 + console.log('3. 测试用户登录...'); + const loginResponse = await axios.post(`${BASE_URL}/login`, { + username: testUser.username, + password: testUser.password + }); + console.log('✅ 登录成功:', loginResponse.data.message); + console.log(''); + + // 4. 测试邮箱登录 + console.log('4. 测试邮箱登录...'); + const emailLoginResponse = await axios.post(`${BASE_URL}/login`, { + username: testUser.email, + password: testUser.password + }); + console.log('✅ 邮箱登录成功:', emailLoginResponse.data.message); + console.log(''); + + // 5. 测试获取用户信息 + console.log('5. 测试获取用户信息...'); + const profileResponse = await axios.get(`${BASE_URL}/profile`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + console.log('✅ 获取用户信息成功:', profileResponse.data.data.user.username); + console.log(''); + + // 6. 测试认证中间件 + console.log('6. 测试认证中间件...'); + const testResponse = await axios.get(`${BASE_URL}/test`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + console.log('✅ 认证中间件测试成功:', testResponse.data.message); + console.log(''); + + // 7. 测试无效token + console.log('7. 测试无效token...'); + try { + await axios.get(`${BASE_URL}/profile`, { + headers: { + 'Authorization': 'Bearer invalid_token' + } + }); + } catch (error) { + console.log('✅ 无效token被正确拒绝:', error.response.data.message); + } + console.log(''); + + // 8. 测试服务器状态 + console.log('8. 测试服务器状态...'); + const healthResponse = await axios.get('http://localhost:3000/health'); + console.log('✅ 服务器状态正常:', healthResponse.data.message); + console.log(''); + + console.log('🎉 所有测试通过!'); + + } catch (error) { + console.error('❌ 测试失败:', error.response?.data || error.message); + } +} + +// 运行测试 +testAPI(); \ No newline at end of file diff --git a/test-oauth.js b/test-oauth.js new file mode 100644 index 0000000..cdab59b --- /dev/null +++ b/test-oauth.js @@ -0,0 +1,219 @@ +const axios = require('axios'); + +const BASE_URL = 'http://localhost:3000'; +const AUTH_URL = `${BASE_URL}/api/auth`; +const OAUTH_URL = `${BASE_URL}/api/oauth`; + +// 测试数据 +const testUser = { + username: `oauthuser${Date.now()}`, + email: `oauthuser${Date.now()}@example.com`, + password: 'TestPassword123' +}; + +const testOAuthClient = { + name: '测试OAuth客户端', + description: '用于测试的OAuth客户端', + redirect_uris: ['http://localhost:3001/callback'], + scopes: ['read', 'write'] +}; + +let authToken = ''; +let clientId = ''; +let clientSecret = ''; +let authCode = ''; +let accessToken = ''; +let refreshToken = ''; + +async function testOAuthFlow() { + console.log('🚀 开始测试OAuth功能...\n'); + + try { + // 1. 注册测试用户 + console.log('1. 注册测试用户...'); + const registerResponse = await axios.post(`${AUTH_URL}/register`, testUser); + if (registerResponse.data.success) { + console.log('✅ 用户注册成功:', registerResponse.data.message); + } else { + console.log('❌ 用户注册失败:', registerResponse.data.message); + return; + } + + // 2. 登录获取JWT令牌 + console.log('\n2. 登录获取JWT令牌...'); + const loginResponse = await axios.post(`${AUTH_URL}/login`, { + username: testUser.username, + password: testUser.password + }); + + if (loginResponse.data.success) { + authToken = loginResponse.data.data.token; + console.log('✅ 登录成功,获取到JWT令牌'); + } else { + console.log('❌ 登录失败:', loginResponse.data.message); + return; + } + + // 3. 创建OAuth客户端 + console.log('\n3. 创建OAuth客户端...'); + const clientResponse = await axios.post(`${OAUTH_URL}/clients`, testOAuthClient, { + headers: { Authorization: `Bearer ${authToken}` } + }); + + if (clientResponse.data.success) { + const clientData = clientResponse.data.data; + clientId = clientData.client_id; + clientSecret = clientData.client_secret; + console.log('✅ OAuth客户端创建成功:', clientResponse.data.message); + console.log(`Client ID: ${clientId}`); + console.log(`Client Secret: ${clientSecret.substring(0, 16)}...`); + } else { + console.log('❌ OAuth客户端创建失败:', clientResponse.data.message); + return; + } + + // 4. 测试授权端点 + console.log('\n4. 测试授权端点...'); + const authorizeUrl = `${OAUTH_URL}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${encodeURIComponent('http://localhost:3001/callback')}&scope=read write&state=test_state_123`; + + const authorizeResponse = await axios.get(authorizeUrl, { + headers: { Authorization: `Bearer ${authToken}` } + }); + + if (authorizeResponse.data.success) { + authCode = authorizeResponse.data.data.code; + console.log('✅ 授权成功:', authorizeResponse.data.message); + console.log(`授权码: ${authCode.substring(0, 20)}...`); + } else { + console.log('❌ 授权失败:', authorizeResponse.data.message); + return; + } + + // 5. 测试令牌交换 + console.log('\n5. 测试令牌交换...'); + const tokenResponse = await axios.post(`${OAUTH_URL}/token`, { + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code: authCode, + redirect_uri: 'http://localhost:3001/callback' + }); + + if (tokenResponse.data.success) { + const tokenData = tokenResponse.data.data; + accessToken = tokenData.access_token; + refreshToken = tokenData.refresh_token; + console.log('✅ 令牌交换成功'); + console.log(`访问令牌: ${accessToken.substring(0, 20)}...`); + console.log(`刷新令牌: ${refreshToken.substring(0, 20)}...`); + } else { + console.log('❌ 令牌交换失败:', tokenResponse.data.message); + return; + } + + // 6. 测试用户信息端点 + console.log('\n6. 测试用户信息端点...'); + const userInfoResponse = await axios.get(`${OAUTH_URL}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + if (userInfoResponse.data.success) { + console.log('✅ 用户信息获取成功'); + console.log('用户信息:', userInfoResponse.data.data); + } else { + console.log('❌ 用户信息获取失败:', userInfoResponse.data.message); + } + + // 7. 测试令牌信息端点 + console.log('\n7. 测试令牌信息端点...'); + const tokenInfoResponse = await axios.get(`${OAUTH_URL}/tokeninfo`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + + if (tokenInfoResponse.data.success) { + console.log('✅ 令牌信息获取成功'); + console.log('令牌信息:', tokenInfoResponse.data.data); + } else { + console.log('❌ 令牌信息获取失败:', tokenInfoResponse.data.message); + } + + // 8. 测试刷新令牌 + console.log('\n8. 测试刷新令牌...'); + const refreshResponse = await axios.post(`${OAUTH_URL}/token`, { + grant_type: 'refresh_token', + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken + }); + + if (refreshResponse.data.success) { + const newTokenData = refreshResponse.data.data; + accessToken = newTokenData.access_token; + refreshToken = newTokenData.refresh_token; + console.log('✅ 刷新令牌成功'); + console.log(`新访问令牌: ${accessToken.substring(0, 20)}...`); + } else { + console.log('❌ 刷新令牌失败:', refreshResponse.data.message); + } + + // 9. 测试撤销令牌 + console.log('\n9. 测试撤销令牌...'); + const revokeResponse = await axios.post(`${OAUTH_URL}/revoke`, { + token: accessToken, + client_id: clientId, + client_secret: clientSecret + }); + + if (revokeResponse.data.success) { + console.log('✅ 令牌撤销成功'); + } else { + console.log('❌ 令牌撤销失败:', revokeResponse.data.message); + } + + // 10. 测试OAuth客户端管理 + console.log('\n10. 测试OAuth客户端管理...'); + const clientsResponse = await axios.get(`${OAUTH_URL}/clients`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + + if (clientsResponse.data.success) { + console.log('✅ 获取客户端列表成功'); + console.log(`客户端数量: ${clientsResponse.data.data.clients.length}`); + } else { + console.log('❌ 获取客户端列表失败:', clientsResponse.data.message); + } + + // 11. 测试获取客户端密钥 + console.log('\n11. 测试获取客户端密钥...'); + const secretResponse = await axios.get(`${OAUTH_URL}/clients/${clientId}/secret`, { + headers: { Authorization: `Bearer ${authToken}` } + }); + + if (secretResponse.data.success) { + console.log('✅ 获取客户端密钥成功'); + console.log(`密钥: ${secretResponse.data.data.client_secret.substring(0, 16)}...`); + } else { + console.log('❌ 获取客户端密钥失败:', secretResponse.data.message); + } + + // 12. 测试OAuth发现端点 + console.log('\n12. 测试OAuth发现端点...'); + const discoveryResponse = await axios.get(`${BASE_URL}/.well-known/oauth-authorization-server`); + + if (discoveryResponse.status === 200) { + console.log('✅ OAuth发现端点正常'); + console.log('授权端点:', discoveryResponse.data.authorization_endpoint); + console.log('令牌端点:', discoveryResponse.data.token_endpoint); + } else { + console.log('❌ OAuth发现端点异常'); + } + + console.log('\n🎉 OAuth完整流程测试通过!'); + + } catch (error) { + console.error('❌ OAuth测试失败:', error.response?.data || error.message); + } +} + +// 运行测试 +testOAuthFlow(); \ No newline at end of file diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..86dc487 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3001, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true + } + } + } +}) \ No newline at end of file