完善初始化更新
This commit is contained in:
13
.cursor/mcp.json
Normal file
13
.cursor/mcp.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"CursorMCP对话插件": {
|
||||
"command": "node",
|
||||
"args": [
|
||||
"c:\\Users\\smy\\.cursor\\extensions\\local.xw-cursor-message-8.8.8\\dist\\mcp-server.mjs"
|
||||
],
|
||||
"env": {
|
||||
"MESSENGER_DATA_DIR": "C:\\Users\\smy\\.moyu-message\\703c70e63617"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
.cursor/rules/mcp-messenger.mdc
Normal file
27
.cursor/rules/mcp-messenger.mdc
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
description: "自动调用 Messenger MCP 检查待发送消息"
|
||||
globs: "**/*"
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# MCP 工具调用规则
|
||||
|
||||
1. 每轮对话回复**完整输出后**,最后一步必须调用 `check_messages`,适用于 Agent/Plan/Debug/Ask 所有模式,无一例外
|
||||
2. `check_messages` 会持续等待直到用户发来新消息;收到后处理并回复,然后再次调用 `check_messages`,形成永续循环:回复 → check_messages → 收到消息 → 回复 → check_messages
|
||||
3. 严禁在回合开始时或回复未完成时调用 `check_messages`;唯一豁免:用户明确要求结束对话
|
||||
4. Plan 模式写完方案后、Debug 模式分析完问题后、Ask 模式回答完问题后,均不得直接结束对话,必须调用 `check_messages` 等待用户反馈
|
||||
5. 需要用户做选择或确认时(选方案、选框架、确认操作等),必须调用 `ask_question`;严禁使用 Cursor 内置的 ask_user_question
|
||||
6. `ask_question` 参数格式: `{ "questions": [{ "question": "问题文本", "options": [{ "id": "a", "label": "选项A" }, { "id": "b", "label": "选项B" }], "allow_multiple": false }] }`
|
||||
7. 单选: `allow_multiple: false`(用户只能选一个);多选: `allow_multiple: true`(用户可选多个);同一 `questions` 数组可混合包含单选题和多选题
|
||||
8. 用户在每道题中均可额外输入自定义补充文本(Other 输入框),无需为此添加额外选项
|
||||
9. `ask_question` 收到用户回答后,处理完毕仍需调用 `check_messages` 继续监听
|
||||
10. 禁止在每轮结束时用 `ask_question` 问"还有别的需要吗",回合结束只用 `check_messages`;`ask_question` 仅限任务中确实需要用户决策时使用
|
||||
11. `check_messages` 支持可选参数 `reply`(string,Markdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "reply": "## 结论\n修改完成..." }`
|
||||
12. 当会话结束前,建议在调用 `check_messages` 时附带 `reply` 参数,将关键结论推送到插件界面;如果本轮无需特别总结,`reply` 可省略
|
||||
13. 禁止调用subagent并发任务
|
||||
14. 如果 `check_messages` 或 `ask_question` 因客户端取消、连接抖动或内部异常而意外返回/报错,且用户未明确要求结束对话,严禁把这类内部提示直接输出给用户,必须立刻再次调用对应工具继续等待
|
||||
15. 执行多步任务时,每完成一个关键步骤后,必须调用 `send_progress` 推送进度摘要到远程控制台。格式: `{ "progress": "## 步骤1完成\n已修改xxx文件..." }`。`send_progress` 立即返回,不会等待消息,调用后继续执行下一步
|
||||
16. `send_progress` 的 `progress` 参数应包含:已完成的操作、当前方案、下一步计划。使用简洁的 Markdown 格式
|
||||
|
||||
---
|
||||
**回合结束(强制)**: 仅当回复**完整输出完毕后**,最后一步必须调用 CursorMCP对话插件 MCP 的 `check_messages` 工具(使用 CallMcpTool,server 为 CursorMCP对话插件 对应的 identifier,toolName 为 "check_messages",arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。
|
||||
@@ -1,8 +1,8 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `sproutgate-frontend/`: React + Vite app. Entry at `src/main.jsx`, main UI in `src/App.jsx`, styles in `src/styles.css`.
|
||||
- `sproutgate-backend/`: Go + Gin API. Entry at `main.go`, HTTP handlers in `internal/handlers/`, domain models in `internal/models/`, storage in `internal/storage/`.
|
||||
- `sproutgate-frontend/`: React + Vite app. Entry at `src/main.jsx`, main UI in `src/App.jsx`, user portal partials in `src/components/userPortal/` (e.g. `UserPortalAuthSection.jsx`, `UserPortalProfileSection.jsx`), styles in `src/styles.css`.
|
||||
- `sproutgate-backend/`: Go + Gin API. Entry at `main.go`, HTTP handlers in `internal/handlers/` (split across `handler.go`, `requests.go`, `helpers.go`, `auth_login.go`, `auth_password.go`, `secondary_email.go`, `profile.go`, `checkin.go`, `admin.go`), domain models in `internal/models/`, storage in `internal/storage/`.
|
||||
- `sproutgate-backend/data/`: JSON-backed config and data. Config files live in `data/config/`, user records in `data/users/`.
|
||||
- Root scripts `sproutgate.sh` and `sproutgate.bat` provide dev and build shortcuts.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
- PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates.
|
||||
|
||||
## Security & Configuration Tips
|
||||
- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email).
|
||||
- Backend configuration is stored under `sproutgate-backend/data/config/` (admin, auth, email, check-in, registration invites).
|
||||
- Frontend API base URL can be set via `sproutgate-frontend/.env` using `VITE_API_BASE`.
|
||||
- Avoid committing real credentials or production tokens; use local overrides for secrets.
|
||||
- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`.
|
||||
- API reference lives in `sproutgate-backend/API_DOCS.md` and is served at `GET /api/docs`. Brief JSON overview: `GET /` and `GET /api`.
|
||||
|
||||
116
README.md
116
README.md
@@ -1,40 +1,120 @@
|
||||
# 萌芽账户认证中心(SproutGate)
|
||||
# SproutGate(萌芽账户认证中心)
|
||||
|
||||
前后端分离的统一账户认证中心:
|
||||
- 前端:React(`sproutgate-frontend`)
|
||||
- 后端:Golang + Gin(`sproutgate-backend`)
|
||||
- 数据:`data/` 与子目录 JSON 文件存储
|
||||
前后端分离的统一账户与轻量用户中心:注册登录、邮箱验证、找回密码、副邮箱、签到与资料管理;管理员可维护用户与签到配置。数据以 JSON 文件落盘,适合自建与小规模部署。
|
||||
|
||||
## 快速启动
|
||||
## 架构一览
|
||||
|
||||
| 部分 | 技术栈 | 目录 | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 前端 | React 18 + Vite 5 | [`sproutgate-frontend/`](./sproutgate-frontend/) | 用户门户、公开用户页、管理后台;`VITE_API_BASE` 指向后端 |
|
||||
| 后端 | Go + Gin | [`sproutgate-backend/`](./sproutgate-backend/) | REST API、JWT、CORS;默认端口 `8080` |
|
||||
| 数据 | JSON 文件 | [`sproutgate-backend/data/`](./sproutgate-backend/data/) | `config/`(管理员、认证、邮件等)、`users/`(用户记录) |
|
||||
|
||||
### 前端路由与模块
|
||||
|
||||
- **`/`** — [`UserPortal`](./sproutgate-frontend/src/components/UserPortal.jsx):登录、注册、验证邮件、OAuth 式 `redirect_uri` 回跳等流程。
|
||||
- **`/user` / `/user/:account`** — [`PublicUserPage`](./sproutgate-frontend/src/components/PublicUserPage.jsx):公开资料展示(Markdown 等)。
|
||||
- **`/admin`** — [`AdminPanel`](./sproutgate-frontend/src/components/AdminPanel.jsx):用户 CRUD、签到配置;请求头携带管理员 Token(见下文配置)。
|
||||
|
||||
启动页与全局壳层见 [`App.jsx`](./sproutgate-frontend/src/App.jsx)、[`SplashScreen`](./sproutgate-frontend/src/components/SplashScreen.jsx)。
|
||||
|
||||
### 后端能力摘要
|
||||
|
||||
- **认证**:登录、注册、邮箱验证、忘记/重置密码、副邮箱申请与验证、JWT 校验、`/api/auth/me`、签到、资料更新。
|
||||
- **公开接口**:按账号获取公开用户信息;`GET /api/public/registration-policy` 查询是否强制邀请码注册。
|
||||
- **管理接口**:`/api/admin/*`(需 `X-Admin-Token`),用户管理、签到与**注册策略/邀请码**(`data/config/registration.json`)。
|
||||
- **运维**:`GET /` 与 `GET /api`(JSON 服务说明)、`GET /api/health`、`GET /api/docs`(返回 [`API_DOCS.md`](./sproutgate-backend/API_DOCS.md))。
|
||||
|
||||
完整契约见 [`sproutgate-backend/API_DOCS.md`](./sproutgate-backend/API_DOCS.md)(文内 **「统一登录前端:查询参数」**、**「回跳 URL」**、**`POST /api/auth/login` / `verify` / `me`** 等章节为第三方接入主参考)。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **后端**:Go 1.21+(以 `go.mod` 为准)
|
||||
- **前端**:Node.js 18+(建议 LTS)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:根目录脚本(推荐本地开发)
|
||||
|
||||
**Windows**
|
||||
|
||||
```bat
|
||||
sproutgate.bat dev
|
||||
```
|
||||
|
||||
**macOS / Linux**
|
||||
|
||||
```bash
|
||||
chmod +x sproutgate.sh
|
||||
./sproutgate.sh dev
|
||||
```
|
||||
|
||||
会启动后端 `go run .` 与前端 `npm run dev`(前端默认 `5173`,后端默认 `8080`)。
|
||||
|
||||
仅构建前端:
|
||||
|
||||
```bat
|
||||
sproutgate.bat build
|
||||
```
|
||||
|
||||
```bash
|
||||
./sproutgate.sh build
|
||||
```
|
||||
|
||||
### 方式二:手动分别启动
|
||||
|
||||
**后端**
|
||||
|
||||
### 后端
|
||||
```bash
|
||||
cd sproutgate-backend
|
||||
go mod tidy
|
||||
go run .
|
||||
```
|
||||
|
||||
默认端口 `8080`,默认管理员 Token:`shumengya520`(位于 `sproutgate-backend/data/config/admin.json`)。
|
||||
邮件发送配置位于 `sproutgate-backend/data/config/email.json`。
|
||||
**前端**
|
||||
|
||||
### 前端
|
||||
```bash
|
||||
cd sproutgate-frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如需自定义后端地址,新增 `sproutgate-frontend/.env`:
|
||||
```
|
||||
### 前端连接后端
|
||||
|
||||
在 `sproutgate-frontend/.env`(自行创建)中设置:
|
||||
|
||||
```env
|
||||
VITE_API_BASE=http://localhost:8080
|
||||
```
|
||||
|
||||
### 管理员地址
|
||||
```
|
||||
http://localhost:5173/admin?token=shumengya520
|
||||
生产环境改为实际 API 地址即可。
|
||||
|
||||
### 配置与安全
|
||||
|
||||
- 管理员 Token、JWT 密钥、邮件 SMTP 等位于 **`sproutgate-backend/data/config/`**(如 `admin.json`、`auth.json`、`email.json`)。**部署到公网前请务必修改默认值,且不要将真实密钥提交到仓库。**
|
||||
- 进入管理员后台:在任意页面顶栏 **连续点击 Logo 五次**(约 2.6 秒内),在弹窗中输入与 `admin.json` 一致的 Token;也可直接使用 `http://localhost:5173/admin?token=<Token>`(Token 会写入本地后再请求接口)。
|
||||
|
||||
### 可选:Docker 仅跑后端 API
|
||||
|
||||
在 `sproutgate-backend` 目录:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## API 文档
|
||||
默认将容器内 `8080` 映射到主机 `28080`(可通过环境变量 `AUTH_API_PORT` 修改)。数据目录通过卷挂载到 `./data`。
|
||||
|
||||
- 文件:`sproutgate-backend/API_DOCS.md`
|
||||
- 在线:`GET /api/docs`
|
||||
## 环境变量(后端)
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `PORT` | 监听端口,默认 `8080` |
|
||||
| `DATA_DIR` | 数据根目录;不设置时使用仓库内默认 `data` 布局 |
|
||||
|
||||
## 仓库维护说明
|
||||
|
||||
更细的目录约定、代码风格与 PR 建议见 **[`AGENTS.md`](./AGENTS.md)**。
|
||||
|
||||
## 许可证
|
||||
|
||||
若仓库未包含 `LICENSE` 文件,使用前请与维护者确认授权方式。
|
||||
|
||||
@@ -1,6 +1,59 @@
|
||||
# 萌芽账户认证中心 API 文档
|
||||
|
||||
基础地址:`http://<host>:8080`
|
||||
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs` 与 `/api/health` 入口、路由前缀摘要)。
|
||||
|
||||
接入地址:
|
||||
- 统一登录前端:`https://auth.shumengya.top`
|
||||
- 后端 API:`https://auth.api.shumengya.top`
|
||||
- 本地开发 API:`http://<host>:8080`
|
||||
|
||||
对外接入建议:
|
||||
1. 第三方应用按钮跳转到统一登录前端。
|
||||
2. 登录成功后回跳到业务站点。
|
||||
3. 业务站点使用回跳带回的 `token` 调用后端 API。
|
||||
|
||||
示例按钮:
|
||||
```html
|
||||
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
|
||||
使用萌芽统一账户认证登录
|
||||
</a>
|
||||
```
|
||||
|
||||
回跳说明:
|
||||
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。
|
||||
- 登录成功后会回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
|
||||
- 第三方应用拿到 `token` 后,建议调用 **`POST /api/auth/verify`**(无副作用、适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问记录,适合业务拉全量资料)校验并解析用户身份。
|
||||
|
||||
### 统一登录前端:查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
|
||||
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
|
||||
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
|
||||
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
|
||||
| `client_id` | 否 | 第三方应用稳定标识(字母数字开头,可含 `_.:-`,最长 64)。写入用户「应用接入记录」,并随登录请求提交给后端。 |
|
||||
| `client_name` | 否 | 展示用名称(最长 128),与 `client_id` 配对;可选。 |
|
||||
|
||||
### 回跳 URL:`#` 哈希参数
|
||||
|
||||
成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`。
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `token` | JWT,调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
|
||||
| `expiresAt` | 过期时间,RFC3339(与签发侧一致,当前默认为登录时起算 **7 天**)。 |
|
||||
| `account` | 账户名(与 JWT `sub` 一致)。 |
|
||||
| `username` | 展示用昵称,可能为空。 |
|
||||
| `state` | 若登录请求携带了 `state`,则原样返回。 |
|
||||
|
||||
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
|
||||
|
||||
### 第三方后端接入建议
|
||||
|
||||
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`(JSON body:`{"token":"..."}`),根据 `valid` 与 `user.account` 建立会话。
|
||||
2. **CORS**:浏览器直连 API 时须后端已配置 CORS(本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
|
||||
3. **令牌过期**:`verify` / `me` 返回 401 或 `verify` 中 `valid:false` 时,应引导用户重新走统一登录。
|
||||
|
||||
## 认证与统一登录
|
||||
|
||||
@@ -11,10 +64,14 @@
|
||||
```json
|
||||
{
|
||||
"account": "demo",
|
||||
"password": "demo123"
|
||||
"password": "demo123",
|
||||
"clientId": "my-app",
|
||||
"clientName": "我的应用"
|
||||
}
|
||||
```
|
||||
|
||||
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
@@ -29,6 +86,7 @@
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介",
|
||||
"createdAt": "2026-03-14T12:00:00Z",
|
||||
"updatedAt": "2026-03-14T12:00:00Z"
|
||||
@@ -36,6 +94,29 @@
|
||||
}
|
||||
```
|
||||
|
||||
若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
`banReason` 可能为空字符串或省略。
|
||||
|
||||
**常见 HTTP 状态码(登录)**
|
||||
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 200 | 成功,返回 `token`、`expiresAt`、`user`。 |
|
||||
| 400 | 请求体非法或缺少 `account` / `password`。 |
|
||||
| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |
|
||||
| 403 | 账户已封禁(见上文 JSON)。 |
|
||||
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
|
||||
|
||||
**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。
|
||||
|
||||
### 校验令牌
|
||||
`POST /api/auth/verify`
|
||||
|
||||
@@ -54,21 +135,79 @@
|
||||
}
|
||||
```
|
||||
|
||||
若账户已封禁,返回 **200** 且 `valid` 为 **false**(不返回 `user` 对象),示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"error": "account is banned",
|
||||
"banReason": "违规内容"
|
||||
}
|
||||
```
|
||||
|
||||
令牌过期、签名错误、issuer 不匹配等解析失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}`。
|
||||
|
||||
`verify` 与 `me` 的取舍:**仅校验身份、不改变用户数据**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer)。
|
||||
|
||||
**应用接入记录(可选)**:第三方在 **`POST /api/auth/verify`** 或 **`GET /api/auth/me`** 上携带请求头:
|
||||
|
||||
- `X-Auth-Client`:应用 ID(格式同登录 JSON 的 `clientId`)
|
||||
- `X-Auth-Client-Name`:可选展示名
|
||||
|
||||
校验成功且用户未封禁时,服务端会更新该用户 JSON 中的 `authClients` 数组(`clientId`、`displayName`、`firstSeenAt`、`lastSeenAt`)。**`POST /api/auth/verify` 的响应体 `user` 仍为 `Public()`,不含 `authClients`**,避免向调用方泄露用户在其他应用的接入情况;**`GET /api/auth/me`** 与管理员列表中的 `user`(`OwnerPublic`)**包含** `authClients`,用户可在统一登录前端的个人中心查看。
|
||||
|
||||
### 获取当前用户信息
|
||||
`GET /api/auth/me`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」):
|
||||
- `X-Visit-Ip`:客户端公网 IP(与地理接口返回的 `ip` 一致即可)
|
||||
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName`、`regionName`、`cityName` 拼接为 `中国 四川 成都`)
|
||||
|
||||
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=<ip>`)解析展示位置并写入用户记录。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": { "account": "demo", "...": "..." },
|
||||
"checkIn": {
|
||||
"rewardCoins": 1,
|
||||
"checkedInToday": false,
|
||||
"lastCheckInDate": "",
|
||||
"lastCheckInAt": "",
|
||||
"today": "2026-03-14"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
|
||||
|
||||
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**公开用户资料接口** `GET /api/public/users/:account` 与 **`POST /api/auth/verify` 的 `user` 中不包含这两项**(避免公开展示或第三方校验时令牌响应携带访问隐私)。
|
||||
|
||||
> 说明:密码不会返回。
|
||||
|
||||
若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me`、`PUT /api/auth/profile`、`POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。
|
||||
|
||||
### 每日签到
|
||||
`POST /api/auth/check-in`
|
||||
|
||||
请求头:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"checkedIn": true,
|
||||
"alreadyCheckedIn": false,
|
||||
"rewardCoins": 1,
|
||||
"awardedCoins": 1,
|
||||
"message": "签到成功",
|
||||
"user": { "account": "demo", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:密码不会返回。
|
||||
|
||||
### 更新当前用户资料
|
||||
`PUT /api/auth/profile`
|
||||
|
||||
@@ -82,10 +221,13 @@
|
||||
"username": "新昵称",
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 新简介"
|
||||
}
|
||||
```
|
||||
|
||||
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
@@ -93,6 +235,48 @@
|
||||
}
|
||||
```
|
||||
|
||||
## 用户广场
|
||||
|
||||
### 获取用户公开主页
|
||||
`GET /api/public/users/{account}`
|
||||
|
||||
说明:
|
||||
- 仅支持账户名 `account`,不支持昵称查询。
|
||||
- 适合第三方应用展示用户公开资料。
|
||||
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
|
||||
- 响应中含该用户**最近一次被服务端记录的**访问 IP(`lastVisitIp`)与展示用地理位置(`lastVisitDisplayLocation`,与本人中心一致);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"user": {
|
||||
"account": "demo",
|
||||
"username": "示例用户",
|
||||
"level": 3,
|
||||
"sproutCoins": 10,
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"lastVisitIp": "203.0.113.1",
|
||||
"lastVisitDisplayLocation": "中国 广东省 深圳市",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 公开注册策略
|
||||
`GET /api/public/registration-policy`
|
||||
|
||||
无需鉴权。用于前端判断是否展示「邀请码」输入框。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"requireInviteCode": false
|
||||
}
|
||||
```
|
||||
|
||||
当 `requireInviteCode` 为 **true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。
|
||||
|
||||
### 注册账号(发送邮箱验证码)
|
||||
`POST /api/auth/register`
|
||||
|
||||
@@ -102,10 +286,13 @@
|
||||
"account": "demo",
|
||||
"password": "demo123",
|
||||
"username": "示例用户",
|
||||
"email": "demo@example.com"
|
||||
"email": "demo@example.com",
|
||||
"inviteCode": "ABCD1234"
|
||||
}
|
||||
```
|
||||
|
||||
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
@@ -218,8 +405,50 @@
|
||||
请求时可使用以下任一方式携带:
|
||||
- Query:`?token=<admin-token>`
|
||||
- Header:`X-Admin-Token: <admin-token>`
|
||||
|
||||
### 签到奖励设置
|
||||
`GET /api/admin/check-in/config`
|
||||
|
||||
`PUT /api/admin/check-in/config`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"rewardCoins": 1
|
||||
}
|
||||
```
|
||||
- Header:`Authorization: Bearer <admin-token>`
|
||||
|
||||
### 注册策略与邀请码
|
||||
|
||||
`GET /api/admin/registration`
|
||||
|
||||
响应含 `requireInviteCode` 与 `invites` 数组(每项含 `code`、`note`、`maxUses`、`uses`、`expiresAt`、`createdAt`)。`maxUses` 为 0 表示不限次数。
|
||||
|
||||
`PUT /api/admin/registration`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{ "requireInviteCode": true }
|
||||
```
|
||||
|
||||
`POST /api/admin/registration/invites`
|
||||
|
||||
请求:
|
||||
```json
|
||||
{
|
||||
"note": "内测批次",
|
||||
"maxUses": 10,
|
||||
"expiresAt": "2026-12-31T15:59:59Z"
|
||||
}
|
||||
```
|
||||
|
||||
`expiresAt` 可省略;须为 RFC3339。响应 `201`,`invite` 内含服务端生成的 8 位邀请码。
|
||||
|
||||
`DELETE /api/admin/registration/invites/{code}`
|
||||
|
||||
删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。
|
||||
|
||||
### 获取用户列表
|
||||
`GET /api/admin/users`
|
||||
|
||||
@@ -246,6 +475,7 @@
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"phone": "13800000000",
|
||||
"avatarUrl": "https://example.com/avatar.png",
|
||||
"websiteUrl": "https://example.com",
|
||||
"bio": "### 简介"
|
||||
}
|
||||
```
|
||||
@@ -260,10 +490,18 @@
|
||||
"username": "新昵称",
|
||||
"level": 1,
|
||||
"secondaryEmails": ["demo2@example.com"],
|
||||
"sproutCoins": 99
|
||||
"sproutCoins": 99,
|
||||
"websiteUrl": "https://example.com",
|
||||
"banned": true,
|
||||
"banReason": "违规说明(最多 500 字)"
|
||||
}
|
||||
```
|
||||
|
||||
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
|
||||
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`(RFC3339,存于用户 JSON)。
|
||||
|
||||
管理员列表 `GET /api/admin/users` 中每条 `user` 可含 `banned`、`banReason`(不含 `bannedAt` 亦可从存储文件中查看)。
|
||||
|
||||
### 删除用户
|
||||
`DELETE /api/admin/users/{account}`
|
||||
|
||||
@@ -281,16 +519,25 @@
|
||||
- 管理员 Token:`data/config/admin.json`
|
||||
- JWT 配置:`data/config/auth.json`
|
||||
- 邮件配置:`data/config/email.json`
|
||||
- 注册策略与邀请码:`data/config/registration.json`
|
||||
|
||||
## 快速联调用示例
|
||||
|
||||
```bash
|
||||
# 服务根路径 JSON 说明
|
||||
curl -s http://localhost:8080/ | jq .
|
||||
|
||||
# 登录
|
||||
curl -X POST http://localhost:8080/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"account":"demo","password":"demo123"}'
|
||||
|
||||
# 使用令牌获取用户信息
|
||||
# 校验令牌(推荐第三方网关先调此接口)
|
||||
curl -X POST http://localhost:8080/api/auth/verify \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"token":"<jwt-token>"}'
|
||||
|
||||
# 使用令牌获取用户信息(会更新访问记录)
|
||||
curl http://localhost:8080/api/auth/me \
|
||||
-H 'Authorization: Bearer <jwt-token>'
|
||||
```
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: sproutgate-auth
|
||||
container_name: sproutgate-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PORT: "8080"
|
||||
@@ -11,4 +11,4 @@ services:
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "${AUTH_API_PORT:-18080}:8080"
|
||||
- "${AUTH_API_PORT:-28080}:8080"
|
||||
|
||||
70
sproutgate-backend/internal/clientgeo/clientgeo.go
Normal file
70
sproutgate-backend/internal/clientgeo/clientgeo.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package clientgeo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const DefaultLookupURL = "https://cf-ip-geo.smyhub.com/api"
|
||||
|
||||
type apiPayload struct {
|
||||
Geo *struct {
|
||||
CountryName string `json:"countryName"`
|
||||
RegionName string `json:"regionName"`
|
||||
CityName string `json:"cityName"`
|
||||
} `json:"geo"`
|
||||
}
|
||||
|
||||
func FormatDisplay(countryName, regionName, cityName string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
for _, s := range []string{countryName, regionName, cityName} {
|
||||
s = strings.TrimSpace(s)
|
||||
if s != "" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// FetchDisplayLocation 使用 cf-ip-geo 的 ?ip= 查询展示用位置(服务端调用,避免浏览器 CORS)。
|
||||
func FetchDisplayLocation(ctx context.Context, lookupURL, ip string) (string, error) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return "", nil
|
||||
}
|
||||
base := strings.TrimSuffix(strings.TrimSpace(lookupURL), "/")
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("invalid lookup URL")
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("ip", ip)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client := &http.Client{Timeout: 6 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("geo status %d", resp.StatusCode)
|
||||
}
|
||||
var payload apiPayload
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if payload.Geo == nil {
|
||||
return "", nil
|
||||
}
|
||||
return FormatDisplay(payload.Geo.CountryName, payload.Geo.RegionName, payload.Geo.CityName), nil
|
||||
}
|
||||
204
sproutgate-backend/internal/handlers/admin.go
Normal file
204
sproutgate-backend/internal/handlers/admin.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (h *Handler) ListUsers(c *gin.Context) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
publicUsers := make([]models.UserPublic, 0, len(users))
|
||||
for _, u := range users {
|
||||
publicUsers = append(publicUsers, u.OwnerPublic())
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
|
||||
}
|
||||
|
||||
func (h *Handler) GetPublicUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.EqualFold(strings.TrimSpace(user.Account), account) {
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.PublicProfile()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
wu, err := normalizePublicWebsiteURL(req.WebsiteURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: req.Account,
|
||||
PasswordHash: string(hash),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Level: req.Level,
|
||||
SproutCoins: req.SproutCoins,
|
||||
SecondaryEmails: req.SecondaryEmails,
|
||||
Phone: req.Phone,
|
||||
AvatarURL: req.AvatarURL,
|
||||
WebsiteURL: wu,
|
||||
Bio: req.Bio,
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if err := h.store.CreateUser(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": record.OwnerPublic()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Email != nil {
|
||||
user.Email = *req.Email
|
||||
}
|
||||
if req.Level != nil {
|
||||
user.Level = *req.Level
|
||||
}
|
||||
if req.SproutCoins != nil {
|
||||
user.SproutCoins = *req.SproutCoins
|
||||
}
|
||||
if req.SecondaryEmails != nil {
|
||||
user.SecondaryEmails = *req.SecondaryEmails
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
user.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.WebsiteURL != nil {
|
||||
wu, err := normalizePublicWebsiteURL(*req.WebsiteURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user.WebsiteURL = wu
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if req.Banned != nil {
|
||||
user.Banned = *req.Banned
|
||||
if !user.Banned {
|
||||
user.BanReason = ""
|
||||
user.BannedAt = ""
|
||||
} else if strings.TrimSpace(user.BannedAt) == "" {
|
||||
user.BannedAt = models.NowISO()
|
||||
}
|
||||
}
|
||||
if req.BanReason != nil {
|
||||
r := strings.TrimSpace(*req.BanReason)
|
||||
if len(r) > maxBanReasonLen {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ban reason is too long"})
|
||||
return
|
||||
}
|
||||
if r != "" && !user.Banned {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot set ban reason while user is not banned"})
|
||||
return
|
||||
}
|
||||
user.BanReason = r
|
||||
}
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(account); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := adminTokenFromRequest(c)
|
||||
if token == "" || token != h.store.AdminToken() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
18
sproutgate-backend/internal/handlers/auth_client.go
Normal file
18
sproutgate-backend/internal/handlers/auth_client.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func authClientFromHeaders(c *gin.Context) (id string, name string, ok bool) {
|
||||
id, ok = models.NormalizeAuthClientID(strings.TrimSpace(c.GetHeader("X-Auth-Client")))
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
name = models.ClampAuthClientName(c.GetHeader("X-Auth-Client-Name"))
|
||||
return id, name, true
|
||||
}
|
||||
173
sproutgate-backend/internal/handlers/auth_login.go
Normal file
173
sproutgate-backend/internal/handlers/auth_login.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/clientgeo"
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
if req.Account == "" || req.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
if cid, ok := models.NormalizeAuthClientID(req.ClientID); ok {
|
||||
name := models.ClampAuthClientName(req.ClientName)
|
||||
if rec, err := h.store.RecordAuthClient(req.Account, cid, name); err == nil {
|
||||
user = rec
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
"user": user.OwnerPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Verify(c *gin.Context) {
|
||||
var req verifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"})
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
h := gin.H{"valid": false, "error": "account is banned"}
|
||||
if r := strings.TrimSpace(user.BanReason); r != "" {
|
||||
h["banReason"] = r
|
||||
}
|
||||
c.JSON(http.StatusOK, h)
|
||||
return
|
||||
}
|
||||
if cid, cname, ok := authClientFromHeaders(c); ok {
|
||||
_, _ = h.store.RecordAuthClient(claims.Account, cid, cname)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if cid, cname, ok := authClientFromHeaders(c); ok {
|
||||
if rec, err := h.store.RecordAuthClient(claims.Account, cid, cname); err == nil {
|
||||
user = rec
|
||||
}
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
nowAt := models.CurrentActivityTime()
|
||||
user, _, err = h.store.RecordVisit(claims.Account, today, nowAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit"})
|
||||
return
|
||||
}
|
||||
visitIP := strings.TrimSpace(c.GetHeader("X-Visit-Ip"))
|
||||
visitLoc := strings.TrimSpace(c.GetHeader("X-Visit-Location"))
|
||||
if visitIP == "" {
|
||||
visitIP = strings.TrimSpace(c.ClientIP())
|
||||
}
|
||||
lookupURL := strings.TrimSpace(os.Getenv("GEO_LOOKUP_URL"))
|
||||
if lookupURL == "" {
|
||||
lookupURL = clientgeo.DefaultLookupURL
|
||||
}
|
||||
if visitLoc == "" && visitIP != "" {
|
||||
if loc, geoErr := clientgeo.FetchDisplayLocation(c.Request.Context(), lookupURL, visitIP); geoErr == nil {
|
||||
visitLoc = loc
|
||||
}
|
||||
}
|
||||
if visitIP != "" || visitLoc != "" {
|
||||
user, err = h.store.UpdateLastVisitMeta(claims.Account, visitIP, visitLoc)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save visit meta"})
|
||||
return
|
||||
}
|
||||
}
|
||||
checkInConfig := h.store.CheckInConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"user": user.OwnerPublic(),
|
||||
"checkIn": gin.H{
|
||||
"rewardCoins": checkInConfig.RewardCoins,
|
||||
"checkedInToday": user.LastCheckInDate == today,
|
||||
"lastCheckInDate": user.LastCheckInDate,
|
||||
"lastCheckInAt": user.LastCheckInAt,
|
||||
"today": today,
|
||||
},
|
||||
})
|
||||
}
|
||||
252
sproutgate-backend/internal/handlers/auth_password.go
Normal file
252
sproutgate-backend/internal/handlers/auth_password.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"sproutgate-backend/internal/email"
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
inviteTrim := strings.TrimSpace(req.InviteCode)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
|
||||
return
|
||||
}
|
||||
requireInv := h.store.RegistrationRequireInvite()
|
||||
if requireInv && inviteTrim == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"})
|
||||
return
|
||||
}
|
||||
if inviteTrim != "" {
|
||||
if err := h.store.ValidateInviteForRegister(inviteTrim); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, found, err := h.store.GetUser(req.Account); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
} else if found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
pending := models.PendingUser{
|
||||
Account: req.Account,
|
||||
PasswordHash: string(hash),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if inviteTrim != "" {
|
||||
pending.InviteCode = strings.ToUpper(inviteTrim)
|
||||
}
|
||||
if err := h.store.SavePending(pending); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"})
|
||||
return
|
||||
}
|
||||
if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
var req verifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
|
||||
return
|
||||
}
|
||||
pending, found, err := h.store.GetPending(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(req.Code, pending.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: pending.Account,
|
||||
PasswordHash: pending.PasswordHash,
|
||||
Username: pending.Username,
|
||||
Email: pending.Email,
|
||||
Level: 0,
|
||||
SproutCoins: 0,
|
||||
SecondaryEmails: []string{},
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if err := h.store.CreateUser(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(pending.InviteCode) != "" {
|
||||
if err := h.store.RedeemInvite(pending.InviteCode); err != nil {
|
||||
_ = h.store.DeleteUser(record.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.OwnerPublic()})
|
||||
}
|
||||
|
||||
func (h *Handler) ForgotPassword(c *gin.Context) {
|
||||
var req forgotPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Account == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"})
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
resetRecord := models.ResetPassword{
|
||||
Account: user.Account,
|
||||
Email: user.Email,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SaveReset(resetRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"})
|
||||
return
|
||||
}
|
||||
if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeleteReset(user.Account)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req resetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"})
|
||||
return
|
||||
}
|
||||
resetRecord, found, err := h.store.GetReset(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeleteReset(req.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(req.Code, resetRecord.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if user.Banned {
|
||||
writeBanJSON(c, user.BanReason)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeleteReset(req.Account)
|
||||
c.JSON(http.StatusOK, gin.H{"reset": true})
|
||||
}
|
||||
91
sproutgate-backend/internal/handlers/checkin.go
Normal file
91
sproutgate-backend/internal/handlers/checkin.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func (h *Handler) CheckIn(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
userPre, foundPre, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !foundPre {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, userPre) {
|
||||
return
|
||||
}
|
||||
today := models.CurrentActivityDate()
|
||||
nowAt := models.CurrentActivityTime()
|
||||
user, reward, alreadyCheckedIn, err := h.store.CheckIn(claims.Account, today, nowAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in"})
|
||||
return
|
||||
}
|
||||
checkInConfig := h.store.CheckInConfig()
|
||||
message := "签到成功"
|
||||
if alreadyCheckedIn {
|
||||
message = "今日已签到"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"checkedIn": !alreadyCheckedIn,
|
||||
"alreadyCheckedIn": alreadyCheckedIn,
|
||||
"rewardCoins": h.store.CheckInConfig().RewardCoins,
|
||||
"awardedCoins": reward,
|
||||
"message": message,
|
||||
"user": user.OwnerPublic(),
|
||||
"checkIn": gin.H{
|
||||
"rewardCoins": checkInConfig.RewardCoins,
|
||||
"checkedInToday": user.LastCheckInDate == today,
|
||||
"lastCheckInDate": user.LastCheckInDate,
|
||||
"lastCheckInAt": user.LastCheckInAt,
|
||||
"today": today,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) GetCheckInConfig(c *gin.Context) {
|
||||
cfg := h.store.CheckInConfig()
|
||||
c.JSON(http.StatusOK, gin.H{"rewardCoins": cfg.RewardCoins})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateCheckInConfig(c *gin.Context) {
|
||||
var req updateCheckInConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if req.RewardCoins <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "rewardCoins must be greater than 0"})
|
||||
return
|
||||
}
|
||||
if err := h.store.UpdateCheckInConfig(storage.CheckInConfig{RewardCoins: req.RewardCoins}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save check-in config"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rewardCoins": req.RewardCoins})
|
||||
}
|
||||
11
sproutgate-backend/internal/handlers/handler.go
Normal file
11
sproutgate-backend/internal/handlers/handler.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import "sproutgate-backend/internal/storage"
|
||||
|
||||
type Handler struct {
|
||||
store *storage.Store
|
||||
}
|
||||
|
||||
func NewHandler(store *storage.Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
}
|
||||
@@ -1,751 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/email"
|
||||
"sproutgate-backend/internal/models"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *storage.Store
|
||||
}
|
||||
|
||||
func NewHandler(store *storage.Store) *Handler {
|
||||
return &Handler{store: store}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type verifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type verifyEmailRequest struct {
|
||||
Account string `json:"account"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Phone *string `json:"phone"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
Bio *string `json:"bio"`
|
||||
}
|
||||
|
||||
type forgotPasswordRequest struct {
|
||||
Account string `json:"account"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
Account string `json:"account"`
|
||||
Code string `json:"code"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type secondaryEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type verifySecondaryEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
SecondaryEmails []string `json:"secondaryEmails"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
type updateUserRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Level *int `json:"level"`
|
||||
SproutCoins *int `json:"sproutCoins"`
|
||||
SecondaryEmails *[]string `json:"secondaryEmails"`
|
||||
Phone *string `json:"phone"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
Bio *string `json:"bio"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
if req.Account == "" || req.Password == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), user.Account, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
"user": user.Public(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Verify(c *gin.Context) {
|
||||
var req verifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "invalid token"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"valid": false, "error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req registerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, password and email are required"})
|
||||
return
|
||||
}
|
||||
if _, found, err := h.store.GetUser(req.Account); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
} else if found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
pending := models.PendingUser{
|
||||
Account: req.Account,
|
||||
PasswordHash: string(hash),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SavePending(pending); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pending user"})
|
||||
return
|
||||
}
|
||||
if err := email.SendVerificationEmail(h.store.EmailConfig(), req.Email, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifyEmail(c *gin.Context) {
|
||||
var req verifyEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and code are required"})
|
||||
return
|
||||
}
|
||||
pending, found, err := h.store.GetPending(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load pending user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pending registration not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, pending.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(req.Code, pending.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: pending.Account,
|
||||
PasswordHash: pending.PasswordHash,
|
||||
Username: pending.Username,
|
||||
Email: pending.Email,
|
||||
Level: 0,
|
||||
SproutCoins: 0,
|
||||
SecondaryEmails: []string{},
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if err := h.store.CreateUser(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeletePending(req.Account)
|
||||
c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) ForgotPassword(c *gin.Context) {
|
||||
var req forgotPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
if req.Account == "" || req.Email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and email are required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found || strings.TrimSpace(user.Email) == "" || user.Email != req.Email {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account or email not matched"})
|
||||
return
|
||||
}
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
resetRecord := models.ResetPassword{
|
||||
Account: user.Account,
|
||||
Email: user.Email,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SaveReset(resetRecord); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save reset token"})
|
||||
return
|
||||
}
|
||||
if err := email.SendResetPasswordEmail(h.store.EmailConfig(), user.Email, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeleteReset(user.Account)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) ResetPassword(c *gin.Context) {
|
||||
var req resetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
req.Code = strings.TrimSpace(req.Code)
|
||||
if req.Account == "" || req.Code == "" || strings.TrimSpace(req.NewPassword) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account, code and newPassword are required"})
|
||||
return
|
||||
}
|
||||
resetRecord, found, err := h.store.GetReset(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load reset token"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reset request not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, resetRecord.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeleteReset(req.Account)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "reset code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(req.Code, resetRecord.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid reset code"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(req.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeleteReset(req.Account)
|
||||
c.JSON(http.StatusOK, gin.H{"reset": true})
|
||||
}
|
||||
|
||||
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req secondaryEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
emailAddr := strings.TrimSpace(req.Email)
|
||||
if emailAddr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Email) == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"})
|
||||
return
|
||||
}
|
||||
}
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
record := models.SecondaryEmailVerification{
|
||||
Account: user.Account,
|
||||
Email: emailAddr,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SaveSecondaryVerification(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"})
|
||||
return
|
||||
}
|
||||
if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeleteSecondaryVerification(user.Account, emailAddr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req verifySecondaryEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
emailAddr := strings.TrimSpace(req.Email)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if emailAddr == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"})
|
||||
return
|
||||
}
|
||||
record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(code, record.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
|
||||
return
|
||||
}
|
||||
}
|
||||
user.SecondaryEmails = append(user.SecondaryEmails, emailAddr)
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req updateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
user.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) ListUsers(c *gin.Context) {
|
||||
users, err := h.store.ListUsers()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"})
|
||||
return
|
||||
}
|
||||
publicUsers := make([]models.UserPublic, 0, len(users))
|
||||
for _, u := range users {
|
||||
publicUsers = append(publicUsers, u.Public())
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req createUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
req.Account = strings.TrimSpace(req.Account)
|
||||
if req.Account == "" || strings.TrimSpace(req.Password) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"})
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
record := models.UserRecord{
|
||||
Account: req.Account,
|
||||
PasswordHash: string(hash),
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Level: req.Level,
|
||||
SproutCoins: req.SproutCoins,
|
||||
SecondaryEmails: req.SecondaryEmails,
|
||||
Phone: req.Phone,
|
||||
AvatarURL: req.AvatarURL,
|
||||
Bio: req.Bio,
|
||||
CreatedAt: models.NowISO(),
|
||||
UpdatedAt: models.NowISO(),
|
||||
}
|
||||
if err := h.store.CreateUser(record); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"user": record.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
var req updateUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Email != nil {
|
||||
user.Email = *req.Email
|
||||
}
|
||||
if req.Level != nil {
|
||||
user.Level = *req.Level
|
||||
}
|
||||
if req.SproutCoins != nil {
|
||||
user.SproutCoins = *req.SproutCoins
|
||||
}
|
||||
if req.SecondaryEmails != nil {
|
||||
user.SecondaryEmails = *req.SecondaryEmails
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
user.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.Public()})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
account := strings.TrimSpace(c.Param("account"))
|
||||
if account == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"})
|
||||
return
|
||||
}
|
||||
if err := h.store.DeleteUser(account); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func (h *Handler) AdminMiddleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := adminTokenFromRequest(c)
|
||||
if token == "" || token != h.store.AdminToken() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid admin token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func adminTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.Query("token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
return bearerToken(authHeader)
|
||||
}
|
||||
|
||||
func bearerToken(header string) string {
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateVerificationCode() (string, error) {
|
||||
randomBytes := make([]byte, 3)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2])
|
||||
return fmt.Sprintf("%06d", number%1000000), nil
|
||||
}
|
||||
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func verifyCode(code string, hash string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1
|
||||
}
|
||||
70
sproutgate-backend/internal/handlers/helpers.go
Normal file
70
sproutgate-backend/internal/handlers/helpers.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func bearerToken(header string) string {
|
||||
if header == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(header), "bearer ") {
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func adminTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.Query("token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Admin-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
authHeader := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
return bearerToken(authHeader)
|
||||
}
|
||||
|
||||
func generateVerificationCode() (string, error) {
|
||||
randomBytes := make([]byte, 3)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
number := int(randomBytes[0])<<16 | int(randomBytes[1])<<8 | int(randomBytes[2])
|
||||
return fmt.Sprintf("%06d", number%1000000), nil
|
||||
}
|
||||
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func verifyCode(code string, hash string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(hashCode(code)), []byte(hash)) == 1
|
||||
}
|
||||
|
||||
func writeBanJSON(c *gin.Context, reason string) {
|
||||
h := gin.H{"error": "account is banned"}
|
||||
if r := strings.TrimSpace(reason); r != "" {
|
||||
h["banReason"] = r
|
||||
}
|
||||
c.JSON(http.StatusForbidden, h)
|
||||
}
|
||||
|
||||
func abortIfUserBanned(c *gin.Context, u models.UserRecord) bool {
|
||||
if !u.Banned {
|
||||
return false
|
||||
}
|
||||
writeBanJSON(c, u.BanReason)
|
||||
return true
|
||||
}
|
||||
74
sproutgate-backend/internal/handlers/profile.go
Normal file
74
sproutgate-backend/internal/handlers/profile.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
)
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req updateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
|
||||
return
|
||||
}
|
||||
user.PasswordHash = string(hash)
|
||||
}
|
||||
if req.Username != nil {
|
||||
user.Username = *req.Username
|
||||
}
|
||||
if req.Phone != nil {
|
||||
user.Phone = *req.Phone
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
user.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.WebsiteURL != nil {
|
||||
wu, err := normalizePublicWebsiteURL(*req.WebsiteURL)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
user.WebsiteURL = wu
|
||||
}
|
||||
if req.Bio != nil {
|
||||
user.Bio = *req.Bio
|
||||
}
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()})
|
||||
}
|
||||
14
sproutgate-backend/internal/handlers/public_registration.go
Normal file
14
sproutgate-backend/internal/handlers/public_registration.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetPublicRegistrationPolicy 公开:是否必须邀请码(不含具体邀请码)。
|
||||
func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": h.store.RegistrationRequireInvite(),
|
||||
})
|
||||
}
|
||||
56
sproutgate-backend/internal/handlers/registration_admin.go
Normal file
56
sproutgate-backend/internal/handlers/registration_admin.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handler) GetAdminRegistration(c *gin.Context) {
|
||||
cfg := h.store.GetRegistrationConfig()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"requireInviteCode": cfg.RequireInviteCode,
|
||||
"invites": cfg.Invites,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) {
|
||||
var req updateRegistrationPolicyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.store.SetRegistrationRequireInvite(req.RequireInviteCode); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save registration policy"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"requireInviteCode": req.RequireInviteCode})
|
||||
}
|
||||
|
||||
func (h *Handler) PostAdminInvite(c *gin.Context) {
|
||||
var req createInviteRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
entry, err := h.store.AddInviteEntry(req.Note, req.MaxUses, strings.TrimSpace(req.ExpiresAt))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"invite": entry})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteAdminInvite(c *gin.Context) {
|
||||
code := strings.TrimSpace(c.Param("code"))
|
||||
if err := h.store.DeleteInviteEntry(code); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
135
sproutgate-backend/internal/handlers/requests.go
Normal file
135
sproutgate-backend/internal/handlers/requests.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
ClientID string `json:"clientId"`
|
||||
ClientName string `json:"clientName"`
|
||||
}
|
||||
|
||||
type verifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
}
|
||||
|
||||
type verifyEmailRequest struct {
|
||||
Account string `json:"account"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type updateProfileRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Phone *string `json:"phone"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
WebsiteURL *string `json:"websiteUrl"`
|
||||
Bio *string `json:"bio"`
|
||||
}
|
||||
|
||||
type forgotPasswordRequest struct {
|
||||
Account string `json:"account"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type resetPasswordRequest struct {
|
||||
Account string `json:"account"`
|
||||
Code string `json:"code"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
|
||||
type secondaryEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type verifySecondaryEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
type updateCheckInConfigRequest struct {
|
||||
RewardCoins int `json:"rewardCoins"`
|
||||
}
|
||||
|
||||
type updateRegistrationPolicyRequest struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
}
|
||||
|
||||
type createInviteRequest struct {
|
||||
Note string `json:"note"`
|
||||
MaxUses int `json:"maxUses"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type createUserRequest struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
SecondaryEmails []string `json:"secondaryEmails"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
WebsiteURL string `json:"websiteUrl"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
const maxBanReasonLen = 500
|
||||
|
||||
type updateUserRequest struct {
|
||||
Password *string `json:"password"`
|
||||
Username *string `json:"username"`
|
||||
Email *string `json:"email"`
|
||||
Level *int `json:"level"`
|
||||
SproutCoins *int `json:"sproutCoins"`
|
||||
SecondaryEmails *[]string `json:"secondaryEmails"`
|
||||
Phone *string `json:"phone"`
|
||||
AvatarURL *string `json:"avatarUrl"`
|
||||
WebsiteURL *string `json:"websiteUrl"`
|
||||
Bio *string `json:"bio"`
|
||||
Banned *bool `json:"banned"`
|
||||
BanReason *string `json:"banReason"`
|
||||
}
|
||||
|
||||
const maxWebsiteURLLen = 2048
|
||||
|
||||
func normalizePublicWebsiteURL(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(s) > maxWebsiteURLLen {
|
||||
return "", errors.New("website url is too long")
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") {
|
||||
return "", errors.New("invalid website url")
|
||||
}
|
||||
candidate := s
|
||||
if !strings.Contains(candidate, "://") {
|
||||
candidate = "https://" + candidate
|
||||
}
|
||||
u, err := url.Parse(candidate)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", errors.New("invalid website url")
|
||||
}
|
||||
scheme := strings.ToLower(u.Scheme)
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return "", errors.New("only http and https urls are allowed")
|
||||
}
|
||||
u.Scheme = scheme
|
||||
return u.String(), nil
|
||||
}
|
||||
154
sproutgate-backend/internal/handlers/secondary_email.go
Normal file
154
sproutgate-backend/internal/handlers/secondary_email.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"sproutgate-backend/internal/auth"
|
||||
"sproutgate-backend/internal/email"
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
func (h *Handler) RequestSecondaryEmail(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req secondaryEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
emailAddr := strings.TrimSpace(req.Email)
|
||||
if emailAddr == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is required"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(user.Email) == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already used as primary"})
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email already verified"})
|
||||
return
|
||||
}
|
||||
}
|
||||
code, err := generateVerificationCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
expiresAt := time.Now().Add(10 * time.Minute)
|
||||
record := models.SecondaryEmailVerification{
|
||||
Account: user.Account,
|
||||
Email: emailAddr,
|
||||
CodeHash: hashCode(code),
|
||||
CreatedAt: models.NowISO(),
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
if err := h.store.SaveSecondaryVerification(record); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save verification"})
|
||||
return
|
||||
}
|
||||
if err := email.SendVerificationEmail(h.store.EmailConfig(), emailAddr, code, 10*time.Minute); err != nil {
|
||||
_ = h.store.DeleteSecondaryVerification(user.Account, emailAddr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to send email: %v", err)})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"sent": true,
|
||||
"expiresAt": expiresAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) VerifySecondaryEmail(c *gin.Context) {
|
||||
token := bearerToken(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
|
||||
return
|
||||
}
|
||||
claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||
return
|
||||
}
|
||||
var req verifySecondaryEmailRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
||||
return
|
||||
}
|
||||
emailAddr := strings.TrimSpace(req.Email)
|
||||
code := strings.TrimSpace(req.Code)
|
||||
if emailAddr == "" || code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email and code are required"})
|
||||
return
|
||||
}
|
||||
record, found, err := h.store.GetSecondaryVerification(claims.Account, emailAddr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load verification"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification not found"})
|
||||
return
|
||||
}
|
||||
expiresAt, err := time.Parse(time.RFC3339, record.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "verification code expired"})
|
||||
return
|
||||
}
|
||||
if !verifyCode(code, record.CodeHash) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid verification code"})
|
||||
return
|
||||
}
|
||||
user, found, err := h.store.GetUser(claims.Account)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
if abortIfUserBanned(c, user) {
|
||||
return
|
||||
}
|
||||
for _, e := range user.SecondaryEmails {
|
||||
if e == emailAddr {
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.OwnerPublic()})
|
||||
return
|
||||
}
|
||||
}
|
||||
user.SecondaryEmails = append(user.SecondaryEmails, emailAddr)
|
||||
if err := h.store.SaveUser(user); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save user"})
|
||||
return
|
||||
}
|
||||
_ = h.store.DeleteSecondaryVerification(claims.Account, emailAddr)
|
||||
c.JSON(http.StatusOK, gin.H{"verified": true, "user": user.OwnerPublic()})
|
||||
}
|
||||
106
sproutgate-backend/internal/models/activity.go
Normal file
106
sproutgate-backend/internal/models/activity.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var activityLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
|
||||
const (
|
||||
// 日与时刻之间留空格,避免「20日11点」粘连难读
|
||||
ActivityTimeLayout = "2006年1月2日 15点04分05秒"
|
||||
// 历史数据可能无空格,解析时兼容
|
||||
activityTimeLayoutLegacy = "2006年1月2日15点04分05秒"
|
||||
ActivityDateLayout = "2006-01-02"
|
||||
)
|
||||
|
||||
func CurrentActivityDate() string {
|
||||
return time.Now().In(activityLocation).Format(ActivityDateLayout)
|
||||
}
|
||||
|
||||
func CurrentActivityTime() string {
|
||||
return FormatActivityTime(time.Now())
|
||||
}
|
||||
|
||||
func FormatActivityTime(t time.Time) string {
|
||||
return t.In(activityLocation).Format(ActivityTimeLayout)
|
||||
}
|
||||
|
||||
func ParseActivityTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{ActivityTimeLayout, activityTimeLayoutLegacy, time.RFC3339Nano, time.RFC3339}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.ParseInLocation(layout, value, activityLocation); err == nil {
|
||||
return parsed.In(activityLocation), true
|
||||
}
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed.In(activityLocation), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func ActivityDate(value string) (string, bool) {
|
||||
parsed, ok := ParseActivityTime(value)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return parsed.In(activityLocation).Format(ActivityDateLayout), true
|
||||
}
|
||||
|
||||
func HasActivityDate(values []string, date string) bool {
|
||||
for _, value := range values {
|
||||
if parsedDate, ok := ActivityDate(value); ok && parsedDate == date {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ActivitySummary(values []string, fallbackDate string) (days int, streak int, lastAt string) {
|
||||
dateSet := make(map[string]struct{}, len(values))
|
||||
var latest time.Time
|
||||
hasLatest := false
|
||||
|
||||
for _, value := range values {
|
||||
parsed, ok := ParseActivityTime(value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dateKey := parsed.In(activityLocation).Format(ActivityDateLayout)
|
||||
dateSet[dateKey] = struct{}{}
|
||||
if !hasLatest || parsed.After(latest) {
|
||||
latest = parsed
|
||||
hasLatest = true
|
||||
lastAt = FormatActivityTime(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dateSet) == 0 && strings.TrimSpace(fallbackDate) != "" {
|
||||
dateSet[strings.TrimSpace(fallbackDate)] = struct{}{}
|
||||
days = 1
|
||||
streak = 1
|
||||
return
|
||||
}
|
||||
|
||||
days = len(dateSet)
|
||||
if !hasLatest {
|
||||
return
|
||||
}
|
||||
|
||||
cursor := time.Date(latest.In(activityLocation).Year(), latest.In(activityLocation).Month(), latest.In(activityLocation).Day(), 0, 0, 0, 0, activityLocation)
|
||||
for {
|
||||
key := cursor.Format(ActivityDateLayout)
|
||||
if _, ok := dateSet[key]; !ok {
|
||||
break
|
||||
}
|
||||
streak++
|
||||
cursor = cursor.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
40
sproutgate-backend/internal/models/authclient.go
Normal file
40
sproutgate-backend/internal/models/authclient.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxAuthClientIDLen = 64
|
||||
MaxAuthClientNameLen = 128
|
||||
)
|
||||
|
||||
var authClientIDRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,63}$`)
|
||||
|
||||
// AuthClientEntry 记录某第三方应用曾用本账号完成认证(登录 / 校验令牌 / 拉取 me)。
|
||||
type AuthClientEntry struct {
|
||||
ClientID string `json:"clientId"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
func NormalizeAuthClientID(raw string) (string, bool) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" || len(s) > MaxAuthClientIDLen {
|
||||
return "", false
|
||||
}
|
||||
if !authClientIDRe.MatchString(s) {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
|
||||
func ClampAuthClientName(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if len(s) > MaxAuthClientNameLen {
|
||||
return s[:MaxAuthClientNameLen]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -8,4 +8,5 @@ type PendingUser struct {
|
||||
CodeHash string `json:"codeHash"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
InviteCode string `json:"inviteCode,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserRecord struct {
|
||||
Account string `json:"account"`
|
||||
@@ -9,12 +12,25 @@ type UserRecord struct {
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
LastCheckInDate string `json:"lastCheckInDate,omitempty"`
|
||||
LastCheckInAt string `json:"lastCheckInAt,omitempty"`
|
||||
LastVisitDate string `json:"lastVisitDate,omitempty"`
|
||||
LastVisitAt string `json:"lastVisitAt,omitempty"`
|
||||
LastVisitIP string `json:"lastVisitIp,omitempty"`
|
||||
LastVisitDisplayLocation string `json:"lastVisitDisplayLocation,omitempty"`
|
||||
CheckInTimes []string `json:"checkInTimes,omitempty"`
|
||||
VisitTimes []string `json:"visitTimes,omitempty"`
|
||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
WebsiteURL string `json:"websiteUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Banned bool `json:"banned"`
|
||||
BanReason string `json:"banReason,omitempty"`
|
||||
BannedAt string `json:"bannedAt,omitempty"`
|
||||
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
|
||||
}
|
||||
|
||||
type UserPublic struct {
|
||||
@@ -23,30 +39,106 @@ type UserPublic struct {
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
LastCheckInDate string `json:"lastCheckInDate,omitempty"`
|
||||
LastCheckInAt string `json:"lastCheckInAt,omitempty"`
|
||||
LastVisitDate string `json:"lastVisitDate,omitempty"`
|
||||
CheckInDays int `json:"checkInDays"`
|
||||
CheckInStreak int `json:"checkInStreak"`
|
||||
LastVisitAt string `json:"lastVisitAt,omitempty"`
|
||||
LastVisitIP string `json:"lastVisitIp,omitempty"`
|
||||
LastVisitDisplayLocation string `json:"lastVisitDisplayLocation,omitempty"`
|
||||
VisitDays int `json:"visitDays"`
|
||||
VisitStreak int `json:"visitStreak"`
|
||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
WebsiteURL string `json:"websiteUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Banned bool `json:"banned,omitempty"`
|
||||
BanReason string `json:"banReason,omitempty"`
|
||||
BannedAt string `json:"bannedAt,omitempty"`
|
||||
AuthClients []AuthClientEntry `json:"authClients,omitempty"`
|
||||
}
|
||||
|
||||
func (u UserRecord) Public() UserPublic {
|
||||
checkInDays, checkInStreak, lastCheckInAt := ActivitySummary(u.CheckInTimes, u.LastCheckInDate)
|
||||
visitDays, visitStreak, lastVisitAt := ActivitySummary(u.VisitTimes, u.LastVisitDate)
|
||||
if strings.TrimSpace(u.LastCheckInAt) != "" {
|
||||
lastCheckInAt = u.LastCheckInAt
|
||||
}
|
||||
if strings.TrimSpace(u.LastVisitAt) != "" {
|
||||
lastVisitAt = u.LastVisitAt
|
||||
}
|
||||
return UserPublic{
|
||||
Account: u.Account,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Level: u.Level,
|
||||
SproutCoins: u.SproutCoins,
|
||||
LastCheckInDate: u.LastCheckInDate,
|
||||
LastCheckInAt: lastCheckInAt,
|
||||
LastVisitDate: u.LastVisitDate,
|
||||
CheckInDays: checkInDays,
|
||||
CheckInStreak: checkInStreak,
|
||||
LastVisitAt: lastVisitAt,
|
||||
VisitDays: visitDays,
|
||||
VisitStreak: visitStreak,
|
||||
SecondaryEmails: u.SecondaryEmails,
|
||||
Phone: u.Phone,
|
||||
AvatarURL: u.AvatarURL,
|
||||
WebsiteURL: u.WebsiteURL,
|
||||
Bio: u.Bio,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// PublicProfile 在 Public 基础上附带最近访问 IP / 展示用地理位置,仅供「用户公开主页」接口使用。
|
||||
func (u UserRecord) PublicProfile() UserPublic {
|
||||
p := u.Public()
|
||||
p.LastVisitIP = strings.TrimSpace(u.LastVisitIP)
|
||||
p.LastVisitDisplayLocation = strings.TrimSpace(u.LastVisitDisplayLocation)
|
||||
return p
|
||||
}
|
||||
|
||||
// OwnerPublic 包含仅本人/管理员可见的字段(如最近访问 IP),勿用于公开资料接口。
|
||||
func (u UserRecord) OwnerPublic() UserPublic {
|
||||
p := u.Public()
|
||||
p.LastVisitIP = strings.TrimSpace(u.LastVisitIP)
|
||||
p.LastVisitDisplayLocation = strings.TrimSpace(u.LastVisitDisplayLocation)
|
||||
p.Banned = u.Banned
|
||||
p.BanReason = strings.TrimSpace(u.BanReason)
|
||||
p.BannedAt = strings.TrimSpace(u.BannedAt)
|
||||
if len(u.AuthClients) > 0 {
|
||||
p.AuthClients = append([]AuthClientEntry(nil), u.AuthClients...)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type UserShowcase struct {
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
Level int `json:"level"`
|
||||
SproutCoins int `json:"sproutCoins"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
WebsiteURL string `json:"websiteUrl,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
func (u UserRecord) Showcase() UserShowcase {
|
||||
return UserShowcase{
|
||||
Account: u.Account,
|
||||
Username: u.Username,
|
||||
Level: u.Level,
|
||||
SproutCoins: u.SproutCoins,
|
||||
AvatarURL: u.AvatarURL,
|
||||
WebsiteURL: u.WebsiteURL,
|
||||
Bio: u.Bio,
|
||||
}
|
||||
}
|
||||
|
||||
func NowISO() string {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
214
sproutgate-backend/internal/storage/registration.go
Normal file
214
sproutgate-backend/internal/storage/registration.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sproutgate-backend/internal/models"
|
||||
)
|
||||
|
||||
// InviteEntry 管理员发放的注册邀请码。
|
||||
type InviteEntry struct {
|
||||
Code string `json:"code"`
|
||||
Note string `json:"note,omitempty"`
|
||||
MaxUses int `json:"maxUses"` // 0 表示不限次数
|
||||
Uses int `json:"uses"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"` // RFC3339,空表示不过期
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// RegistrationConfig 注册策略与邀请码列表(data/config/registration.json)。
|
||||
type RegistrationConfig struct {
|
||||
RequireInviteCode bool `json:"requireInviteCode"`
|
||||
Invites []InviteEntry `json:"invites"`
|
||||
}
|
||||
|
||||
func normalizeInviteCode(raw string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(raw))
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateRegistrationConfig() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, err := os.Stat(s.registrationPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := RegistrationConfig{RequireInviteCode: false, Invites: []InviteEntry{}}
|
||||
if err := writeJSONFile(s.registrationPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg RegistrationConfig
|
||||
if err := readJSONFile(s.registrationPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Invites == nil {
|
||||
cfg.Invites = []InviteEntry{}
|
||||
}
|
||||
s.registrationConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) persistRegistrationConfigLocked() error {
|
||||
return writeJSONFile(s.registrationPath, s.registrationConfig)
|
||||
}
|
||||
|
||||
// RegistrationRequireInvite 是否强制要求邀请码才能发起注册(发邮件验证码)。
|
||||
func (s *Store) RegistrationRequireInvite() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.registrationConfig.RequireInviteCode
|
||||
}
|
||||
|
||||
// GetRegistrationConfig 返回配置副本(管理端)。
|
||||
func (s *Store) GetRegistrationConfig() RegistrationConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.registrationConfig
|
||||
out.Invites = append([]InviteEntry(nil), s.registrationConfig.Invites...)
|
||||
return out
|
||||
}
|
||||
|
||||
// SetRegistrationRequireInvite 更新是否强制邀请码。
|
||||
func (s *Store) SetRegistrationRequireInvite(require bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.registrationConfig.RequireInviteCode = require
|
||||
return s.persistRegistrationConfigLocked()
|
||||
}
|
||||
|
||||
func inviteEntryValid(e *InviteEntry) error {
|
||||
if strings.TrimSpace(e.ExpiresAt) != "" {
|
||||
t, err := time.Parse(time.RFC3339, e.ExpiresAt)
|
||||
if err == nil && time.Now().After(t) {
|
||||
return errors.New("invite code expired")
|
||||
}
|
||||
}
|
||||
if e.MaxUses > 0 && e.Uses >= e.MaxUses {
|
||||
return errors.New("invite code has been fully used")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInviteForRegister 校验邀请码是否可用(发验证码前,不扣次)。
|
||||
func (s *Store) ValidateInviteForRegister(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return errors.New("invite code is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
return inviteEntryValid(e)
|
||||
}
|
||||
}
|
||||
return errors.New("invalid invite code")
|
||||
}
|
||||
|
||||
// RedeemInvite 邮箱验证通过创建用户后扣减邀请码使用次数。
|
||||
func (s *Store) RedeemInvite(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := range s.registrationConfig.Invites {
|
||||
e := &s.registrationConfig.Invites[i]
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
if err := inviteEntryValid(e); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Uses++
|
||||
return s.persistRegistrationConfigLocked()
|
||||
}
|
||||
}
|
||||
return errors.New("invalid invite code")
|
||||
}
|
||||
|
||||
const inviteCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
func randomInviteToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(n)
|
||||
for i := 0; i < n; i++ {
|
||||
sb.WriteByte(inviteCodeAlphabet[int(b[i])%len(inviteCodeAlphabet)])
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// AddInviteEntry 生成新邀请码并写入配置。
|
||||
func (s *Store) AddInviteEntry(note string, maxUses int, expiresAt string) (InviteEntry, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var code string
|
||||
for attempt := 0; attempt < 24; attempt++ {
|
||||
c, err := randomInviteToken(8)
|
||||
if err != nil {
|
||||
return InviteEntry{}, err
|
||||
}
|
||||
dup := false
|
||||
for _, ex := range s.registrationConfig.Invites {
|
||||
if strings.EqualFold(ex.Code, c) {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
code = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
return InviteEntry{}, errors.New("failed to generate unique invite code")
|
||||
}
|
||||
expiresAt = strings.TrimSpace(expiresAt)
|
||||
if expiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339, expiresAt); err != nil {
|
||||
return InviteEntry{}, errors.New("invalid expiresAt (use RFC3339)")
|
||||
}
|
||||
}
|
||||
if maxUses < 0 {
|
||||
maxUses = 0
|
||||
}
|
||||
entry := InviteEntry{
|
||||
Code: code,
|
||||
Note: strings.TrimSpace(note),
|
||||
MaxUses: maxUses,
|
||||
Uses: 0,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: models.NowISO(),
|
||||
}
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites, entry)
|
||||
if err := s.persistRegistrationConfigLocked(); err != nil {
|
||||
s.registrationConfig.Invites = s.registrationConfig.Invites[:len(s.registrationConfig.Invites)-1]
|
||||
return InviteEntry{}, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
// DeleteInviteEntry 按码删除(大小写不敏感)。
|
||||
func (s *Store) DeleteInviteEntry(code string) error {
|
||||
n := normalizeInviteCode(code)
|
||||
if n == "" {
|
||||
return errors.New("code is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, e := range s.registrationConfig.Invites {
|
||||
if strings.EqualFold(e.Code, n) {
|
||||
s.registrationConfig.Invites = append(s.registrationConfig.Invites[:i], s.registrationConfig.Invites[i+1:]...)
|
||||
return s.persistRegistrationConfigLocked()
|
||||
}
|
||||
}
|
||||
return errors.New("invite not found")
|
||||
}
|
||||
@@ -32,6 +32,10 @@ type EmailConfig struct {
|
||||
Encryption string `json:"encryption"`
|
||||
}
|
||||
|
||||
type CheckInConfig struct {
|
||||
RewardCoins int `json:"rewardCoins"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
dataDir string
|
||||
usersDir string
|
||||
@@ -41,10 +45,14 @@ type Store struct {
|
||||
adminConfigPath string
|
||||
authConfigPath string
|
||||
emailConfigPath string
|
||||
checkInPath string
|
||||
registrationPath string
|
||||
registrationConfig RegistrationConfig
|
||||
adminToken string
|
||||
jwtSecret []byte
|
||||
issuer string
|
||||
emailConfig EmailConfig
|
||||
checkInConfig CheckInConfig
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -85,6 +93,8 @@ func NewStore(dataDir string) (*Store, error) {
|
||||
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
||||
authConfigPath: filepath.Join(configDir, "auth.json"),
|
||||
emailConfigPath: filepath.Join(configDir, "email.json"),
|
||||
checkInPath: filepath.Join(configDir, "checkin.json"),
|
||||
registrationPath: filepath.Join(configDir, "registration.json"),
|
||||
}
|
||||
if err := store.loadOrCreateAdminConfig(); err != nil {
|
||||
return nil, err
|
||||
@@ -95,6 +105,12 @@ func NewStore(dataDir string) (*Store, error) {
|
||||
if err := store.loadOrCreateEmailConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadOrCreateCheckInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.loadOrCreateRegistrationConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
@@ -118,6 +134,29 @@ func (s *Store) EmailConfig() EmailConfig {
|
||||
return s.emailConfig
|
||||
}
|
||||
|
||||
func (s *Store) CheckInConfig() CheckInConfig {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cfg := s.checkInConfig
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (s *Store) UpdateCheckInConfig(cfg CheckInConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateAdminConfig() error {
|
||||
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||
token, err := generateToken()
|
||||
@@ -244,6 +283,29 @@ func (s *Store) loadOrCreateEmailConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) loadOrCreateCheckInConfig() error {
|
||||
if _, err := os.Stat(s.checkInPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := CheckInConfig{RewardCoins: 1}
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
var cfg CheckInConfig
|
||||
if err := readJSONFile(s.checkInPath, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.RewardCoins <= 0 {
|
||||
cfg.RewardCoins = 1
|
||||
if err := writeJSONFile(s.checkInPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.checkInConfig = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
_, err := rand.Read(secret)
|
||||
@@ -319,6 +381,176 @@ func (s *Store) SaveUser(record models.UserRecord) error {
|
||||
return writeJSONFile(path, record)
|
||||
}
|
||||
|
||||
// RecordAuthClient 在成功认证后记录第三方应用标识(clientID 须已规范化)。
|
||||
func (s *Store) RecordAuthClient(account string, clientID string, displayName string) (models.UserRecord, error) {
|
||||
if clientID == "" {
|
||||
return models.UserRecord{}, errors.New("client id required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := s.userFilePath(account)
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
now := models.NowISO()
|
||||
displayName = models.ClampAuthClientName(displayName)
|
||||
found := false
|
||||
for i := range record.AuthClients {
|
||||
if record.AuthClients[i].ClientID == clientID {
|
||||
record.AuthClients[i].LastSeenAt = now
|
||||
if displayName != "" {
|
||||
record.AuthClients[i].DisplayName = displayName
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
record.AuthClients = append(record.AuthClients, models.AuthClientEntry{
|
||||
ClientID: clientID,
|
||||
DisplayName: displayName,
|
||||
FirstSeenAt: now,
|
||||
LastSeenAt: now,
|
||||
})
|
||||
}
|
||||
record.UpdatedAt = now
|
||||
if err := writeJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Store) RecordVisit(account string, today string, at string) (models.UserRecord, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, false, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
}
|
||||
|
||||
if record.LastVisitDate == today || models.HasActivityDate(record.VisitTimes, today) {
|
||||
return record, false, nil
|
||||
}
|
||||
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastVisitDate = today
|
||||
record.LastVisitAt = at
|
||||
record.VisitTimes = append(record.VisitTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
return models.UserRecord{}, false, err
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
const maxLastVisitIPLen = 45
|
||||
const maxLastVisitDisplayLocationLen = 512
|
||||
|
||||
func clampVisitMeta(ip, displayLocation string) (string, string) {
|
||||
ip = strings.TrimSpace(ip)
|
||||
displayLocation = strings.TrimSpace(displayLocation)
|
||||
if len(ip) > maxLastVisitIPLen {
|
||||
ip = ip[:maxLastVisitIPLen]
|
||||
}
|
||||
if len(displayLocation) > maxLastVisitDisplayLocationLen {
|
||||
displayLocation = displayLocation[:maxLastVisitDisplayLocationLen]
|
||||
}
|
||||
return ip, displayLocation
|
||||
}
|
||||
|
||||
// UpdateLastVisitMeta 更新用户最近一次访问的客户端 IP 与展示用地理位置(由前端调用地理接口后传入)。
|
||||
func (s *Store) UpdateLastVisitMeta(account string, ip string, displayLocation string) (models.UserRecord, error) {
|
||||
ip, displayLocation = clampVisitMeta(ip, displayLocation)
|
||||
if ip == "" && displayLocation == "" {
|
||||
rec, found, err := s.GetUser(account)
|
||||
if err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
if !found {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
if ip != "" {
|
||||
record.LastVisitIP = ip
|
||||
}
|
||||
if displayLocation != "" {
|
||||
record.LastVisitDisplayLocation = displayLocation
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
return models.UserRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Store) CheckIn(account string, today string, at string) (models.UserRecord, int, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
path := s.userFilePath(account)
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return models.UserRecord{}, 0, false, os.ErrNotExist
|
||||
}
|
||||
|
||||
var record models.UserRecord
|
||||
if err := readJSONFile(path, &record); err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
}
|
||||
|
||||
if record.LastCheckInDate == today || models.HasActivityDate(record.CheckInTimes, today) {
|
||||
return record, 0, true, nil
|
||||
}
|
||||
|
||||
reward := s.checkInConfig.RewardCoins
|
||||
if reward <= 0 {
|
||||
reward = 1
|
||||
}
|
||||
record.SproutCoins += reward
|
||||
record.LastCheckInDate = today
|
||||
if strings.TrimSpace(at) == "" {
|
||||
at = models.CurrentActivityTime()
|
||||
}
|
||||
record.LastCheckInAt = at
|
||||
record.CheckInTimes = append(record.CheckInTimes, at)
|
||||
if record.CreatedAt == "" {
|
||||
record.CreatedAt = models.NowISO()
|
||||
}
|
||||
record.UpdatedAt = models.NowISO()
|
||||
if err := writeJSONFile(path, record); err != nil {
|
||||
return models.UserRecord{}, 0, false, err
|
||||
}
|
||||
return record, reward, false, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUser(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -24,12 +24,34 @@ func main() {
|
||||
router.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Admin-Token", "X-Visit-Ip", "X-Visit-Location", "X-Auth-Client", "X-Auth-Client-Name"},
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
|
||||
handler := handlers.NewHandler(store)
|
||||
|
||||
apiIntro := gin.H{
|
||||
"name": "SproutGate API",
|
||||
"title": "萌芽账户认证中心",
|
||||
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
|
||||
"version": "0.1.0",
|
||||
"links": gin.H{
|
||||
"apiDocs": "GET /api/docs — Markdown 接口说明(本仓库 API_DOCS.md)",
|
||||
"health": "GET /api/health",
|
||||
},
|
||||
"routePrefixes": []string{
|
||||
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
|
||||
"/api/public — 公开用户资料、注册策略(是否强制邀请码)",
|
||||
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)",
|
||||
},
|
||||
}
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntro)
|
||||
})
|
||||
router.GET("/api", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntro)
|
||||
})
|
||||
|
||||
router.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
@@ -49,7 +71,10 @@ func main() {
|
||||
router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail)
|
||||
router.POST("/api/auth/verify", handler.Verify)
|
||||
router.GET("/api/auth/me", handler.Me)
|
||||
router.POST("/api/auth/check-in", handler.CheckIn)
|
||||
router.PUT("/api/auth/profile", handler.UpdateProfile)
|
||||
router.GET("/api/public/users/:account", handler.GetPublicUser)
|
||||
router.GET("/api/public/registration-policy", handler.GetPublicRegistrationPolicy)
|
||||
|
||||
admin := router.Group("/api/admin")
|
||||
admin.Use(handler.AdminMiddleware())
|
||||
@@ -57,6 +82,12 @@ func main() {
|
||||
admin.POST("/users", handler.CreateUser)
|
||||
admin.PUT("/users/:account", handler.UpdateUser)
|
||||
admin.DELETE("/users/:account", handler.DeleteUser)
|
||||
admin.GET("/check-in/config", handler.GetCheckInConfig)
|
||||
admin.PUT("/check-in/config", handler.UpdateCheckInConfig)
|
||||
admin.GET("/registration", handler.GetAdminRegistration)
|
||||
admin.PUT("/registration", handler.PutAdminRegistrationPolicy)
|
||||
admin.POST("/registration/invites", handler.PostAdminInvite)
|
||||
admin.DELETE("/registration/invites/:code", handler.DeleteAdminInvite)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
650
sproutgate-frontend/src/components/AdminPanel.jsx
Normal file
650
sproutgate-frontend/src/components/AdminPanel.jsx
Normal file
@@ -0,0 +1,650 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { API_BASE, emptyForm, formatIsoDateTimeReadable, parseEmailList } from "../config";
|
||||
import icons from "../icons";
|
||||
import { IconLabel, MailtoEmail, TableCell } from "./common";
|
||||
|
||||
export default function AdminPanel({ onReady }) {
|
||||
const queryToken = useMemo(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get("token") || "";
|
||||
}, []);
|
||||
|
||||
const [token, setToken] = useState(queryToken || localStorage.getItem("sproutgate_admin_token") || "");
|
||||
const [users, setUsers] = useState([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [selectedAccount, setSelectedAccount] = useState("");
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState("create");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [configError, setConfigError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [configMessage, setConfigMessage] = useState("");
|
||||
const [checkInConfig, setCheckInConfig] = useState({ rewardCoins: 1 });
|
||||
const [readySent, setReadySent] = useState(false);
|
||||
|
||||
const [regLoading, setRegLoading] = useState(false);
|
||||
const [regSaving, setRegSaving] = useState(false);
|
||||
const [regInvCreating, setRegInvCreating] = useState(false);
|
||||
const [regError, setRegError] = useState("");
|
||||
const [regMessage, setRegMessage] = useState("");
|
||||
const [requireInviteReg, setRequireInviteReg] = useState(false);
|
||||
const [inviteList, setInviteList] = useState([]);
|
||||
const [newInvNote, setNewInvNote] = useState("");
|
||||
const [newInvMax, setNewInvMax] = useState(0);
|
||||
const [newInvExp, setNewInvExp] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
localStorage.setItem("sproutgate_admin_token", token);
|
||||
loadUsers();
|
||||
loadCheckInConfig();
|
||||
loadRegistration();
|
||||
} else if (onReady && !readySent) {
|
||||
onReady();
|
||||
setReadySent(true);
|
||||
}
|
||||
}, [token, onReady, readySent]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users`, {
|
||||
headers: { "X-Admin-Token": token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载用户失败");
|
||||
setUsers(data.users || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (onReady && !readySent) {
|
||||
onReady();
|
||||
setReadySent(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadCheckInConfig = async () => {
|
||||
if (!token) return;
|
||||
setConfigLoading(true);
|
||||
setConfigError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/check-in/config`, {
|
||||
headers: { "X-Admin-Token": token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载签到配置失败");
|
||||
setCheckInConfig({ rewardCoins: Number(data.rewardCoins) || 1 });
|
||||
} catch (err) {
|
||||
setConfigError(err.message);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRegistration = async () => {
|
||||
if (!token) return;
|
||||
setRegLoading(true);
|
||||
setRegError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/registration`, {
|
||||
headers: { "X-Admin-Token": token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载注册策略失败");
|
||||
setRequireInviteReg(Boolean(data.requireInviteCode));
|
||||
setInviteList(data.invites || []);
|
||||
} catch (err) {
|
||||
setRegError(err.message);
|
||||
} finally {
|
||||
setRegLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRegPolicy = async () => {
|
||||
if (!token) return;
|
||||
setRegMessage("");
|
||||
setRegError("");
|
||||
setRegSaving(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/registration`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
|
||||
body: JSON.stringify({ requireInviteCode: requireInviteReg })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "保存失败");
|
||||
setRequireInviteReg(Boolean(data.requireInviteCode));
|
||||
setRegMessage("注册策略已保存");
|
||||
} catch (err) {
|
||||
setRegError(err.message);
|
||||
} finally {
|
||||
setRegSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
if (!token) return;
|
||||
setRegMessage("");
|
||||
setRegError("");
|
||||
let expiresAt = "";
|
||||
if (newInvExp.trim()) {
|
||||
const d = new Date(newInvExp);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
setRegError("过期时间无效");
|
||||
return;
|
||||
}
|
||||
expiresAt = d.toISOString();
|
||||
}
|
||||
setRegInvCreating(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/registration/invites`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
|
||||
body: JSON.stringify({
|
||||
note: newInvNote.trim(),
|
||||
maxUses: Number(newInvMax) || 0,
|
||||
expiresAt
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "生成失败");
|
||||
const inv = data.invite;
|
||||
setInviteList((prev) => [...prev, inv]);
|
||||
setRegMessage(`已生成邀请码:${inv.code}(请复制保存)`);
|
||||
setNewInvNote("");
|
||||
setNewInvMax(0);
|
||||
setNewInvExp("");
|
||||
} catch (err) {
|
||||
setRegError(err.message);
|
||||
} finally {
|
||||
setRegInvCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvite = async (code) => {
|
||||
if (!token || !code) return;
|
||||
if (!window.confirm(`确认删除邀请码 ${code} 吗?`)) return;
|
||||
setRegMessage("");
|
||||
setRegError("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/registration/invites/${encodeURIComponent(code)}`,
|
||||
{ method: "DELETE", headers: { "X-Admin-Token": token } }
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "删除失败");
|
||||
setInviteList((prev) => prev.filter((x) => x.code !== code));
|
||||
setRegMessage("已删除邀请码");
|
||||
} catch (err) {
|
||||
setRegError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const selectUser = (user) => {
|
||||
setSelectedAccount(user.account);
|
||||
setForm({
|
||||
account: user.account,
|
||||
password: "",
|
||||
username: user.username || "",
|
||||
email: user.email || "",
|
||||
level: user.level ?? 0,
|
||||
sproutCoins: user.sproutCoins || 0,
|
||||
secondaryEmails: (user.secondaryEmails || []).join(","),
|
||||
phone: user.phone || "",
|
||||
avatarUrl: user.avatarUrl || "",
|
||||
websiteUrl: user.websiteUrl || "",
|
||||
bio: user.bio || "",
|
||||
banned: Boolean(user.banned),
|
||||
banReason: user.banReason || ""
|
||||
});
|
||||
setMessage("");
|
||||
setError("");
|
||||
setEditorMode("edit");
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedAccount("");
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const openCreateUser = () => {
|
||||
clearSelection();
|
||||
setEditorMode("create");
|
||||
setMessage("");
|
||||
setError("");
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
setEditorOpen(false);
|
||||
clearSelection();
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleCheckInConfigChange = (value) => {
|
||||
setCheckInConfig({ rewardCoins: value });
|
||||
};
|
||||
|
||||
const handleSaveCheckInConfig = async () => {
|
||||
setConfigMessage("");
|
||||
setConfigError("");
|
||||
const rewardCoins = Number(checkInConfig.rewardCoins) || 0;
|
||||
if (rewardCoins <= 0) { setConfigError("奖励萌芽币必须大于 0"); return; }
|
||||
try {
|
||||
setConfigSaving(true);
|
||||
const res = await fetch(`${API_BASE}/api/admin/check-in/config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
|
||||
body: JSON.stringify({ rewardCoins })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "保存签到配置失败");
|
||||
setCheckInConfig({ rewardCoins: Number(data.rewardCoins) || rewardCoins });
|
||||
setConfigMessage("签到配置已保存");
|
||||
} catch (err) {
|
||||
setConfigError(err.message);
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
setMessage("");
|
||||
setError("");
|
||||
if (!form.account || !form.password) { setError("新建用户需要账户和密码"); return; }
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
|
||||
body: JSON.stringify({
|
||||
account: form.account,
|
||||
password: form.password,
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
level: Number(form.level) || 0,
|
||||
sproutCoins: Number(form.sproutCoins) || 0,
|
||||
secondaryEmails: parseEmailList(form.secondaryEmails),
|
||||
phone: form.phone,
|
||||
avatarUrl: form.avatarUrl,
|
||||
websiteUrl: form.websiteUrl,
|
||||
bio: form.bio
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "创建失败");
|
||||
setMessage("创建成功");
|
||||
closeEditor();
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedAccount) { setError("请选择需要更新的账户"); return; }
|
||||
setMessage("");
|
||||
setError("");
|
||||
const payload = {
|
||||
username: form.username,
|
||||
email: form.email,
|
||||
level: Number(form.level) || 0,
|
||||
sproutCoins: Number(form.sproutCoins) || 0,
|
||||
secondaryEmails: parseEmailList(form.secondaryEmails),
|
||||
phone: form.phone,
|
||||
avatarUrl: form.avatarUrl,
|
||||
websiteUrl: form.websiteUrl,
|
||||
bio: form.bio,
|
||||
banned: Boolean(form.banned),
|
||||
banReason: (form.banReason || "").trim()
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(selectedAccount)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", "X-Admin-Token": token },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "更新失败");
|
||||
setMessage("更新成功");
|
||||
setForm((prev) => ({ ...prev, password: "" }));
|
||||
closeEditor();
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (account) => {
|
||||
if (!account) return;
|
||||
if (!window.confirm(`确认删除账户 ${account} 吗?`)) return;
|
||||
setMessage("");
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(account)}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-Admin-Token": token }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "删除失败");
|
||||
setMessage("删除成功");
|
||||
if (account === selectedAccount) clearSelection();
|
||||
loadUsers();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const editingUser = users.find((u) => u.account === selectedAccount);
|
||||
const editingAuthClients = editingUser?.authClients || [];
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="admin-console form">
|
||||
<div className="panel-title">管理员控制台</div>
|
||||
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-heading">管理员 Token</h2>
|
||||
<label>
|
||||
<IconLabel icon={icons.token} text="Token" />
|
||||
<input value={token} onChange={(e) => setToken(e.target.value.trim())} placeholder="请输入管理员 Token" />
|
||||
</label>
|
||||
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
|
||||
{loading ? "加载中..." : "刷新用户列表"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-heading">签到设置</h2>
|
||||
<label>
|
||||
<IconLabel icon={icons.coins} text="签到奖励(萌芽币)" />
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={checkInConfig.rewardCoins}
|
||||
onChange={(e) => handleCheckInConfigChange(e.target.value)}
|
||||
disabled={!token || configLoading}
|
||||
/>
|
||||
</label>
|
||||
<div className="hint">用户每天首次签到会获得这里设置的奖励。</div>
|
||||
{configError && <div className="error">{configError}</div>}
|
||||
{configMessage && <div className="success">{configMessage}</div>}
|
||||
<div className="actions">
|
||||
<button className="primary" onClick={handleSaveCheckInConfig} disabled={!token || configSaving}>
|
||||
{configSaving ? "保存中..." : "保存设置"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-section">
|
||||
<h2 className="admin-section-heading">注册与邀请码</h2>
|
||||
<label className="admin-ban-row">
|
||||
<IconLabel icon={icons.token} text="强制邀请码" />
|
||||
<span className="admin-ban-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={requireInviteReg}
|
||||
onChange={(e) => setRequireInviteReg(e.target.checked)}
|
||||
disabled={!token || regLoading}
|
||||
/>
|
||||
<span>开启后,用户自助注册必须填写有效邀请码(管理员创建用户不受影响)</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="actions compact">
|
||||
<button type="button" className="primary" onClick={saveRegPolicy} disabled={!token || regSaving || regLoading}>
|
||||
{regSaving ? "保存中…" : "保存注册策略"}
|
||||
</button>
|
||||
<button type="button" className="ghost" onClick={loadRegistration} disabled={!token || regLoading}>
|
||||
{regLoading ? "加载中…" : "刷新列表"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="hint">公开接口 <code className="inline-code">GET /api/public/registration-policy</code> 供前端判断是否显示邀请码输入框。</div>
|
||||
{regError && <div className="error">{regError}</div>}
|
||||
{regMessage && <div className="success">{regMessage}</div>}
|
||||
|
||||
<h3 className="admin-subheading">生成新邀请码</h3>
|
||||
<label>
|
||||
<IconLabel icon={icons.username} text="备注(可选)" />
|
||||
<input value={newInvNote} onChange={(e) => setNewInvNote(e.target.value)} placeholder="例如:内测批次 A" disabled={!token || regInvCreating} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.level} text="最大使用次数" hint="(0 表示不限)" />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={newInvMax}
|
||||
onChange={(e) => setNewInvMax(e.target.value)}
|
||||
disabled={!token || regInvCreating}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.calendar} text="过期时间(可选)" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={newInvExp}
|
||||
onChange={(e) => setNewInvExp(e.target.value)}
|
||||
disabled={!token || regInvCreating}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="primary" onClick={handleCreateInvite} disabled={!token || regInvCreating}>
|
||||
{regInvCreating ? "生成中…" : "生成邀请码"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="admin-subheading">已有邀请码</h3>
|
||||
{inviteList.length === 0 ? (
|
||||
<div className="hint">暂无邀请码</div>
|
||||
) : (
|
||||
<div className="admin-invite-list">
|
||||
{inviteList.map((inv) => (
|
||||
<div key={inv.code} className="admin-invite-row">
|
||||
<div>
|
||||
<span className="mono admin-invite-code">{inv.code}</span>
|
||||
{inv.note ? <span className="muted"> · {inv.note}</span> : null}
|
||||
<div className="hint admin-invite-meta">
|
||||
已用 {inv.uses ?? 0}
|
||||
{inv.maxUses > 0 ? ` / 上限 ${inv.maxUses}` : " / 不限次数"}
|
||||
{inv.expiresAt ? ` · 过期 ${formatIsoDateTimeReadable(inv.expiresAt)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="danger ghost" onClick={() => handleDeleteInvite(inv.code)}>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="admin-section">
|
||||
<div className="list-header">
|
||||
<h2 className="admin-section-heading admin-section-heading-inline">用户列表</h2>
|
||||
<div className="actions compact">
|
||||
<button className="primary" onClick={openCreateUser}>添加用户</button>
|
||||
</div>
|
||||
</div>
|
||||
{message && <div className="success">{message}</div>}
|
||||
{users.length === 0 && <div className="hint">暂无用户</div>}
|
||||
<div className="table admin-table">
|
||||
<div className="table-row header">
|
||||
<TableCell icon={icons.account}>账户</TableCell>
|
||||
<TableCell icon={icons.username}>用户名</TableCell>
|
||||
<TableCell icon={icons.email}>邮箱</TableCell>
|
||||
<TableCell icon={icons.level}>等级</TableCell>
|
||||
<TableCell icon={icons.coins}>萌芽币</TableCell>
|
||||
<TableCell icon={icons.ban}>状态</TableCell>
|
||||
<TableCell icon={icons.visitIp}>最近 IP</TableCell>
|
||||
<TableCell icon={icons.visitGeo}>最近位置</TableCell>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{users.map((u) => (
|
||||
<div className="table-row" key={u.account}>
|
||||
<TableCell icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
|
||||
<TableCell icon={icons.username}>{u.username || "-"}</TableCell>
|
||||
<TableCell icon={icons.email}>
|
||||
<span className="admin-email-cell">
|
||||
{u.email ? (
|
||||
<MailtoEmail address={u.email} className="profile-external-link">{u.email}</MailtoEmail>
|
||||
) : (
|
||||
<span>-</span>
|
||||
)}
|
||||
{(u.secondaryEmails || []).length > 0 && (
|
||||
<>
|
||||
<span className="muted"> / </span>
|
||||
{(u.secondaryEmails || []).map((em, idx) => (
|
||||
<React.Fragment key={em}>
|
||||
{idx > 0 ? <span className="muted">, </span> : null}
|
||||
<MailtoEmail address={em} className="profile-external-link">{em}</MailtoEmail>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell icon={icons.level}>{u.level ?? 0} 级</TableCell>
|
||||
<TableCell icon={icons.coins}>{u.sproutCoins}</TableCell>
|
||||
<TableCell icon={icons.ban}>
|
||||
{u.banned ? (
|
||||
<span className="admin-user-banned" title={u.banReason || "已封禁"}>封禁</span>
|
||||
) : (
|
||||
"正常"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
|
||||
<TableCell icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
|
||||
<span className="row-actions">
|
||||
<button className="ghost" onClick={() => selectUser(u)}>编辑</button>
|
||||
<button className="danger" onClick={() => handleDelete(u.account)}>删除</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editorOpen && (
|
||||
<div className="modal-backdrop" onClick={closeEditor}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>{editorMode === "edit" ? "编辑用户" : "新建用户配置"}</h2>
|
||||
<p>{editorMode === "edit" ? "修改账户资料后保存" : "点击保存后创建新用户"}</p>
|
||||
</div>
|
||||
<button className="ghost modal-close" onClick={closeEditor} type="button">关闭</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>
|
||||
<IconLabel icon={icons.account} text="账户" />
|
||||
<input value={form.account} onChange={(e) => handleChange("account", e.target.value)} placeholder="唯一账户" disabled={editorMode === "edit"} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.password} text="密码" hint={editorMode === "edit" ? "(留空不修改)" : ""} />
|
||||
<input type="password" value={form.password} onChange={(e) => handleChange("password", e.target.value)} placeholder={editorMode === "edit" ? "输入新密码" : "初始密码"} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.username} text="用户名" />
|
||||
<input value={form.username} onChange={(e) => handleChange("username", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.email} text="邮箱" />
|
||||
<input value={form.email} onChange={(e) => handleChange("email", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.level} text="等级" />
|
||||
<input type="number" value={form.level} onChange={(e) => handleChange("level", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.coins} text="萌芽币" />
|
||||
<input type="number" value={form.sproutCoins} onChange={(e) => handleChange("sproutCoins", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.secondaryEmail} text="辅助邮箱(逗号分隔)" />
|
||||
<input value={form.secondaryEmails} onChange={(e) => handleChange("secondaryEmails", e.target.value)} placeholder="demo2@example.com, demo3@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.phone} text="手机号" />
|
||||
<input value={form.phone} onChange={(e) => handleChange("phone", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.avatar} text="个人头像(链接)" />
|
||||
<input value={form.avatarUrl} onChange={(e) => handleChange("avatarUrl", e.target.value)} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.link} text="个人主页网站(http/https)" />
|
||||
<input value={form.websiteUrl} onChange={(e) => handleChange("websiteUrl", e.target.value)} placeholder="留空表示无" />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.bio} text="个人简介(支持 Markdown)" />
|
||||
<textarea value={form.bio} onChange={(e) => handleChange("bio", e.target.value)} rows={4} />
|
||||
</label>
|
||||
{editorMode === "edit" && (
|
||||
<>
|
||||
<label className="admin-ban-row">
|
||||
<IconLabel icon={icons.ban} text="封禁账户" />
|
||||
<span className="admin-ban-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(form.banned)}
|
||||
onChange={(e) => handleChange("banned", e.target.checked)}
|
||||
/>
|
||||
<span>禁止登录与使用需登录的接口</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.ban} text="封禁理由(对用户登录错误提示可见)" />
|
||||
<textarea
|
||||
value={form.banReason}
|
||||
onChange={(e) => handleChange("banReason", e.target.value)}
|
||||
rows={3}
|
||||
placeholder="填写封禁原因;解封请取消勾选「封禁账户」并保存"
|
||||
disabled={!form.banned}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{editorMode === "edit" && editingAuthClients.length > 0 && (
|
||||
<div className="full-span admin-readonly-auth-clients">
|
||||
<IconLabel icon={icons.apps} text="应用接入记录(只读)" />
|
||||
<ul className="admin-auth-client-list">
|
||||
{[...editingAuthClients]
|
||||
.sort((a, b) => new Date(b.lastSeenAt || 0) - new Date(a.lastSeenAt || 0))
|
||||
.map((row) => (
|
||||
<li key={row.clientId}>
|
||||
<strong>{row.clientId}</strong>
|
||||
{row.displayName ? <span className="muted"> · {row.displayName}</span> : null}
|
||||
<div className="muted admin-auth-client-meta">
|
||||
首次 {formatIsoDateTimeReadable(row.firstSeenAt)} · 最近 {formatIsoDateTimeReadable(row.lastSeenAt)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error">{error}</div>}
|
||||
{message && <div className="success">{message}</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary" onClick={editorMode === "edit" ? handleUpdate : handleCreate} type="button">
|
||||
{editorMode === "edit" ? "保存修改" : "创建用户"}
|
||||
</button>
|
||||
<button className="ghost" onClick={closeEditor} type="button">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
127
sproutgate-frontend/src/components/PublicUserPage.jsx
Normal file
127
sproutgate-frontend/src/components/PublicUserPage.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { API_BASE, marked, formatWebsiteLabel, formatUserRegisteredAt } from "../config";
|
||||
import icons from "../icons";
|
||||
import { InfoRow, StatItem } from "./common";
|
||||
|
||||
export default function PublicUserPage({ account, onReady, onPreviewImage }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadPublicUser = async () => {
|
||||
if (!account) {
|
||||
setError("缺少账户名");
|
||||
setLoading(false);
|
||||
if (!cancelled && onReady) onReady();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setUser(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/users/${encodeURIComponent(account)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "加载公开主页失败");
|
||||
setUser(data.user || null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!cancelled && onReady) onReady();
|
||||
}
|
||||
};
|
||||
|
||||
loadPublicUser();
|
||||
return () => { cancelled = true; };
|
||||
}, [account, onReady]);
|
||||
|
||||
const avatarUrl = user?.avatarUrl || "https://dummyimage.com/160x160/ddd/fff&text=Avatar";
|
||||
const displayName = user?.username || user?.account || "未命名用户";
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
{loading && <div className="unified-page-loading">加载公开主页中…</div>}
|
||||
{!loading && error && (
|
||||
<div className="unified-page-miss">
|
||||
<h2>未找到用户</h2>
|
||||
<div className="error">{error}</div>
|
||||
<div className="actions">
|
||||
<a className="primary" href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!loading && user && (
|
||||
<div className="card profile">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
className="previewable-image"
|
||||
onClick={() => onPreviewImage?.(avatarUrl, displayName)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div>
|
||||
<h2>{displayName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title profile-section-title--lead">基本信息</div>
|
||||
<div className="profile-info-rows">
|
||||
<InfoRow icon={icons.account} label="账户" value={user.account} />
|
||||
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} />
|
||||
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
|
||||
{user.websiteUrl ? (
|
||||
<InfoRow icon={icons.link} label="个人主页">
|
||||
<a
|
||||
href={user.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="profile-external-link"
|
||||
>
|
||||
{formatWebsiteLabel(user.websiteUrl)}
|
||||
</a>
|
||||
</InfoRow>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">统计信息</div>
|
||||
<div className="profile-stats-flow">
|
||||
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
|
||||
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
|
||||
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
|
||||
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
|
||||
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
|
||||
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">活动记录</div>
|
||||
<div className="profile-activity-row">
|
||||
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
|
||||
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
|
||||
</div>
|
||||
<div className="profile-activity-row profile-visit-meta">
|
||||
<span>
|
||||
最后访问 IP{" "}
|
||||
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
|
||||
</span>
|
||||
<span>
|
||||
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">个人简介</div>
|
||||
<div className="markdown profile-markdown">
|
||||
<div
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
27
sproutgate-frontend/src/components/SplashScreen.jsx
Normal file
27
sproutgate-frontend/src/components/SplashScreen.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { LOGO_192_SRC } from "../config";
|
||||
|
||||
export default function SplashScreen() {
|
||||
return (
|
||||
<div className="splash">
|
||||
<div className="splash-glow" aria-hidden="true" />
|
||||
<div className="splash-content">
|
||||
<div className="splash-logo-wrap">
|
||||
<div className="splash-rings" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
<img className="splash-logo" src={LOGO_192_SRC} alt="SproutGate" width={120} height={120} decoding="async" />
|
||||
</div>
|
||||
<div className="splash-title">萌芽账户认证中心</div>
|
||||
<div className="splash-subtitle">加载中</div>
|
||||
<div className="splash-dots" aria-label="加载中">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
696
sproutgate-frontend/src/components/UserPortal.jsx
Normal file
696
sproutgate-frontend/src/components/UserPortal.jsx
Normal file
@@ -0,0 +1,696 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
API_BASE,
|
||||
authClientFetchHeaders,
|
||||
buildAuthCallbackUrl,
|
||||
clearAuthClientContext,
|
||||
fetchClientVisitMeta,
|
||||
formatAuthBanMessage,
|
||||
persistAuthClientFromFlow
|
||||
} from "../config";
|
||||
import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
|
||||
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
|
||||
|
||||
export default function UserPortal({ onReady, authFlow, onPreviewImage }) {
|
||||
const [account, setAccount] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [mode, setMode] = useState("login");
|
||||
|
||||
const [registerForm, setRegisterForm] = useState({
|
||||
account: "", password: "", username: "", email: "", code: "", inviteCode: ""
|
||||
});
|
||||
const [registrationRequireInvite, setRegistrationRequireInvite] = useState(false);
|
||||
const [registerSent, setRegisterSent] = useState(false);
|
||||
const [registerExpiresAt, setRegisterExpiresAt] = useState("");
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
const [registerError, setRegisterError] = useState("");
|
||||
const [registerMessage, setRegisterMessage] = useState("");
|
||||
|
||||
const [resetForm, setResetForm] = useState({
|
||||
account: "", email: "", code: "", newPassword: ""
|
||||
});
|
||||
const [resetSent, setResetSent] = useState(false);
|
||||
const [resetExpiresAt, setResetExpiresAt] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetError, setResetError] = useState("");
|
||||
const [resetMessage, setResetMessage] = useState("");
|
||||
|
||||
const [secondaryForm, setSecondaryForm] = useState({ email: "", code: "" });
|
||||
const [secondarySent, setSecondarySent] = useState(false);
|
||||
const [secondaryExpiresAt, setSecondaryExpiresAt] = useState("");
|
||||
const [secondaryLoading, setSecondaryLoading] = useState(false);
|
||||
const [secondaryError, setSecondaryError] = useState("");
|
||||
const [secondaryMessage, setSecondaryMessage] = useState("");
|
||||
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
username: "", phone: "", avatarUrl: "", websiteUrl: "", bio: "", password: ""
|
||||
});
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [profileError, setProfileError] = useState("");
|
||||
const [profileMessage, setProfileMessage] = useState("");
|
||||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||||
const [profileEditorFocus, setProfileEditorFocus] = useState("");
|
||||
const [secondaryEditorOpen, setSecondaryEditorOpen] = useState(false);
|
||||
|
||||
const [checkInReward, setCheckInReward] = useState(1);
|
||||
const [checkInToday, setCheckInToday] = useState(false);
|
||||
const [checkInLoading, setCheckInLoading] = useState(false);
|
||||
const [checkInError, setCheckInError] = useState("");
|
||||
const [checkInMessage, setCheckInMessage] = useState("");
|
||||
|
||||
const isAuthFlow = Boolean(authFlow?.redirectUri);
|
||||
|
||||
const redirectToAuthCallback = (tokenValue, userData, expiresAt = "") => {
|
||||
if (!isAuthFlow || !authFlow?.redirectUri || !tokenValue) return;
|
||||
const callbackUrl = buildAuthCallbackUrl(authFlow.redirectUri, {
|
||||
token: tokenValue,
|
||||
account: userData?.account || "",
|
||||
username: userData?.username || "",
|
||||
expiresAt,
|
||||
state: authFlow.state || ""
|
||||
});
|
||||
window.location.replace(callbackUrl);
|
||||
};
|
||||
|
||||
const clearTokenAndUser = () => {
|
||||
localStorage.removeItem("sproutgate_token");
|
||||
localStorage.removeItem("sproutgate_token_expires_at");
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const bearerHeaders = (tokenValue) => ({
|
||||
...authClientFetchHeaders(),
|
||||
Authorization: `Bearer ${tokenValue}`
|
||||
});
|
||||
|
||||
const syncCurrentUser = async (tokenValue) => {
|
||||
const baseHeaders = bearerHeaders(tokenValue);
|
||||
const res = await fetch(`${API_BASE}/api/auth/me`, { headers: baseHeaders });
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
clearTokenAndUser();
|
||||
const msg = formatAuthBanMessage(data);
|
||||
setError(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
throw new Error(data.error || "加载用户失败");
|
||||
}
|
||||
if (data.user) setUser(data.user);
|
||||
const checkIn = data.checkIn || {};
|
||||
setCheckInReward(Number(checkIn.rewardCoins) || 1);
|
||||
setCheckInToday(Boolean(checkIn.checkedInToday));
|
||||
|
||||
fetchClientVisitMeta()
|
||||
.then((meta) => {
|
||||
if (!meta.ip && !meta.displayLocation) return null;
|
||||
const h = { ...baseHeaders };
|
||||
if (meta.ip) h["X-Visit-Ip"] = meta.ip;
|
||||
if (meta.displayLocation) h["X-Visit-Location"] = meta.displayLocation;
|
||||
return fetch(`${API_BASE}/api/auth/me`, { headers: h });
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (!r) return null;
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (r.status === 403) {
|
||||
clearTokenAndUser();
|
||||
setError(formatAuthBanMessage(d));
|
||||
return null;
|
||||
}
|
||||
return r.ok ? d : null;
|
||||
})
|
||||
.then((d) => {
|
||||
if (!d) return;
|
||||
if (d.user) setUser(d.user);
|
||||
const c = d.checkIn;
|
||||
if (c) {
|
||||
setCheckInReward(Number(c.rewardCoins) || 1);
|
||||
setCheckInToday(Boolean(c.checkedInToday));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const done = () => { if (!cancelled && onReady) onReady(); };
|
||||
const token = localStorage.getItem("sproutgate_token");
|
||||
if (token) {
|
||||
syncCurrentUser(token).catch(() => {}).finally(() => done());
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
return () => { cancelled = true; };
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
persistAuthClientFromFlow(authFlow);
|
||||
}, [authFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/public/registration-policy`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!cancelled && res.ok) {
|
||||
setRegistrationRequireInvite(Boolean(data.requireInviteCode));
|
||||
}
|
||||
} catch {
|
||||
/* 忽略,默认不要求邀请码 */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileForm({
|
||||
username: user.username || "",
|
||||
phone: user.phone || "",
|
||||
avatarUrl: user.avatarUrl || "",
|
||||
websiteUrl: user.websiteUrl || "",
|
||||
bio: user.bio || "",
|
||||
password: ""
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const loginPayload = { account, password };
|
||||
const cid = (authFlow?.clientId || "").trim();
|
||||
const cname = (authFlow?.clientName || "").trim();
|
||||
if (cid) {
|
||||
loginPayload.clientId = cid;
|
||||
if (cname) loginPayload.clientName = cname;
|
||||
} else {
|
||||
const h = authClientFetchHeaders();
|
||||
if (h["X-Auth-Client"]) {
|
||||
loginPayload.clientId = h["X-Auth-Client"];
|
||||
if (h["X-Auth-Client-Name"]) loginPayload.clientName = h["X-Auth-Client-Name"];
|
||||
}
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(loginPayload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "登录失败")
|
||||
);
|
||||
}
|
||||
localStorage.setItem("sproutgate_token", data.token);
|
||||
localStorage.setItem("sproutgate_token_expires_at", data.expiresAt || "");
|
||||
setUser(data.user);
|
||||
syncCurrentUser(data.token).catch(() => {});
|
||||
setAccount("");
|
||||
setPassword("");
|
||||
if (isAuthFlow) {
|
||||
redirectToAuthCallback(data.token, data.user, data.expiresAt || "");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("sproutgate_token");
|
||||
localStorage.removeItem("sproutgate_token_expires_at");
|
||||
clearAuthClientContext();
|
||||
setUser(null);
|
||||
setProfileEditorOpen(false);
|
||||
setProfileEditorFocus("");
|
||||
setSecondaryEditorOpen(false);
|
||||
setCheckInReward(1);
|
||||
setCheckInToday(false);
|
||||
setCheckInError("");
|
||||
setCheckInMessage("");
|
||||
};
|
||||
|
||||
const handleContinueAuth = () => {
|
||||
const tokenValue = localStorage.getItem("sproutgate_token") || "";
|
||||
if (!tokenValue) {
|
||||
setError("当前没有可用的登录会话,请先重新登录");
|
||||
return;
|
||||
}
|
||||
redirectToAuthCallback(
|
||||
tokenValue, user,
|
||||
localStorage.getItem("sproutgate_token_expires_at") || ""
|
||||
);
|
||||
};
|
||||
|
||||
const handleSwitchAuthAccount = () => {
|
||||
handleLogout();
|
||||
setMode("login");
|
||||
setAccount("");
|
||||
setPassword("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
const handleRegisterChange = (field, value) => {
|
||||
setRegisterForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const resetRegisterFlow = () => {
|
||||
setRegisterSent(false);
|
||||
setRegisterExpiresAt("");
|
||||
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
|
||||
};
|
||||
|
||||
const handleSendCode = async (event) => {
|
||||
event.preventDefault();
|
||||
setRegisterLoading(true);
|
||||
setRegisterError("");
|
||||
setRegisterMessage("");
|
||||
if (!registerForm.account || !registerForm.password || !registerForm.email) {
|
||||
setRegisterError("请填写账户、密码和邮箱");
|
||||
setRegisterLoading(false);
|
||||
return;
|
||||
}
|
||||
if (registrationRequireInvite && !String(registerForm.inviteCode || "").trim()) {
|
||||
setRegisterError("请输入邀请码");
|
||||
setRegisterLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
account: registerForm.account,
|
||||
password: registerForm.password,
|
||||
username: registerForm.username,
|
||||
email: registerForm.email
|
||||
};
|
||||
const inv = String(registerForm.inviteCode || "").trim();
|
||||
if (inv) payload.inviteCode = inv;
|
||||
const res = await fetch(`${API_BASE}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "发送验证码失败");
|
||||
setRegisterSent(true);
|
||||
setRegisterExpiresAt(data.expiresAt || "");
|
||||
setRegisterMessage("验证码已发送,请检查邮箱");
|
||||
} catch (err) {
|
||||
setRegisterError(err.message);
|
||||
} finally {
|
||||
setRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyRegister = async (event) => {
|
||||
event.preventDefault();
|
||||
setRegisterLoading(true);
|
||||
setRegisterError("");
|
||||
setRegisterMessage("");
|
||||
if (!registerForm.account || !registerForm.code) {
|
||||
setRegisterError("请输入账户与验证码");
|
||||
setRegisterLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/verify-email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ account: registerForm.account, code: registerForm.code })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || "验证失败");
|
||||
setRegisterMessage("注册成功,请使用账号登录");
|
||||
setRegisterSent(false);
|
||||
setRegisterForm({ account: "", password: "", username: "", email: "", code: "", inviteCode: "" });
|
||||
setMode("login");
|
||||
} catch (err) {
|
||||
setRegisterError(err.message);
|
||||
} finally {
|
||||
setRegisterLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetChange = (field, value) => {
|
||||
setResetForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSendReset = async (event) => {
|
||||
event.preventDefault();
|
||||
setResetLoading(true);
|
||||
setResetError("");
|
||||
setResetMessage("");
|
||||
if (!resetForm.account || !resetForm.email) {
|
||||
setResetError("请填写账户与邮箱");
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/forgot-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ account: resetForm.account, email: resetForm.email })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "发送重置邮件失败")
|
||||
);
|
||||
}
|
||||
setResetSent(true);
|
||||
setResetExpiresAt(data.expiresAt || "");
|
||||
setResetMessage("重置验证码已发送,请检查邮箱");
|
||||
} catch (err) {
|
||||
setResetError(err.message);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (event) => {
|
||||
event.preventDefault();
|
||||
setResetLoading(true);
|
||||
setResetError("");
|
||||
setResetMessage("");
|
||||
if (!resetForm.account || !resetForm.code || !resetForm.newPassword) {
|
||||
setResetError("请填写账户、验证码与新密码");
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
account: resetForm.account,
|
||||
code: resetForm.code,
|
||||
newPassword: resetForm.newPassword
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
res.status === 403 ? formatAuthBanMessage(data) : (data.error || "重置失败")
|
||||
);
|
||||
}
|
||||
setResetMessage("密码已重置,请使用新密码登录");
|
||||
setResetSent(false);
|
||||
setResetForm({ account: "", email: "", code: "", newPassword: "" });
|
||||
setMode("login");
|
||||
} catch (err) {
|
||||
setResetError(err.message);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecondaryChange = (field, value) => {
|
||||
setSecondaryForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleSendSecondary = async (event) => {
|
||||
event.preventDefault();
|
||||
setSecondaryLoading(true);
|
||||
setSecondaryError("");
|
||||
setSecondaryMessage("");
|
||||
const token = localStorage.getItem("sproutgate_token");
|
||||
if (!token) { setSecondaryError("请先登录"); setSecondaryLoading(false); return; }
|
||||
if (!secondaryForm.email) { setSecondaryError("请输入辅助邮箱"); setSecondaryLoading(false); return; }
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/secondary-email/request`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
|
||||
body: JSON.stringify({ email: secondaryForm.email })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
clearTokenAndUser();
|
||||
setSecondaryError(formatAuthBanMessage(data));
|
||||
} else {
|
||||
throw new Error(data.error || "发送验证码失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSecondarySent(true);
|
||||
setSecondaryExpiresAt(data.expiresAt || "");
|
||||
setSecondaryMessage("验证码已发送,请检查邮箱");
|
||||
} catch (err) {
|
||||
setSecondaryError(err.message);
|
||||
} finally {
|
||||
setSecondaryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifySecondary = async (event) => {
|
||||
event.preventDefault();
|
||||
setSecondaryLoading(true);
|
||||
setSecondaryError("");
|
||||
setSecondaryMessage("");
|
||||
const token = localStorage.getItem("sproutgate_token");
|
||||
if (!token) { setSecondaryError("请先登录"); setSecondaryLoading(false); return; }
|
||||
if (!secondaryForm.email || !secondaryForm.code) {
|
||||
setSecondaryError("请输入邮箱与验证码");
|
||||
setSecondaryLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/secondary-email/verify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
|
||||
body: JSON.stringify({ email: secondaryForm.email, code: secondaryForm.code })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
clearTokenAndUser();
|
||||
setSecondaryError(formatAuthBanMessage(data));
|
||||
} else {
|
||||
throw new Error(data.error || "验证失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.user) setUser(data.user);
|
||||
setSecondaryMessage("辅助邮箱验证成功");
|
||||
setSecondarySent(false);
|
||||
setSecondaryForm({ email: "", code: "" });
|
||||
setSecondaryEditorOpen(false);
|
||||
} catch (err) {
|
||||
setSecondaryError(err.message);
|
||||
} finally {
|
||||
setSecondaryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileChange = (field, value) => {
|
||||
setProfileForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleProfileSave = async (event) => {
|
||||
event.preventDefault();
|
||||
setProfileLoading(true);
|
||||
setProfileError("");
|
||||
setProfileMessage("");
|
||||
const token = localStorage.getItem("sproutgate_token");
|
||||
if (!token) { setProfileError("请先登录"); setProfileLoading(false); return; }
|
||||
const payload = {
|
||||
username: profileForm.username,
|
||||
phone: profileForm.phone,
|
||||
avatarUrl: profileForm.avatarUrl,
|
||||
websiteUrl: profileForm.websiteUrl,
|
||||
bio: profileForm.bio
|
||||
};
|
||||
if (profileForm.password) payload.password = profileForm.password;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/profile`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...bearerHeaders(token) },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
clearTokenAndUser();
|
||||
setProfileError(formatAuthBanMessage(data));
|
||||
} else {
|
||||
throw new Error(data.error || "保存失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setUser(data.user);
|
||||
setProfileMessage("保存成功");
|
||||
setProfileForm((prev) => ({ ...prev, password: "" }));
|
||||
setProfileEditorOpen(false);
|
||||
setProfileEditorFocus("");
|
||||
} catch (err) {
|
||||
setProfileError(err.message);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openProfileEditor = (field = "") => {
|
||||
setProfileError("");
|
||||
setProfileMessage("");
|
||||
setProfileEditorFocus(field);
|
||||
setProfileEditorOpen(true);
|
||||
};
|
||||
|
||||
const openSecondaryEditor = () => {
|
||||
setSecondaryError("");
|
||||
setSecondaryMessage("");
|
||||
setSecondarySent(false);
|
||||
setSecondaryForm({ email: "", code: "" });
|
||||
setSecondaryEditorOpen(true);
|
||||
};
|
||||
|
||||
const closeProfileEditor = () => {
|
||||
setProfileEditorOpen(false);
|
||||
setProfileEditorFocus("");
|
||||
};
|
||||
|
||||
const closeSecondaryEditor = () => {
|
||||
setSecondaryEditorOpen(false);
|
||||
setSecondarySent(false);
|
||||
setSecondaryForm({ email: "", code: "" });
|
||||
setSecondaryError("");
|
||||
setSecondaryMessage("");
|
||||
};
|
||||
|
||||
const profileFocusLabels = {
|
||||
phone: "手机号",
|
||||
avatarUrl: "头像",
|
||||
websiteUrl: "个人主页",
|
||||
bio: "简介",
|
||||
username: "用户名"
|
||||
};
|
||||
const profileModalTitle = profileEditorFocus && profileFocusLabels[profileEditorFocus]
|
||||
? `修改${profileFocusLabels[profileEditorFocus]}`
|
||||
: "修改资料";
|
||||
|
||||
const handleCheckIn = async () => {
|
||||
const token = localStorage.getItem("sproutgate_token");
|
||||
if (!token) { setCheckInError("请先登录"); return; }
|
||||
setCheckInLoading(true);
|
||||
setCheckInError("");
|
||||
setCheckInMessage("");
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/auth/check-in`, {
|
||||
method: "POST",
|
||||
headers: bearerHeaders(token)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
clearTokenAndUser();
|
||||
setCheckInError(formatAuthBanMessage(data));
|
||||
} else {
|
||||
throw new Error(data.error || "签到失败");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.user) setUser(data.user);
|
||||
const checkIn = data.checkIn || {};
|
||||
setCheckInReward(Number(checkIn.rewardCoins) || 1);
|
||||
setCheckInToday(Boolean(checkIn.checkedInToday));
|
||||
setCheckInMessage(
|
||||
data.message || (data.alreadyCheckedIn ? "今日已签到" : `签到成功,获得 ${data.awardedCoins ?? data.rewardCoins ?? 0} 萌芽币`)
|
||||
);
|
||||
} catch (err) {
|
||||
setCheckInError(err.message);
|
||||
} finally {
|
||||
setCheckInLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<UserPortalAuthSection
|
||||
isAuthFlow={isAuthFlow}
|
||||
user={user}
|
||||
onPreviewImage={onPreviewImage}
|
||||
handleContinueAuth={handleContinueAuth}
|
||||
handleSwitchAuthAccount={handleSwitchAuthAccount}
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
account={account}
|
||||
setAccount={setAccount}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
loading={loading}
|
||||
error={error}
|
||||
setError={setError}
|
||||
handleLogin={handleLogin}
|
||||
registerForm={registerForm}
|
||||
handleRegisterChange={handleRegisterChange}
|
||||
registerSent={registerSent}
|
||||
registerExpiresAt={registerExpiresAt}
|
||||
registerLoading={registerLoading}
|
||||
registerError={registerError}
|
||||
registerMessage={registerMessage}
|
||||
handleSendCode={handleSendCode}
|
||||
handleVerifyRegister={handleVerifyRegister}
|
||||
setRegisterError={setRegisterError}
|
||||
setRegisterMessage={setRegisterMessage}
|
||||
registrationRequireInvite={registrationRequireInvite}
|
||||
resetRegisterFlow={resetRegisterFlow}
|
||||
resetForm={resetForm}
|
||||
handleResetChange={handleResetChange}
|
||||
resetSent={resetSent}
|
||||
resetExpiresAt={resetExpiresAt}
|
||||
resetLoading={resetLoading}
|
||||
resetError={resetError}
|
||||
resetMessage={resetMessage}
|
||||
handleSendReset={handleSendReset}
|
||||
handleResetPassword={handleResetPassword}
|
||||
setResetError={setResetError}
|
||||
setResetMessage={setResetMessage}
|
||||
/>
|
||||
{user && (
|
||||
<UserPortalProfileSection
|
||||
user={user}
|
||||
onPreviewImage={onPreviewImage}
|
||||
handleLogout={handleLogout}
|
||||
openProfileEditor={openProfileEditor}
|
||||
openSecondaryEditor={openSecondaryEditor}
|
||||
profileEditorOpen={profileEditorOpen}
|
||||
closeProfileEditor={closeProfileEditor}
|
||||
handleProfileSave={handleProfileSave}
|
||||
profileForm={profileForm}
|
||||
handleProfileChange={handleProfileChange}
|
||||
profileLoading={profileLoading}
|
||||
profileError={profileError}
|
||||
profileMessage={profileMessage}
|
||||
profileModalTitle={profileModalTitle}
|
||||
secondaryEditorOpen={secondaryEditorOpen}
|
||||
closeSecondaryEditor={closeSecondaryEditor}
|
||||
handleSendSecondary={handleSendSecondary}
|
||||
handleVerifySecondary={handleVerifySecondary}
|
||||
secondaryForm={secondaryForm}
|
||||
handleSecondaryChange={handleSecondaryChange}
|
||||
secondarySent={secondarySent}
|
||||
secondaryLoading={secondaryLoading}
|
||||
secondaryError={secondaryError}
|
||||
secondaryMessage={secondaryMessage}
|
||||
secondaryExpiresAt={secondaryExpiresAt}
|
||||
checkInReward={checkInReward}
|
||||
checkInToday={checkInToday}
|
||||
checkInLoading={checkInLoading}
|
||||
checkInError={checkInError}
|
||||
checkInMessage={checkInMessage}
|
||||
handleCheckIn={handleCheckIn}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
73
sproutgate-frontend/src/components/common.jsx
Normal file
73
sproutgate-frontend/src/components/common.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from "react";
|
||||
import { mailtoHref } from "../config";
|
||||
|
||||
export function IconLabel({ icon, text, hint }) {
|
||||
return (
|
||||
<span className="label-text">
|
||||
<span className="icon">{icon}</span>
|
||||
<span>{text}</span>
|
||||
{hint && <span className="hint">{hint}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoLabel({ icon, text }) {
|
||||
return (
|
||||
<span className="info-label">
|
||||
<span className="icon">{icon}</span>
|
||||
<span>{text}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableCell({ icon, children, onClick }) {
|
||||
return (
|
||||
<span className="table-cell" onClick={onClick}>
|
||||
<span className="icon">{icon}</span>
|
||||
<span>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatItem({ icon, label, value }) {
|
||||
return (
|
||||
<span className="stat-item">
|
||||
<span className="stat-item-lead">
|
||||
{icon ? <span className="icon stat-item-icon">{icon}</span> : null}
|
||||
<span className="stat-item-label">{label}</span>
|
||||
</span>
|
||||
<strong className="stat-item-value">{value}</strong>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 邮箱展示为可点击的 mailto 链接(非法地址则回退为纯文本) */
|
||||
export function MailtoEmail({ address, className, children }) {
|
||||
const href = mailtoHref(address);
|
||||
const text = children ?? address;
|
||||
if (!href) {
|
||||
return <span className={className}>{text}</span>;
|
||||
}
|
||||
return (
|
||||
<a href={href} className={className || "mailto-email-link"}>
|
||||
{text}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoRow({ icon, label, value, actionLabel, onAction, children }) {
|
||||
return (
|
||||
<div className="info-row">
|
||||
<span className="info-row-label">
|
||||
<span className="icon">{icon}</span>
|
||||
{label}
|
||||
</span>
|
||||
<span className="info-row-value">{children ?? value}</span>
|
||||
{actionLabel && (
|
||||
<button type="button" className="text-button" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from "react";
|
||||
import icons from "../../icons";
|
||||
import { IconLabel, MailtoEmail } from "../common";
|
||||
|
||||
export default function UserPortalAuthSection({
|
||||
isAuthFlow,
|
||||
user,
|
||||
onPreviewImage,
|
||||
handleContinueAuth,
|
||||
handleSwitchAuthAccount,
|
||||
mode,
|
||||
setMode,
|
||||
account,
|
||||
setAccount,
|
||||
password,
|
||||
setPassword,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
handleLogin,
|
||||
registerForm,
|
||||
handleRegisterChange,
|
||||
registerSent,
|
||||
registerExpiresAt,
|
||||
registerLoading,
|
||||
registerError,
|
||||
registerMessage,
|
||||
handleSendCode,
|
||||
handleVerifyRegister,
|
||||
setRegisterError,
|
||||
setRegisterMessage,
|
||||
registrationRequireInvite,
|
||||
resetRegisterFlow,
|
||||
resetForm,
|
||||
handleResetChange,
|
||||
resetSent,
|
||||
resetExpiresAt,
|
||||
resetLoading,
|
||||
resetError,
|
||||
resetMessage,
|
||||
handleSendReset,
|
||||
handleResetPassword,
|
||||
setResetError,
|
||||
setResetMessage
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{isAuthFlow && user && (
|
||||
<div className="card form">
|
||||
<h2>第三方登录授权</h2>
|
||||
<div className="hint">外部应用正在请求使用当前萌芽统一账户认证登录。</div>
|
||||
<div className="profile-header" style={{ marginTop: "12px" }}>
|
||||
<img
|
||||
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
|
||||
alt="avatar"
|
||||
className="previewable-image"
|
||||
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div>
|
||||
<h3>{user.username || user.account}</h3>
|
||||
<p>{user.account}</p>
|
||||
<p>
|
||||
{user.email ? (
|
||||
<MailtoEmail address={user.email} className="profile-external-link">{user.email}</MailtoEmail>
|
||||
) : (
|
||||
"未填写邮箱"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="primary" onClick={handleContinueAuth}>继续授权</button>
|
||||
<button type="button" className="ghost" onClick={handleSwitchAuthAccount}>切换账号</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user && mode === "login" && (
|
||||
<form className="card form auth-card" onSubmit={handleLogin}>
|
||||
<div className="auth-form-head">
|
||||
<h2>登录</h2>
|
||||
<p className="auth-form-lead">使用萌芽统一账户进入用户中心</p>
|
||||
{isAuthFlow && (
|
||||
<div className="hint auth-form-hint">你正在通过统一登录为外部应用授权,登录后将自动返回。</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="auth-form-body">
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.account} text="账户" />
|
||||
<input
|
||||
value={account}
|
||||
onChange={(e) => setAccount(e.target.value)}
|
||||
placeholder="请输入账户"
|
||||
autoComplete="username"
|
||||
name="account"
|
||||
/>
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.password} text="密码" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
autoComplete="current-password"
|
||||
name="password"
|
||||
/>
|
||||
</label>
|
||||
{error && <div className="error auth-form-alert" role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className="auth-form-actions">
|
||||
<button type="submit" className="primary auth-submit" disabled={loading}>
|
||||
{loading ? "登录中…" : "登录"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="auth-form-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="auth-footer-link"
|
||||
onClick={() => { setMode("register"); setError(""); resetRegisterFlow?.(); setRegisterError(""); setRegisterMessage(""); }}
|
||||
>
|
||||
注册账号
|
||||
</button>
|
||||
<span className="auth-footer-sep" aria-hidden />
|
||||
<button type="button" className="auth-footer-link" onClick={() => { setMode("reset"); setError(""); }}>
|
||||
忘记密码
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!user && mode === "register" && (
|
||||
<form className="card form auth-card" onSubmit={registerSent ? handleVerifyRegister : handleSendCode}>
|
||||
<div className="auth-form-head">
|
||||
<h2>注册账号</h2>
|
||||
<p className="auth-form-lead">创建萌芽统一账户,验证码将发送至你的邮箱</p>
|
||||
</div>
|
||||
<div className="auth-form-body">
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.account} text="账户" />
|
||||
<input value={registerForm.account} onChange={(e) => handleRegisterChange("account", e.target.value)} placeholder="请输入账户" />
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.password} text="密码" />
|
||||
<input type="password" value={registerForm.password} onChange={(e) => handleRegisterChange("password", e.target.value)} placeholder="请输入密码" />
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.username} text="用户名" />
|
||||
<input value={registerForm.username} onChange={(e) => handleRegisterChange("username", e.target.value)} placeholder="可选" />
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.email} text="邮箱" />
|
||||
<input value={registerForm.email} onChange={(e) => handleRegisterChange("email", e.target.value)} placeholder="用于接收验证码" />
|
||||
</label>
|
||||
{(registrationRequireInvite || !registerSent) && (
|
||||
<label className="auth-field">
|
||||
<IconLabel
|
||||
icon={icons.token}
|
||||
text="邀请码"
|
||||
hint={registrationRequireInvite ? "(必填)" : "(选填)"}
|
||||
/>
|
||||
<input
|
||||
value={registerForm.inviteCode || ""}
|
||||
onChange={(e) => handleRegisterChange("inviteCode", e.target.value)}
|
||||
placeholder={registrationRequireInvite ? "请输入管理员发放的邀请码" : "有邀请码可填写,无则留空"}
|
||||
disabled={registerSent}
|
||||
autoCapitalize="characters"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{registerSent && (
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.token} text="邮箱验证码" />
|
||||
<input value={registerForm.code} onChange={(e) => handleRegisterChange("code", e.target.value)} placeholder="6 位验证码" />
|
||||
</label>
|
||||
)}
|
||||
{registerExpiresAt && <div className="hint">验证码有效期至:{registerExpiresAt}</div>}
|
||||
{registerError && <div className="error auth-form-alert" role="alert">{registerError}</div>}
|
||||
{registerMessage && <div className="success auth-form-alert">{registerMessage}</div>}
|
||||
</div>
|
||||
<div className="auth-form-actions">
|
||||
<button type="submit" className="primary auth-submit" disabled={registerLoading}>
|
||||
{registerLoading ? "处理中…" : registerSent ? "完成注册" : "发送验证码"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-footer-back"
|
||||
onClick={() => { setMode("login"); resetRegisterFlow?.(); setRegisterError(""); setRegisterMessage(""); }}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!user && mode === "reset" && (
|
||||
<form className="card form auth-card" onSubmit={resetSent ? handleResetPassword : handleSendReset}>
|
||||
<div className="auth-form-head">
|
||||
<h2>重置密码</h2>
|
||||
<p className="auth-form-lead">通过注册邮箱接收验证码,设置新密码</p>
|
||||
</div>
|
||||
<div className="auth-form-body">
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.account} text="账户" />
|
||||
<input value={resetForm.account} onChange={(e) => handleResetChange("account", e.target.value)} placeholder="请输入账户" />
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.email} text="邮箱" />
|
||||
<input value={resetForm.email} onChange={(e) => handleResetChange("email", e.target.value)} placeholder="请输入注册邮箱" />
|
||||
</label>
|
||||
{resetSent && (
|
||||
<>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.token} text="邮箱验证码" />
|
||||
<input value={resetForm.code} onChange={(e) => handleResetChange("code", e.target.value)} placeholder="6 位验证码" />
|
||||
</label>
|
||||
<label className="auth-field">
|
||||
<IconLabel icon={icons.password} text="新密码" />
|
||||
<input type="password" value={resetForm.newPassword} onChange={(e) => handleResetChange("newPassword", e.target.value)} placeholder="输入新密码" />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{resetExpiresAt && <div className="hint">验证码有效期至:{resetExpiresAt}</div>}
|
||||
{resetError && <div className="error auth-form-alert" role="alert">{resetError}</div>}
|
||||
{resetMessage && <div className="success auth-form-alert">{resetMessage}</div>}
|
||||
</div>
|
||||
<div className="auth-form-actions">
|
||||
<button type="submit" className="primary auth-submit" disabled={resetLoading}>
|
||||
{resetLoading ? "处理中…" : resetSent ? "确认重置" : "发送重置邮件"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-footer-back"
|
||||
onClick={() => { setMode("login"); setResetError(""); setResetMessage(""); }}
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import React from "react";
|
||||
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
|
||||
import icons from "../../icons";
|
||||
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
|
||||
|
||||
export default function UserPortalProfileSection({
|
||||
user,
|
||||
onPreviewImage,
|
||||
handleLogout,
|
||||
openProfileEditor,
|
||||
openSecondaryEditor,
|
||||
profileEditorOpen,
|
||||
closeProfileEditor,
|
||||
handleProfileSave,
|
||||
profileForm,
|
||||
handleProfileChange,
|
||||
profileLoading,
|
||||
profileError,
|
||||
profileMessage,
|
||||
profileModalTitle,
|
||||
secondaryEditorOpen,
|
||||
closeSecondaryEditor,
|
||||
handleSendSecondary,
|
||||
handleVerifySecondary,
|
||||
secondaryForm,
|
||||
handleSecondaryChange,
|
||||
secondarySent,
|
||||
secondaryLoading,
|
||||
secondaryError,
|
||||
secondaryMessage,
|
||||
secondaryExpiresAt,
|
||||
checkInReward,
|
||||
checkInToday,
|
||||
checkInLoading,
|
||||
checkInError,
|
||||
checkInMessage,
|
||||
handleCheckIn
|
||||
}) {
|
||||
return (
|
||||
<div className="card profile">
|
||||
<div className="profile-header">
|
||||
<img
|
||||
src={user.avatarUrl || "https://dummyimage.com/120x120/ddd/fff&text=Avatar"}
|
||||
alt="avatar"
|
||||
className="previewable-image"
|
||||
onClick={() => onPreviewImage?.(user.avatarUrl || "", user.username || user.account || "avatar")}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
<div>
|
||||
<h2>{user.username || user.account}</h2>
|
||||
<p className="profile-header-email">
|
||||
{user.email ? (
|
||||
<MailtoEmail address={user.email} className="profile-external-link">
|
||||
{user.email}
|
||||
</MailtoEmail>
|
||||
) : (
|
||||
"未填写邮箱"
|
||||
)}
|
||||
</p>
|
||||
<div className="profile-header-actions">
|
||||
<a className="ghost" href={`/user/${encodeURIComponent(user.account)}`}>公开主页</a>
|
||||
<button type="button" className="ghost" onClick={handleLogout}>退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-section-title profile-section-title--lead">基本信息</div>
|
||||
<div className="profile-info-rows">
|
||||
<InfoRow icon={icons.account} label="账户" value={user.account} />
|
||||
<InfoRow icon={icons.username} label="用户名" value={user.username || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("username")} />
|
||||
<InfoRow icon={icons.calendar} label="注册时间" value={formatUserRegisteredAt(user.createdAt)} />
|
||||
<InfoRow icon={icons.email} label="邮箱">
|
||||
{user.email ? (
|
||||
<MailtoEmail address={user.email} className="profile-external-link">
|
||||
{user.email}
|
||||
</MailtoEmail>
|
||||
) : (
|
||||
"未填写"
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={icons.phone} label="手机号" value={user.phone || "未填写"} actionLabel="修改" onAction={() => openProfileEditor("phone")} />
|
||||
<InfoRow icon={icons.avatar} label="头像" value={user.avatarUrl ? "已设置" : "未填写"} actionLabel="修改" onAction={() => openProfileEditor("avatarUrl")} />
|
||||
<InfoRow icon={icons.link} label="个人主页" actionLabel="修改" onAction={() => openProfileEditor("websiteUrl")}>
|
||||
{user.websiteUrl ? (
|
||||
<a
|
||||
href={user.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="profile-external-link"
|
||||
>
|
||||
{formatWebsiteLabel(user.websiteUrl)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="muted">未填写</span>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow
|
||||
icon={icons.secondaryEmail}
|
||||
label="辅助邮箱"
|
||||
actionLabel={(user.secondaryEmails || []).length ? "管理" : "添加"}
|
||||
onAction={openSecondaryEditor}
|
||||
>
|
||||
{(user.secondaryEmails || []).length === 0 ? (
|
||||
<span className="muted">暂无</span>
|
||||
) : (
|
||||
(user.secondaryEmails || []).map((email) => (
|
||||
<MailtoEmail key={email} address={email} className="tag tag-mailto">
|
||||
{email}
|
||||
</MailtoEmail>
|
||||
))
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow icon={icons.bio} label="简介" value={user.bio ? "已填写" : "暂无简介"} actionLabel="修改" onAction={() => openProfileEditor("bio")} />
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">统计信息</div>
|
||||
<div className="profile-stats-flow">
|
||||
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />
|
||||
<StatItem icon={icons.coins} label="萌芽币" value={user.sproutCoins ?? 0} />
|
||||
<StatItem icon={icons.calendar} label="签到天数" value={`${user.checkInDays ?? 0}`} />
|
||||
<StatItem icon={icons.statLightning} label="连续签到" value={`${user.checkInStreak ?? 0}`} />
|
||||
<StatItem icon={icons.statClock} label="访问天数" value={`${user.visitDays ?? 0}`} />
|
||||
<StatItem icon={icons.statRepeat} label="连续访问" value={`${user.visitStreak ?? 0}`} />
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">活动记录</div>
|
||||
<div className="profile-activity-row">
|
||||
<span>最后签到 <strong>{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</strong></span>
|
||||
<span>最后访问 <strong>{user.lastVisitAt || user.lastVisitDate || "未访问"}</strong></span>
|
||||
</div>
|
||||
<div className="profile-activity-row profile-visit-meta">
|
||||
<span>
|
||||
最后访问 IP{" "}
|
||||
<strong className="mono">{user.lastVisitIp || "暂无"}</strong>
|
||||
</span>
|
||||
<span>
|
||||
最后位置 <strong>{user.lastVisitDisplayLocation || "暂无"}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">应用接入记录</div>
|
||||
<p className="profile-auth-clients-hint muted">
|
||||
第三方应用在登录页 URL 中携带 <code className="inline-code">client_id</code>(可选 <code className="inline-code">client_name</code>),或在调用 <code className="inline-code">POST /api/auth/verify</code>、<code className="inline-code">GET /api/auth/me</code> 时传入请求头 <code className="inline-code">X-Auth-Client</code>,即可在此累计展示。
|
||||
</p>
|
||||
{(!user.authClients || user.authClients.length === 0) ? (
|
||||
<div className="profile-auth-clients-empty muted">暂无接入记录</div>
|
||||
) : (
|
||||
<ul className="profile-auth-clients">
|
||||
{[...user.authClients]
|
||||
.sort((a, b) => {
|
||||
const ta = new Date(a.lastSeenAt || 0).getTime();
|
||||
const tb = new Date(b.lastSeenAt || 0).getTime();
|
||||
return tb - ta;
|
||||
})
|
||||
.map((row) => (
|
||||
<li key={row.clientId} className="profile-auth-client-row">
|
||||
<span className="profile-auth-client-id">
|
||||
<span className="icon profile-auth-client-icon" aria-hidden="true">{icons.apps}</span>
|
||||
<strong>{row.clientId}</strong>
|
||||
{row.displayName ? <span className="muted profile-auth-client-dname">({row.displayName})</span> : null}
|
||||
</span>
|
||||
<span className="profile-auth-client-times muted">
|
||||
首次 {formatIsoDateTimeReadable(row.firstSeenAt)} · 最近 {formatIsoDateTimeReadable(row.lastSeenAt)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{profileEditorOpen && (
|
||||
<div className="modal-backdrop" onClick={closeProfileEditor}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<form onSubmit={handleProfileSave}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>{profileModalTitle}</h2>
|
||||
<p>填写完成后保存修改。</p>
|
||||
</div>
|
||||
<button type="button" className="ghost modal-close" onClick={closeProfileEditor}>关闭</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>
|
||||
<IconLabel icon={icons.username} text="用户名" />
|
||||
<input value={profileForm.username} onChange={(e) => handleProfileChange("username", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.phone} text="手机号" />
|
||||
<input value={profileForm.phone} onChange={(e) => handleProfileChange("phone", e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<IconLabel icon={icons.avatar} text="个人头像(链接)" />
|
||||
<input value={profileForm.avatarUrl} onChange={(e) => handleProfileChange("avatarUrl", e.target.value)} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.link} text="个人主页网站" hint="(http/https,可省略协议)" />
|
||||
<input
|
||||
value={profileForm.websiteUrl}
|
||||
onChange={(e) => handleProfileChange("websiteUrl", e.target.value)}
|
||||
placeholder="https://example.com 或 example.com"
|
||||
/>
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.bio} text="个人简介(支持 Markdown)" />
|
||||
<textarea value={profileForm.bio} onChange={(e) => handleProfileChange("bio", e.target.value)} rows={4} />
|
||||
</label>
|
||||
<label className="full-span">
|
||||
<IconLabel icon={icons.password} text="密码" hint="(留空不修改)" />
|
||||
<input type="password" value={profileForm.password} onChange={(e) => handleProfileChange("password", e.target.value)} placeholder="输入新密码" />
|
||||
</label>
|
||||
{profileError && <div className="error full-span">{profileError}</div>}
|
||||
{profileMessage && <div className="success full-span">{profileMessage}</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary" disabled={profileLoading} type="submit">{profileLoading ? "保存中..." : "保存修改"}</button>
|
||||
<button type="button" className="ghost" onClick={closeProfileEditor}>取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secondaryEditorOpen && (
|
||||
<div className="modal-backdrop" onClick={closeSecondaryEditor}>
|
||||
<div className="modal-card" onClick={(e) => e.stopPropagation()}>
|
||||
<form onSubmit={secondarySent ? handleVerifySecondary : handleSendSecondary}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2>辅助邮箱</h2>
|
||||
<p>发送验证码并完成验证后,辅助邮箱会更新到当前账户。</p>
|
||||
</div>
|
||||
<button type="button" className="ghost modal-close" onClick={closeSecondaryEditor}>关闭</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label>
|
||||
<IconLabel icon={icons.secondaryEmail} text="辅助邮箱" />
|
||||
<input value={secondaryForm.email} onChange={(e) => handleSecondaryChange("email", e.target.value)} placeholder="请输入辅助邮箱" />
|
||||
</label>
|
||||
{secondarySent && (
|
||||
<label>
|
||||
<IconLabel icon={icons.token} text="邮箱验证码" />
|
||||
<input value={secondaryForm.code} onChange={(e) => handleSecondaryChange("code", e.target.value)} placeholder="6 位验证码" />
|
||||
</label>
|
||||
)}
|
||||
{secondaryExpiresAt && <div className="hint full-span">验证码有效期至:{secondaryExpiresAt}</div>}
|
||||
{secondaryError && <div className="error full-span">{secondaryError}</div>}
|
||||
{secondaryMessage && <div className="success full-span">{secondaryMessage}</div>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary" disabled={secondaryLoading} type="submit">{secondarySent ? "确认验证" : "发送验证码"}</button>
|
||||
<button type="button" className="ghost" onClick={closeSecondaryEditor}>取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-section-title">个人简介</div>
|
||||
<div className="markdown profile-markdown">
|
||||
<div
|
||||
className="markdown-body"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(user.bio || "暂无简介") }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="profile-section-title">每日签到</div>
|
||||
<div className="profile-checkin-block">
|
||||
<div className="hint">每天可签到一次,签到奖励 {checkInReward} 萌芽币</div>
|
||||
<div className="tag-list">
|
||||
<span className="tag">{checkInToday ? "今日已签到" : "今日未签到"}</span>
|
||||
<span className="tag">上次签到:{user.lastCheckInAt || user.lastCheckInDate || "未签到"}</span>
|
||||
</div>
|
||||
{checkInError && <div className="error">{checkInError}</div>}
|
||||
{checkInMessage && <div className="success">{checkInMessage}</div>}
|
||||
<div className="actions profile-checkin-actions">
|
||||
<button type="button" className="primary" onClick={handleCheckIn} disabled={checkInLoading || checkInToday}>
|
||||
{checkInLoading ? "签到中..." : checkInToday ? "今日已签到" : `立即签到 +${checkInReward}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
187
sproutgate-frontend/src/config.js
Normal file
187
sproutgate-frontend/src/config.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { marked } from "marked";
|
||||
|
||||
const DEFAULT_DEV_API_BASE = "http://localhost:8080";
|
||||
const DEFAULT_PROD_API_BASE = "https://auth.api.shumengya.top";
|
||||
|
||||
export const API_BASE = (() => {
|
||||
const configuredBase = import.meta.env.VITE_API_BASE?.trim();
|
||||
const fallbackBase = import.meta.env.DEV ? DEFAULT_DEV_API_BASE : DEFAULT_PROD_API_BASE;
|
||||
return (configuredBase || fallbackBase).replace(/\/+$/, "");
|
||||
})();
|
||||
|
||||
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
|
||||
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;
|
||||
|
||||
/** 浏览器侧 IP/地理(与后端写入「最后访问」字段配合使用) */
|
||||
export const CLIENT_GEO_LOOKUP_URL = "https://cf-ip-geo.smyhub.com/api";
|
||||
|
||||
export function formatVisitDisplayLocation(payload) {
|
||||
const g = payload?.geo;
|
||||
if (!g || typeof g !== "object") return "";
|
||||
const parts = [g.countryName, g.regionName, g.cityName].filter(
|
||||
(x) => typeof x === "string" && x.trim()
|
||||
);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export async function fetchClientVisitMeta(timeoutMs = 2800) {
|
||||
const ctrl = new AbortController();
|
||||
const id = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(CLIENT_GEO_LOOKUP_URL, {
|
||||
credentials: "omit",
|
||||
signal: ctrl.signal
|
||||
});
|
||||
clearTimeout(id);
|
||||
if (!res.ok) return { ip: "", displayLocation: "" };
|
||||
const data = await res.json();
|
||||
const ip = typeof data.ip === "string" ? data.ip.trim() : "";
|
||||
const displayLocation = formatVisitDisplayLocation(data);
|
||||
return { ip, displayLocation };
|
||||
} catch {
|
||||
clearTimeout(id);
|
||||
return { ip: "", displayLocation: "" };
|
||||
}
|
||||
}
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
export { marked };
|
||||
|
||||
export const emptyForm = {
|
||||
account: "",
|
||||
password: "",
|
||||
username: "",
|
||||
email: "",
|
||||
level: 0,
|
||||
sproutCoins: 0,
|
||||
secondaryEmails: "",
|
||||
phone: "",
|
||||
avatarUrl: "",
|
||||
websiteUrl: "",
|
||||
bio: "",
|
||||
banned: false,
|
||||
banReason: ""
|
||||
};
|
||||
|
||||
/** 后端封禁响应:`error` + 可选 `banReason`(登录/me 等 403) */
|
||||
export function formatAuthBanMessage(data) {
|
||||
const base = (data && data.error && String(data.error).trim()) || "account is banned";
|
||||
const reason = data && data.banReason != null && String(data.banReason).trim();
|
||||
return reason ? `${base}:${reason}` : base;
|
||||
}
|
||||
|
||||
/** 展示用:从完整 URL 得到「主机 + 路径」短文案 */
|
||||
export function formatWebsiteLabel(url) {
|
||||
if (!url || typeof url !== "string") return "";
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const path = u.pathname === "/" ? "" : u.pathname;
|
||||
return u.host + path;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** 用户 `createdAt`(RFC3339)→ 本地可读注册时间 */
|
||||
export function formatUserRegisteredAt(iso) {
|
||||
if (!iso || typeof iso !== "string") return "未知";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short"
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** RFC3339 / 后端存储时间 → 本地可读(用于应用接入记录等) */
|
||||
export function formatIsoDateTimeReadable(iso) {
|
||||
if (!iso || typeof iso !== "string") return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium"
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
export const parseEmailList = (value) =>
|
||||
value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
|
||||
/** 可点击发信:`mailto:`,排除明显非法地址 */
|
||||
export function mailtoHref(address) {
|
||||
const a = typeof address === "string" ? address.trim() : "";
|
||||
if (!a || /\s/.test(a) || a.includes("<") || a.includes(">")) return null;
|
||||
const at = a.indexOf("@");
|
||||
if (at < 1 || at === a.length - 1) return null;
|
||||
return `mailto:${a}`;
|
||||
}
|
||||
|
||||
export function getPublicAccountFromPathname(pathname) {
|
||||
if (pathname !== "/user" && !pathname.startsWith("/user/")) return "";
|
||||
const raw = pathname === "/user" ? "" : pathname.slice("/user/".length).split("/")[0];
|
||||
if (!raw) return "";
|
||||
try {
|
||||
return decodeURIComponent(raw).trim();
|
||||
} catch {
|
||||
return raw.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthFlowFromSearch(search) {
|
||||
const params = new URLSearchParams(search);
|
||||
const redirectUri = (params.get("redirect_uri") || params.get("return_url") || "").trim();
|
||||
return {
|
||||
redirectUri,
|
||||
state: (params.get("state") || "").trim(),
|
||||
prompt: (params.get("prompt") || "").trim(),
|
||||
clientId: (params.get("client_id") || "").trim(),
|
||||
clientName: (params.get("client_name") || "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
const AUTH_CLIENT_ID_KEY = "sproutgate_auth_client_id";
|
||||
const AUTH_CLIENT_NAME_KEY = "sproutgate_auth_client_name";
|
||||
|
||||
/** 从统一登录 URL 上的 `client_id` / `client_name` 写入 session,后续请求带 `X-Auth-Client` 头以便记录接入应用 */
|
||||
export function persistAuthClientFromFlow(authFlow) {
|
||||
if (!authFlow?.clientId) return;
|
||||
try {
|
||||
sessionStorage.setItem(AUTH_CLIENT_ID_KEY, authFlow.clientId);
|
||||
if (authFlow.clientName) sessionStorage.setItem(AUTH_CLIENT_NAME_KEY, authFlow.clientName);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function authClientFetchHeaders() {
|
||||
try {
|
||||
const id = (sessionStorage.getItem(AUTH_CLIENT_ID_KEY) || "").trim();
|
||||
const name = (sessionStorage.getItem(AUTH_CLIENT_NAME_KEY) || "").trim();
|
||||
const h = {};
|
||||
if (id) h["X-Auth-Client"] = id;
|
||||
if (name) h["X-Auth-Client-Name"] = name;
|
||||
return h;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthClientContext() {
|
||||
try {
|
||||
sessionStorage.removeItem(AUTH_CLIENT_ID_KEY);
|
||||
sessionStorage.removeItem(AUTH_CLIENT_NAME_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAuthCallbackUrl(redirectUri, payload) {
|
||||
const url = new URL(redirectUri, window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash ? url.hash.slice(1) : "");
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
hashParams.set(key, String(value));
|
||||
});
|
||||
url.hash = hashParams.toString();
|
||||
return url.toString();
|
||||
}
|
||||
132
sproutgate-frontend/src/icons.jsx
Normal file
132
sproutgate-frontend/src/icons.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
|
||||
const icons = {
|
||||
account: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M4 20c1.5-3.5 5-5 8-5s6.5 1.5 8 5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
password: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="5" y="10" width="14" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M8 10V7a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
username: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="9" cy="8" r="3" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 20c1-3 4-4.5 6-4.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M14 7h7M14 11h7M14 15h5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
email: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 8l9 6 9-6" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
coins: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<ellipse cx="12" cy="7" rx="7" ry="3.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M5 7v5c0 1.9 3.1 3.5 7 3.5s7-1.6 7-3.5V7" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M5 12v5c0 1.9 3.1 3.5 7 3.5s7-1.6 7-3.5v-5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
phone: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="7" y="3" width="10" height="18" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="17" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
avatar: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="9" cy="10" r="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 17l5-4 4 3 4-5 5 6" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
bio: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 4h8l4 4v12H6z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M14 4v4h4" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M8 12h8M8 16h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
link: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M10 13a5 5 0 0 1 0-7l1-1a5 5 0 0 1 7 7l-1 1" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M14 11a5 5 0 0 1 0 7l-1 1a5 5 0 0 1-7-7l1-1" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
token: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="7" cy="12" r="4" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M11 12h10M17 12v4M20 12v2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
level: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M9 15h6M9 9h6M9 12h6" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
calendar: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="5" width="16" height="15" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M4 9h16M8 3v4M16 3v4" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M8 13h3M13 13h3M8 16h3" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
secondaryEmail: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="6" width="14" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M4 8l7 4 7-4" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="8" y="10" width="12" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
visitIp: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 12h18M12 3a15 15 0 0 1 0 18" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
visitGeo: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="12" cy="10" r="2.2" fill="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
ban: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M6 18L18 6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
),
|
||||
apps: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="4" y="4" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="13" y="4" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="4" y="13" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<rect x="13" y="13" width="7" height="7" rx="1.5" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
statLightning: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
statClock: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M12 7v6l4 2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
),
|
||||
statRepeat: (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 3h4v4M7 21H3v-4M3 12a9 9 0 0 1 14.3-7.2L21 8M21 12a9 9 0 0 1-14.3 7.2L3 16" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||
</svg>
|
||||
)
|
||||
};
|
||||
|
||||
export default icons;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,7 @@ start "SproutGate Backend" cmd /k "cd /d %ROOT_DIR%sproutgate-backend && go run
|
||||
|
||||
echo ==^> Starting frontend (React)...
|
||||
cd /d %ROOT_DIR%sproutgate-frontend
|
||||
if not exist node_modules (
|
||||
npm install
|
||||
)
|
||||
|
||||
npm run dev
|
||||
exit /b 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user