完善初始化更新
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
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## 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-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/`, domain models in `internal/models/`, storage in `internal/storage/`.
|
- `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/`.
|
- `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.
|
- 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.
|
- PRs should include a concise summary, testing notes, and screenshots for UI changes; call out any config or env var updates.
|
||||||
|
|
||||||
## Security & Configuration Tips
|
## 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`.
|
- 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.
|
- 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(萌芽账户认证中心)
|
||||||
|
|
||||||
前后端分离的统一账户认证中心:
|
前后端分离的统一账户与轻量用户中心:注册登录、邮箱验证、找回密码、副邮箱、签到与资料管理;管理员可维护用户与签到配置。数据以 JSON 文件落盘,适合自建与小规模部署。
|
||||||
- 前端:React(`sproutgate-frontend`)
|
|
||||||
- 后端:Golang + Gin(`sproutgate-backend`)
|
|
||||||
- 数据:`data/` 与子目录 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
|
```bash
|
||||||
cd sproutgate-backend
|
cd sproutgate-backend
|
||||||
go mod tidy
|
go mod tidy
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
默认端口 `8080`,默认管理员 Token:`shumengya520`(位于 `sproutgate-backend/data/config/admin.json`)。
|
**前端**
|
||||||
邮件发送配置位于 `sproutgate-backend/data/config/email.json`。
|
|
||||||
|
|
||||||
### 前端
|
|
||||||
```bash
|
```bash
|
||||||
cd sproutgate-frontend
|
cd sproutgate-frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
如需自定义后端地址,新增 `sproutgate-frontend/.env`:
|
### 前端连接后端
|
||||||
```
|
|
||||||
|
在 `sproutgate-frontend/.env`(自行创建)中设置:
|
||||||
|
|
||||||
|
```env
|
||||||
VITE_API_BASE=http://localhost:8080
|
VITE_API_BASE=http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
### 管理员地址
|
生产环境改为实际 API 地址即可。
|
||||||
```
|
|
||||||
http://localhost:5173/admin?token=shumengya520
|
### 配置与安全
|
||||||
|
|
||||||
|
- 管理员 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 文档
|
# 萌芽账户认证中心 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
|
```json
|
||||||
{
|
{
|
||||||
"account": "demo",
|
"account": "demo",
|
||||||
"password": "demo123"
|
"password": "demo123",
|
||||||
|
"clientId": "my-app",
|
||||||
|
"clientName": "我的应用"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见下文 `authClients`)。
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -29,6 +86,7 @@
|
|||||||
"secondaryEmails": ["demo2@example.com"],
|
"secondaryEmails": ["demo2@example.com"],
|
||||||
"phone": "13800000000",
|
"phone": "13800000000",
|
||||||
"avatarUrl": "https://example.com/avatar.png",
|
"avatarUrl": "https://example.com/avatar.png",
|
||||||
|
"websiteUrl": "https://example.com",
|
||||||
"bio": "### 简介",
|
"bio": "### 简介",
|
||||||
"createdAt": "2026-03-14T12:00:00Z",
|
"createdAt": "2026-03-14T12:00:00Z",
|
||||||
"updatedAt": "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`
|
`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`
|
`GET /api/auth/me`
|
||||||
|
|
||||||
请求头:
|
请求头:
|
||||||
`Authorization: Bearer <jwt-token>`
|
`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
|
```json
|
||||||
{
|
{
|
||||||
|
"checkedIn": true,
|
||||||
|
"alreadyCheckedIn": false,
|
||||||
|
"rewardCoins": 1,
|
||||||
|
"awardedCoins": 1,
|
||||||
|
"message": "签到成功",
|
||||||
"user": { "account": "demo", "...": "..." }
|
"user": { "account": "demo", "...": "..." }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 说明:密码不会返回。
|
|
||||||
|
|
||||||
### 更新当前用户资料
|
### 更新当前用户资料
|
||||||
`PUT /api/auth/profile`
|
`PUT /api/auth/profile`
|
||||||
|
|
||||||
@@ -82,10 +221,13 @@
|
|||||||
"username": "新昵称",
|
"username": "新昵称",
|
||||||
"phone": "13800000000",
|
"phone": "13800000000",
|
||||||
"avatarUrl": "https://example.com/avatar.png",
|
"avatarUrl": "https://example.com/avatar.png",
|
||||||
|
"websiteUrl": "https://example.com",
|
||||||
"bio": "### 新简介"
|
"bio": "### 新简介"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`。
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
```json
|
```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`
|
`POST /api/auth/register`
|
||||||
|
|
||||||
@@ -102,10 +286,13 @@
|
|||||||
"account": "demo",
|
"account": "demo",
|
||||||
"password": "demo123",
|
"password": "demo123",
|
||||||
"username": "示例用户",
|
"username": "示例用户",
|
||||||
"email": "demo@example.com"
|
"email": "demo@example.com",
|
||||||
|
"inviteCode": "ABCD1234"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
|
||||||
|
|
||||||
响应:
|
响应:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -218,8 +405,50 @@
|
|||||||
请求时可使用以下任一方式携带:
|
请求时可使用以下任一方式携带:
|
||||||
- Query:`?token=<admin-token>`
|
- Query:`?token=<admin-token>`
|
||||||
- Header:`X-Admin-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>`
|
- 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`
|
`GET /api/admin/users`
|
||||||
|
|
||||||
@@ -246,6 +475,7 @@
|
|||||||
"secondaryEmails": ["demo2@example.com"],
|
"secondaryEmails": ["demo2@example.com"],
|
||||||
"phone": "13800000000",
|
"phone": "13800000000",
|
||||||
"avatarUrl": "https://example.com/avatar.png",
|
"avatarUrl": "https://example.com/avatar.png",
|
||||||
|
"websiteUrl": "https://example.com",
|
||||||
"bio": "### 简介"
|
"bio": "### 简介"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -260,10 +490,18 @@
|
|||||||
"username": "新昵称",
|
"username": "新昵称",
|
||||||
"level": 1,
|
"level": 1,
|
||||||
"secondaryEmails": ["demo2@example.com"],
|
"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}`
|
`DELETE /api/admin/users/{account}`
|
||||||
|
|
||||||
@@ -281,16 +519,25 @@
|
|||||||
- 管理员 Token:`data/config/admin.json`
|
- 管理员 Token:`data/config/admin.json`
|
||||||
- JWT 配置:`data/config/auth.json`
|
- JWT 配置:`data/config/auth.json`
|
||||||
- 邮件配置:`data/config/email.json`
|
- 邮件配置:`data/config/email.json`
|
||||||
|
- 注册策略与邀请码:`data/config/registration.json`
|
||||||
|
|
||||||
## 快速联调用示例
|
## 快速联调用示例
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 服务根路径 JSON 说明
|
||||||
|
curl -s http://localhost:8080/ | jq .
|
||||||
|
|
||||||
# 登录
|
# 登录
|
||||||
curl -X POST http://localhost:8080/api/auth/login \
|
curl -X POST http://localhost:8080/api/auth/login \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"account":"demo","password":"demo123"}'
|
-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 \
|
curl http://localhost:8080/api/auth/me \
|
||||||
-H 'Authorization: Bearer <jwt-token>'
|
-H 'Authorization: Bearer <jwt-token>'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: sproutgate-auth
|
container_name: sproutgate-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
@@ -11,4 +11,4 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
ports:
|
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"`
|
CodeHash string `json:"codeHash"`
|
||||||
ExpiresAt string `json:"expiresAt"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
InviteCode string `json:"inviteCode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,144 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type UserRecord struct {
|
type UserRecord struct {
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
PasswordHash string `json:"passwordHash"`
|
PasswordHash string `json:"passwordHash"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
SproutCoins int `json:"sproutCoins"`
|
SproutCoins int `json:"sproutCoins"`
|
||||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
LastCheckInDate string `json:"lastCheckInDate,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
LastCheckInAt string `json:"lastCheckInAt,omitempty"`
|
||||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
LastVisitDate string `json:"lastVisitDate,omitempty"`
|
||||||
Bio string `json:"bio,omitempty"`
|
LastVisitAt string `json:"lastVisitAt,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
LastVisitIP string `json:"lastVisitIp,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
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 {
|
type UserPublic struct {
|
||||||
Account string `json:"account"`
|
Account string `json:"account"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Level int `json:"level"`
|
Level int `json:"level"`
|
||||||
SproutCoins int `json:"sproutCoins"`
|
SproutCoins int `json:"sproutCoins"`
|
||||||
SecondaryEmails []string `json:"secondaryEmails,omitempty"`
|
LastCheckInDate string `json:"lastCheckInDate,omitempty"`
|
||||||
Phone string `json:"phone,omitempty"`
|
LastCheckInAt string `json:"lastCheckInAt,omitempty"`
|
||||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
LastVisitDate string `json:"lastVisitDate,omitempty"`
|
||||||
Bio string `json:"bio,omitempty"`
|
CheckInDays int `json:"checkInDays"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CheckInStreak int `json:"checkInStreak"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
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 {
|
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{
|
return UserPublic{
|
||||||
Account: u.Account,
|
Account: u.Account,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
Level: u.Level,
|
Level: u.Level,
|
||||||
SproutCoins: u.SproutCoins,
|
SproutCoins: u.SproutCoins,
|
||||||
|
LastCheckInDate: u.LastCheckInDate,
|
||||||
|
LastCheckInAt: lastCheckInAt,
|
||||||
|
LastVisitDate: u.LastVisitDate,
|
||||||
|
CheckInDays: checkInDays,
|
||||||
|
CheckInStreak: checkInStreak,
|
||||||
|
LastVisitAt: lastVisitAt,
|
||||||
|
VisitDays: visitDays,
|
||||||
|
VisitStreak: visitStreak,
|
||||||
SecondaryEmails: u.SecondaryEmails,
|
SecondaryEmails: u.SecondaryEmails,
|
||||||
Phone: u.Phone,
|
Phone: u.Phone,
|
||||||
AvatarURL: u.AvatarURL,
|
AvatarURL: u.AvatarURL,
|
||||||
|
WebsiteURL: u.WebsiteURL,
|
||||||
Bio: u.Bio,
|
Bio: u.Bio,
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
UpdatedAt: u.UpdatedAt,
|
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 {
|
func NowISO() string {
|
||||||
return time.Now().Format(time.RFC3339)
|
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"`
|
Encryption string `json:"encryption"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CheckInConfig struct {
|
||||||
|
RewardCoins int `json:"rewardCoins"`
|
||||||
|
}
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
usersDir string
|
usersDir string
|
||||||
@@ -41,10 +45,14 @@ type Store struct {
|
|||||||
adminConfigPath string
|
adminConfigPath string
|
||||||
authConfigPath string
|
authConfigPath string
|
||||||
emailConfigPath string
|
emailConfigPath string
|
||||||
|
checkInPath string
|
||||||
|
registrationPath string
|
||||||
|
registrationConfig RegistrationConfig
|
||||||
adminToken string
|
adminToken string
|
||||||
jwtSecret []byte
|
jwtSecret []byte
|
||||||
issuer string
|
issuer string
|
||||||
emailConfig EmailConfig
|
emailConfig EmailConfig
|
||||||
|
checkInConfig CheckInConfig
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +93,8 @@ func NewStore(dataDir string) (*Store, error) {
|
|||||||
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
adminConfigPath: filepath.Join(configDir, "admin.json"),
|
||||||
authConfigPath: filepath.Join(configDir, "auth.json"),
|
authConfigPath: filepath.Join(configDir, "auth.json"),
|
||||||
emailConfigPath: filepath.Join(configDir, "email.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 {
|
if err := store.loadOrCreateAdminConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -95,6 +105,12 @@ func NewStore(dataDir string) (*Store, error) {
|
|||||||
if err := store.loadOrCreateEmailConfig(); err != nil {
|
if err := store.loadOrCreateEmailConfig(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := store.loadOrCreateCheckInConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := store.loadOrCreateRegistrationConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return store, nil
|
return store, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +134,29 @@ func (s *Store) EmailConfig() EmailConfig {
|
|||||||
return s.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 {
|
func (s *Store) loadOrCreateAdminConfig() error {
|
||||||
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(s.adminConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
token, err := generateToken()
|
token, err := generateToken()
|
||||||
@@ -244,6 +283,29 @@ func (s *Store) loadOrCreateEmailConfig() error {
|
|||||||
return nil
|
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) {
|
func generateSecret() ([]byte, error) {
|
||||||
secret := make([]byte, 32)
|
secret := make([]byte, 32)
|
||||||
_, err := rand.Read(secret)
|
_, err := rand.Read(secret)
|
||||||
@@ -319,6 +381,176 @@ func (s *Store) SaveUser(record models.UserRecord) error {
|
|||||||
return writeJSONFile(path, record)
|
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 {
|
func (s *Store) DeleteUser(account string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@@ -24,12 +24,34 @@ func main() {
|
|||||||
router.Use(cors.New(cors.Config{
|
router.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: []string{"*"},
|
AllowOrigins: []string{"*"},
|
||||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
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,
|
MaxAge: 12 * time.Hour,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
handler := handlers.NewHandler(store)
|
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) {
|
router.GET("/api/health", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
@@ -49,7 +71,10 @@ func main() {
|
|||||||
router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail)
|
router.POST("/api/auth/secondary-email/verify", handler.VerifySecondaryEmail)
|
||||||
router.POST("/api/auth/verify", handler.Verify)
|
router.POST("/api/auth/verify", handler.Verify)
|
||||||
router.GET("/api/auth/me", handler.Me)
|
router.GET("/api/auth/me", handler.Me)
|
||||||
|
router.POST("/api/auth/check-in", handler.CheckIn)
|
||||||
router.PUT("/api/auth/profile", handler.UpdateProfile)
|
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 := router.Group("/api/admin")
|
||||||
admin.Use(handler.AdminMiddleware())
|
admin.Use(handler.AdminMiddleware())
|
||||||
@@ -57,6 +82,12 @@ func main() {
|
|||||||
admin.POST("/users", handler.CreateUser)
|
admin.POST("/users", handler.CreateUser)
|
||||||
admin.PUT("/users/:account", handler.UpdateUser)
|
admin.PUT("/users/:account", handler.UpdateUser)
|
||||||
admin.DELETE("/users/:account", handler.DeleteUser)
|
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")
|
port := os.Getenv("PORT")
|
||||||
if 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)...
|
echo ==^> Starting frontend (React)...
|
||||||
cd /d %ROOT_DIR%sproutgate-frontend
|
cd /d %ROOT_DIR%sproutgate-frontend
|
||||||
if not exist node_modules (
|
|
||||||
npm install
|
|
||||||
)
|
|
||||||
npm run dev
|
npm run dev
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user