认证系统更新

This commit is contained in:
aaron 2026-04-24 09:00:46 +08:00
parent 9254b4aede
commit a05ccfd1b4
27 changed files with 1092 additions and 582 deletions

View File

@ -5,3 +5,11 @@ ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
ASTOCK_ALERT_ENABLED=true
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
ASTOCK_ALERT_ENVIRONMENT=local
ASTOCK_ADMIN_USERNAME=75981230@qq.com
ASTOCK_ADMIN_EMAIL=75981230@qq.com
ASTOCK_ADMIN_PASSWORD=880803
ASTOCK_SMTP_HOST=gz-smtp.qcloudmail.com
ASTOCK_SMTP_PORT=465
ASTOCK_SMTP_USERNAME=noreply@xclaw.ren
ASTOCK_SMTP_PASSWORD=bAgKbd0Zgb10irHqKpoW
ASTOCK_SMTP_SENDER=noreply@xclaw.ren

View File

@ -1,4 +1,11 @@
ASTOCK_TUSHARE_TOKEN=your_tushare_token_here
ASTOCK_DEBUG=true
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
ASTOCK_DEEPSEEK_API_KEY=your_deepseek_api_key_here
ASTOCK_ADMIN_USERNAME=admin@example.com
ASTOCK_ADMIN_EMAIL=admin@example.com
ASTOCK_ADMIN_PASSWORD=change_me
ASTOCK_SMTP_HOST=gz-smtp.qcloudmail.com
ASTOCK_SMTP_PORT=465
ASTOCK_SMTP_USERNAME=noreply@example.com
ASTOCK_SMTP_PASSWORD=your_smtp_password
ASTOCK_SMTP_SENDER=noreply@example.com

View File

@ -1,238 +1,437 @@
"""认证 API
"""认证 API
登录密码修改用户管理管理员数据重置管理员
邮箱+密码登录
邀请码 + 邮箱验证码 + 密码注册
"""
import secrets
from __future__ import annotations
import logging
import random
import string
import re
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select, update, text, func
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select, update, text, func, delete
from app.config import settings
from app.core.auth import hash_password, verify_password, create_access_token
from app.core.deps import get_current_user, get_current_admin
from app.core.email import send_email, build_register_code_email
from app.db.database import get_db
from app.db.tables import users_table
from app.db.tables import users_table, email_verification_codes_table, invite_codes_table
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["auth"])
# ---------- Request/Response Models ----------
class LoginRequest(BaseModel):
username: str
email: str
password: str
class SendRegisterCodeRequest(BaseModel):
email: str
invite_code: str = Field(min_length=4, max_length=64)
class RegisterRequest(BaseModel):
email: str
invite_code: str = Field(min_length=4, max_length=64)
email_code: str = Field(min_length=6, max_length=6)
password: str = Field(min_length=6)
class ChangePasswordRequest(BaseModel):
old_password: str
new_password: str
new_password: str = Field(min_length=6)
class CreateUserRequest(BaseModel):
username: str
role: str = "user"
class CreateInviteCodeRequest(BaseModel):
code: str = Field(min_length=4, max_length=64)
description: str = ""
max_uses: int = 1
class DataResetRequest(BaseModel):
mode: str # "all", "recommendations", "date_range", "low_score"
before_date: str | None = None # for date_range mode, e.g. "2026-04-10"
min_score: int | None = None # for low_score mode, default 60
mode: str
before_date: str | None = None
min_score: int | None = None
# ---------- Public Endpoints ----------
def _normalize_email(email: str) -> str:
return str(email or "").strip().lower()
def _validate_email(email: str) -> str:
value = _normalize_email(email)
if not re.fullmatch(r"[^@\s]+@[^@\s]+\.[^@\s]+", value):
raise HTTPException(status_code=400, detail="邮箱格式错误")
return value
def _validate_password(password: str) -> None:
if len(password or "") < settings.auth_min_password_length:
raise HTTPException(status_code=400, detail=f"密码至少 {settings.auth_min_password_length}")
def _build_username_from_email(email: str) -> str:
return _normalize_email(email)
def _generate_email_code() -> str:
return "".join(random.choices(string.digits, k=6))
def _coerce_naive_datetime(value) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00")).replace(tzinfo=None)
return None
async def _get_user_by_email(email: str) -> dict | None:
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.email == _normalize_email(email))
)
user = result.mappings().first()
return dict(user) if user else None
async def _get_invite_code(code: str) -> dict | None:
async with get_db() as db:
result = await db.execute(
select(invite_codes_table).where(invite_codes_table.c.code == code.strip())
)
row = result.mappings().first()
return dict(row) if row else None
def _assert_invite_code_valid(invite_row: dict | None) -> None:
if not settings.invite_code_required:
return
if not invite_row:
raise HTTPException(status_code=400, detail="邀请码无效")
if not invite_row["is_active"]:
raise HTTPException(status_code=400, detail="邀请码已停用")
if invite_row["max_uses"] is not None and invite_row["used_count"] >= invite_row["max_uses"]:
raise HTTPException(status_code=400, detail="邀请码已用完")
expires_at = _coerce_naive_datetime(invite_row.get("expires_at"))
if expires_at and expires_at < datetime.utcnow():
raise HTTPException(status_code=400, detail="邀请码已过期")
async def _consume_invite_code(code: str) -> None:
if not settings.invite_code_required:
return
async with get_db() as db:
await db.execute(
update(invite_codes_table)
.where(invite_codes_table.c.code == code.strip())
.values(
used_count=invite_codes_table.c.used_count + 1,
updated_at=func.now(),
)
)
await db.commit()
async def _save_email_code(email: str, code: str, purpose: str) -> None:
expires_at = datetime.utcnow() + timedelta(minutes=settings.email_code_expiry_minutes)
async with get_db() as db:
await db.execute(
delete(email_verification_codes_table).where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.purpose == purpose,
)
)
await db.execute(
email_verification_codes_table.insert().values(
email=email,
code=code,
purpose=purpose,
expires_at=expires_at,
used=False,
)
)
await db.commit()
async def _assert_email_code_valid(email: str, code: str, purpose: str) -> None:
async with get_db() as db:
result = await db.execute(
select(email_verification_codes_table)
.where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.code == code,
email_verification_codes_table.c.purpose == purpose,
email_verification_codes_table.c.used == False, # noqa: E712
)
.order_by(email_verification_codes_table.c.id.desc())
)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=400, detail="邮箱验证码错误")
if row["expires_at"] < datetime.utcnow():
raise HTTPException(status_code=400, detail="邮箱验证码已过期")
async def _mark_email_code_used(email: str, code: str, purpose: str) -> None:
async with get_db() as db:
await db.execute(
update(email_verification_codes_table)
.where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.code == code,
email_verification_codes_table.c.purpose == purpose,
)
.values(used=True)
)
await db.commit()
@router.post("/login")
async def login(req: LoginRequest):
"""用户登录,返回 JWT token"""
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.username == req.username)
)
user = result.mappings().first()
email = _validate_email(req.email)
user = await _get_user_by_email(email)
if user is None or not user["is_active"]:
raise HTTPException(status_code=401, detail="用户名或密码错误")
raise HTTPException(status_code=401, detail="邮箱或密码错误")
if not verify_password(req.password, user["password_hash"]):
raise HTTPException(status_code=401, detail="用户名或密码错误")
raise HTTPException(status_code=401, detail="邮箱或密码错误")
token = create_access_token({"sub": str(user["id"]), "role": user["role"]})
return {
"token": token,
"user": {
"id": user["id"],
"username": user["username"],
"email": user["email"],
"role": user["role"],
},
}
# ---------- Authenticated Endpoints ----------
@router.post("/send-register-code")
async def send_register_code(req: SendRegisterCodeRequest):
email = _validate_email(req.email)
if await _get_user_by_email(email):
raise HTTPException(status_code=400, detail="邮箱已注册")
invite_row = await _get_invite_code(req.invite_code)
_assert_invite_code_valid(invite_row)
async with get_db() as db:
result = await db.execute(
select(email_verification_codes_table)
.where(
email_verification_codes_table.c.email == email,
email_verification_codes_table.c.purpose == "register",
)
.order_by(email_verification_codes_table.c.id.desc())
)
last_code = result.mappings().first()
if last_code and last_code["created_at"]:
created_at = _coerce_naive_datetime(last_code["created_at"])
delta = datetime.utcnow() - created_at if created_at else timedelta.max
if delta.total_seconds() < settings.email_code_cooldown_seconds:
raise HTTPException(status_code=429, detail=f"发送过于频繁,请 {settings.email_code_cooldown_seconds} 秒后再试")
code = _generate_email_code()
subject, html, text = build_register_code_email(code)
try:
send_email(subject=subject, to_email=email, html=html, text=text)
except Exception as e:
logger.error("发送注册验证码失败: %s", e)
raise HTTPException(status_code=500, detail="验证码发送失败")
await _save_email_code(email, code, "register")
return {"message": "验证码已发送,请查收邮箱"}
@router.post("/register")
async def register(req: RegisterRequest):
email = _validate_email(req.email)
_validate_password(req.password)
if await _get_user_by_email(email):
raise HTTPException(status_code=400, detail="邮箱已注册")
invite_row = await _get_invite_code(req.invite_code)
_assert_invite_code_valid(invite_row)
await _assert_email_code_valid(email, req.email_code.strip(), "register")
username = _build_username_from_email(email)
async with get_db() as db:
await db.execute(
users_table.insert().values(
username=username,
email=email,
password_hash=hash_password(req.password),
role="user",
is_active=True,
invite_code_used=req.invite_code.strip(),
)
)
await db.commit()
await _mark_email_code_used(email, req.email_code.strip(), "register")
await _consume_invite_code(req.invite_code)
return {"message": "注册成功,请使用邮箱和密码登录"}
@router.get("/me")
async def get_me(current_user: dict = Depends(get_current_user)):
"""获取当前用户信息"""
return {
"id": current_user["id"],
"username": current_user["username"],
"email": current_user["email"],
"role": current_user["role"],
"is_active": current_user["is_active"],
}
@router.post("/change-password")
async def change_password(
req: ChangePasswordRequest,
current_user: dict = Depends(get_current_user),
):
"""修改自己的密码"""
async def change_password(req: ChangePasswordRequest, current_user: dict = Depends(get_current_user)):
_validate_password(req.new_password)
if not verify_password(req.old_password, current_user["password_hash"]):
raise HTTPException(status_code=400, detail="旧密码错误")
new_hash = hash_password(req.new_password)
async with get_db() as db:
await db.execute(
update(users_table)
.where(users_table.c.id == current_user["id"])
.values(password_hash=new_hash)
.values(password_hash=hash_password(req.new_password), updated_at=func.now())
)
await db.commit()
return {"message": "密码修改成功"}
# ---------- Admin Endpoints ----------
@router.get("/users")
async def list_users(admin: dict = Depends(get_current_admin)):
"""列出所有用户(管理员)"""
async with get_db() as db:
result = await db.execute(
select(users_table).order_by(users_table.c.id)
)
result = await db.execute(select(users_table).order_by(users_table.c.id))
rows = result.mappings().all()
return [
{
"id": r["id"],
"username": r["username"],
"email": r["email"],
"role": r["role"],
"is_active": r["is_active"],
"invite_code_used": r.get("invite_code_used") or "",
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
for r in rows
]
@router.post("/users")
async def create_user(req: CreateUserRequest, admin: dict = Depends(get_current_admin)):
"""创建新用户(管理员),自动生成随机密码"""
# 检查用户名是否已存在
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.username == req.username)
)
if result.first():
raise HTTPException(status_code=400, detail="用户名已存在")
if req.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="角色必须是 admin 或 user")
# 生成 12 位随机密码
raw_password = secrets.token_urlsafe(9)
password_hash = hash_password(raw_password)
await db.execute(
users_table.insert().values(
username=req.username,
password_hash=password_hash,
role=req.role,
)
)
await db.commit()
logger.info(f"管理员 {admin['username']} 创建了用户 {req.username} ({req.role})")
return {
"username": req.username,
"password": raw_password,
"role": req.role,
"message": "请妥善保管密码,此密码仅显示一次",
}
@router.delete("/users/{user_id}")
async def disable_user(user_id: int, admin: dict = Depends(get_current_admin)):
"""禁用用户(软删除)"""
if user_id == admin["id"]:
raise HTTPException(status_code=400, detail="不能禁用自己")
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.id == user_id)
)
result = await db.execute(select(users_table).where(users_table.c.id == user_id))
user = result.mappings().first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
await db.execute(
update(users_table)
.where(users_table.c.id == user_id)
.values(is_active=False)
update(users_table).where(users_table.c.id == user_id).values(is_active=False, updated_at=func.now())
)
await db.commit()
return {"message": f"用户 {user['username']} 已禁用"}
return {"message": f"用户 {user['email']} 已禁用"}
@router.post("/users/{user_id}/reset-password")
async def reset_password(user_id: int, admin: dict = Depends(get_current_admin)):
"""重置用户密码(管理员),生成新的随机密码"""
new_password = "".join(random.choices(string.ascii_letters + string.digits, k=10))
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.id == user_id)
)
result = await db.execute(select(users_table).where(users_table.c.id == user_id))
user = result.mappings().first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
raw_password = secrets.token_urlsafe(9)
password_hash = hash_password(raw_password)
await db.execute(
update(users_table)
.where(users_table.c.id == user_id)
.values(password_hash=password_hash)
.values(password_hash=hash_password(new_password), updated_at=func.now())
)
await db.commit()
return {
"username": user["username"],
"password": raw_password,
"email": user["email"],
"password": new_password,
"message": "请妥善保管新密码,此密码仅显示一次",
}
# ---------- Data Reset Endpoints (Admin Only) ----------
@router.get("/invite-codes")
async def list_invite_codes(admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(select(invite_codes_table).order_by(invite_codes_table.c.id.desc()))
rows = result.mappings().all()
return [
{
"id": r["id"],
"code": r["code"],
"description": r["description"] or "",
"is_active": r["is_active"],
"max_uses": r["max_uses"],
"used_count": r["used_count"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
}
for r in rows
]
@router.post("/invite-codes")
async def create_invite_code(req: CreateInviteCodeRequest, admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(select(invite_codes_table).where(invite_codes_table.c.code == req.code.strip()))
if result.first():
raise HTTPException(status_code=400, detail="邀请码已存在")
await db.execute(
invite_codes_table.insert().values(
code=req.code.strip(),
description=req.description.strip(),
max_uses=max(1, req.max_uses),
used_count=0,
is_active=True,
created_by=admin["id"],
)
)
await db.commit()
return {"message": "邀请码创建成功", "code": req.code.strip()}
@router.post("/invite-codes/{invite_id}/toggle")
async def toggle_invite_code(invite_id: int, admin: dict = Depends(get_current_admin)):
async with get_db() as db:
result = await db.execute(select(invite_codes_table).where(invite_codes_table.c.id == invite_id))
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="邀请码不存在")
await db.execute(
update(invite_codes_table)
.where(invite_codes_table.c.id == invite_id)
.values(is_active=not row["is_active"], updated_at=func.now())
)
await db.commit()
return {"message": "邀请码状态已更新"}
@router.get("/data-stats")
async def get_data_stats(admin: dict = Depends(get_current_admin)):
"""获取数据统计(管理员)"""
async with get_db() as db:
rec_count = (await db.execute(text("SELECT COUNT(*) FROM recommendations"))).scalar() or 0
track_count = (await db.execute(text("SELECT COUNT(*) FROM recommendation_tracking"))).scalar() or 0
sector_count = (await db.execute(text("SELECT COUNT(*) FROM sector_heat"))).scalar() or 0
temp_count = (await db.execute(text("SELECT COUNT(*) FROM market_temperature"))).scalar() or 0
low_score = (await db.execute(text("SELECT COUNT(*) FROM recommendations WHERE score < 60"))).scalar() or 0
# 最新日期
latest_rec = (await db.execute(text("SELECT MAX(date(created_at)) FROM recommendations"))).scalar() or ""
earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or ""
return {
"recommendations": rec_count,
"tracking": track_count,
@ -246,85 +445,39 @@ async def get_data_stats(admin: dict = Depends(get_current_admin)):
@router.post("/data-reset")
async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_admin)):
"""数据重置(管理员)
mode:
- "all": 清除所有业务数据推荐跟踪板块热度市场温度诊断
- "recommendations": 清除推荐记录和跟踪数据保留板块和市场温度
- "date_range": 清除指定日期之前的数据
- "low_score": 清除低分推荐score < min_score和过期跟踪数据
"""
deleted = {}
deleted: dict[str, int] = {}
async with get_db() as db:
if req.mode == "all":
# 清除所有业务数据(保留用户)
result = await db.execute(text("DELETE FROM recommendation_tracking"))
deleted["tracking"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM recommendations"))
deleted["recommendations"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM sector_heat"))
deleted["sector_heat"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM market_temperature"))
deleted["market_temperature"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM stock_diagnoses"))
deleted["stock_diagnoses"] = result.rowcount or 0
for table in ["recommendation_tracking", "recommendations", "sector_heat", "market_temperature", "stock_diagnoses"]:
result = await db.execute(text(f"DELETE FROM {table}"))
deleted[table] = result.rowcount or 0
elif req.mode == "recommendations":
result = await db.execute(text("DELETE FROM recommendation_tracking"))
deleted["tracking"] = result.rowcount or 0
result = await db.execute(text("DELETE FROM recommendations"))
deleted["recommendations"] = result.rowcount or 0
for table in ["recommendation_tracking", "recommendations"]:
result = await db.execute(text(f"DELETE FROM {table}"))
deleted[table] = result.rowcount or 0
elif req.mode == "date_range":
if not req.before_date:
raise HTTPException(status_code=400, detail="date_range 模式需要 before_date 参数")
# 删除 before_date 之前的推荐和跟踪
result = await db.execute(
text("DELETE FROM recommendation_tracking WHERE track_date < :bd"),
{"bd": req.before_date}
)
result = await db.execute(text("DELETE FROM recommendation_tracking WHERE track_date < :bd"), {"bd": req.before_date})
deleted["tracking"] = result.rowcount or 0
result = await db.execute(
text("DELETE FROM recommendations WHERE date(created_at) < :bd"),
{"bd": req.before_date}
)
result = await db.execute(text("DELETE FROM recommendations WHERE date(created_at) < :bd"), {"bd": req.before_date})
deleted["recommendations"] = result.rowcount or 0
result = await db.execute(
text("DELETE FROM sector_heat WHERE trade_date < :bd"),
{"bd": req.before_date}
)
result = await db.execute(text("DELETE FROM sector_heat WHERE trade_date < :bd"), {"bd": req.before_date})
deleted["sector_heat"] = result.rowcount or 0
result = await db.execute(
text("DELETE FROM market_temperature WHERE trade_date < :bd"),
{"bd": req.before_date}
)
result = await db.execute(text("DELETE FROM market_temperature WHERE trade_date < :bd"), {"bd": req.before_date})
deleted["market_temperature"] = result.rowcount or 0
elif req.mode == "low_score":
threshold = req.min_score or 60
# 删除低分推荐及其跟踪数据
result = await db.execute(
text("DELETE FROM recommendation_tracking WHERE recommendation_id IN "
"(SELECT id FROM recommendations WHERE score < :ms)"),
{"ms": threshold}
text("DELETE FROM recommendation_tracking WHERE recommendation_id IN (SELECT id FROM recommendations WHERE score < :ms)"),
{"ms": threshold},
)
deleted["tracking"] = result.rowcount or 0
result = await db.execute(
text("DELETE FROM recommendations WHERE score < :ms"),
{"ms": threshold}
)
result = await db.execute(text("DELETE FROM recommendations WHERE score < :ms"), {"ms": threshold})
deleted["recommendations"] = result.rowcount or 0
else:
raise HTTPException(status_code=400, detail=f"不支持的模式: {req.mode}")
await db.commit()
logger.info(f"管理员 {admin['username']} 执行数据重置: mode={req.mode}, deleted={deleted}")
return {
"status": "ok",
"mode": req.mode,
"deleted": deleted,
}
logger.info("管理员 %s 执行数据重置: mode=%s deleted=%s", admin["email"], req.mode, deleted)
return {"status": "ok", "mode": req.mode, "deleted": deleted}

View File

@ -76,8 +76,23 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
# 默认管理员(首次启动自动创建)
admin_username: str = "admin"
admin_password: str = "admin123"
admin_username: str = "75981230@qq.com"
admin_email: str = "75981230@qq.com"
admin_password: str = "880803"
# 认证体系
auth_min_password_length: int = 6
invite_code_required: bool = True
email_code_expiry_minutes: int = 10
email_code_cooldown_seconds: int = 60
# SMTP
smtp_host: str = ""
smtp_port: int = 465
smtp_username: str = ""
smtp_password: str = ""
smtp_sender: str = ""
smtp_use_ssl: bool = True
model_config = {"env_file": ".env", "env_prefix": "ASTOCK_"}

50
backend/app/core/email.py Normal file
View File

@ -0,0 +1,50 @@
"""SMTP 邮件发送工具。"""
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from app.config import settings
def send_email(subject: str, to_email: str, html: str, text: str | None = None) -> None:
if not settings.smtp_host or not settings.smtp_sender or not settings.smtp_username or not settings.smtp_password:
raise RuntimeError("SMTP 配置不完整")
message = EmailMessage()
message["Subject"] = subject
message["From"] = settings.smtp_sender
message["To"] = to_email
message.set_content(text or subject)
message.add_alternative(html, subtype="html")
if settings.smtp_use_ssl:
with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port) as server:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
else:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server:
server.starttls()
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
def build_register_code_email(code: str) -> tuple[str, str]:
subject = "AStock Agent 注册验证码"
html = f"""
<html>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #111827;">
<div style="max-width: 520px; margin: 0 auto; padding: 24px;">
<h2 style="margin: 0 0 12px;">注册验证码</h2>
<p style="margin: 0 0 16px; color: #4b5563;">你正在注册 AStock Agent请使用以下验证码完成注册</p>
<div style="font-size: 28px; font-weight: 700; letter-spacing: 6px; padding: 14px 18px; background: #fff7ed; color: #b45309; border-radius: 12px; display: inline-block;">
{code}
</div>
<p style="margin: 16px 0 0; color: #6b7280;">验证码 {settings.email_code_expiry_minutes} 分钟内有效请勿泄露给他人</p>
</div>
</body>
</html>
"""
text = f"你正在注册 AStock Agent验证码为{code}{settings.email_code_expiry_minutes} 分钟内有效。"
return subject, html, text

View File

@ -77,6 +77,8 @@ async def init_db():
"ALTER TABLE recommendation_tracking ADD COLUMN days_since_recommendation INTEGER DEFAULT 0",
"ALTER TABLE recommendation_tracking ADD COLUMN close_reason TEXT DEFAULT ''",
"ALTER TABLE recommendation_tracking ADD COLUMN review_note TEXT DEFAULT ''",
"ALTER TABLE users ADD COLUMN email TEXT",
"ALTER TABLE users ADD COLUMN invite_code_used TEXT DEFAULT ''",
"ALTER TABLE stock_diagnoses ADD COLUMN diagnosis_mode TEXT DEFAULT 'entry'",
"ALTER TABLE user_watchlists ADD COLUMN note TEXT DEFAULT ''",
"ALTER TABLE user_watchlists ADD COLUMN watch_group TEXT DEFAULT 'observe'",
@ -98,3 +100,12 @@ async def init_db():
)
except Exception:
pass # 列已存在,忽略
try:
await conn.execute(
__import__("sqlalchemy").text(
"UPDATE users SET email = username WHERE email IS NULL OR email = ''"
)
)
except Exception:
pass

View File

@ -110,9 +110,36 @@ users_table = Table(
"users", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("username", Text, nullable=False, unique=True),
Column("email", Text, nullable=False, unique=True),
Column("password_hash", Text, nullable=False),
Column("role", Text, default="user"),
Column("is_active", Boolean, default=True),
Column("invite_code_used", Text, default=""),
Column("created_at", DateTime, server_default=func.now()),
Column("updated_at", DateTime, server_default=func.now()),
)
email_verification_codes_table = Table(
"email_verification_codes", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("email", Text, nullable=False),
Column("code", Text, nullable=False),
Column("purpose", Text, nullable=False, default="register"),
Column("expires_at", DateTime, nullable=False),
Column("used", Boolean, default=False),
Column("created_at", DateTime, server_default=func.now()),
)
invite_codes_table = Table(
"invite_codes", metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("code", Text, nullable=False, unique=True),
Column("description", Text, default=""),
Column("is_active", Boolean, default=True),
Column("max_uses", Integer, default=1),
Column("used_count", Integer, default=0),
Column("expires_at", DateTime, default=None),
Column("created_by", Integer, default=None),
Column("created_at", DateTime, server_default=func.now()),
Column("updated_at", DateTime, server_default=func.now()),
)

View File

@ -22,27 +22,56 @@ logger = logging.getLogger(__name__)
async def ensure_admin_exists():
"""如果没有管理员用户,自动创建默认管理员"""
from sqlalchemy import select, insert
"""确保配置中的管理员账号存在并可用。"""
from sqlalchemy import select, insert, update
from app.db.database import get_db
from app.db.tables import users_table
from app.db.tables import users_table, invite_codes_table
from app.core.auth import hash_password
async with get_db() as db:
result = await db.execute(
select(users_table).where(users_table.c.role == "admin")
configured_admin = await db.execute(
select(users_table).where(users_table.c.email == settings.admin_email)
)
if result.first() is None:
admin_row = configured_admin.mappings().first()
if admin_row is None:
await db.execute(
insert(users_table).values(
username=settings.admin_username,
email=settings.admin_email,
password_hash=hash_password(settings.admin_password),
role="admin",
is_active=True,
)
)
await db.commit()
logger.info(f"默认管理员用户 '{settings.admin_username}' 已创建")
logger.info(f"默认管理员用户 '{settings.admin_email}' 已创建")
elif admin_row["role"] != "admin" or not admin_row["is_active"]:
await db.execute(
update(users_table)
.where(users_table.c.id == admin_row["id"])
.values(
role="admin",
is_active=True,
)
)
await db.commit()
logger.info("默认管理员用户 '%s' 已修正为可用管理员", settings.admin_email)
invite = await db.execute(
select(invite_codes_table).where(invite_codes_table.c.code == "ASTOCK-ACCESS")
)
if invite.first() is None:
await db.execute(
insert(invite_codes_table).values(
code="ASTOCK-ACCESS",
description="默认注册邀请码",
is_active=True,
max_uses=999999,
used_count=0,
)
)
await db.commit()
@asynccontextmanager

Binary file not shown.

View File

@ -1,9 +1,14 @@
{
"pages": {
"/(auth)/layout": [
"/(public)/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/layout.js"
"static/chunks/app/(public)/page.js"
],
"/(public)/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/layout.js"
],
"/layout": [
"static/chunks/webpack.js",
@ -11,45 +16,25 @@
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/(public)/login/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/login/page.js"
],
"/(auth)/dashboard/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/dashboard/page.js"
],
"/(auth)/recommendations/page": [
"/(auth)/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/recommendations/page.js"
],
"/(auth)/stock/[code]/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/stock/[code]/page.js"
],
"/(auth)/strategy/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/strategy/page.js"
],
"/(auth)/sectors/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/sectors/page.js"
"static/chunks/app/(auth)/layout.js"
],
"/(auth)/settings/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/settings/page.js"
],
"/(auth)/watchlists/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/watchlists/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
]
}
}

View File

@ -2,9 +2,7 @@
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [
"static/chunks/react-refresh.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
@ -15,16 +13,7 @@
"static/chunks/main-app.js"
],
"pages": {
"/_app": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
"/_app": []
},
"ampFirstPages": []
}

View File

@ -1,20 +1 @@
{
"app/(auth)/sectors/page.tsx -> echarts": {
"id": "app/(auth)/sectors/page.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/capital-flow.tsx -> echarts": {
"id": "components/capital-flow.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
},
"components/kline-chart.tsx -> echarts": {
"id": "components/kline-chart.tsx -> echarts",
"files": [
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
]
}
}
{}

View File

@ -1,8 +1,6 @@
{
"/_not-found/page": "app/_not-found/page.js",
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
"/(auth)/settings/page": "app/(auth)/settings/page.js",
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js"
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(public)/page": "app/(public)/page.js",
"/(public)/login/page": "app/(public)/login/page.js"
}

View File

@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [
"static/chunks/react-refresh.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [],
"rootMainFiles": [
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
"static/chunks/main-app.js"
],
"pages": {
"/_app": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
]
"/_app": []
},
"ampFirstPages": []
};

View File

@ -1 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"app/(auth)/sectors/page.tsx -> echarts\":{\"id\":\"app/(auth)/sectors/page.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
self.__REACT_LOADABLE_MANIFEST="{}"

View File

@ -1,5 +1 @@
{
"/_app": "pages/_app.js",
"/_error": "pages/_error.js",
"/_document": "pages/_document.js"
}
{}

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "qGqEEZzUFqZxeEgbiNEfbm7ophOEC3RaVTABPCm+KZ8="
"encryptionKey": "mF8eeADKwF8ZgzkVb17uMLBKIP2Av/vT46Y1sYJ8rsk="
}

File diff suppressed because one or more lines are too long

View File

@ -4,15 +4,18 @@ import { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/hooks/use-auth";
import {
listUsersAPI,
createUserAPI,
disableUserAPI,
resetPasswordAPI,
listInviteCodesAPI,
createInviteCodeAPI,
toggleInviteCodeAPI,
getDataStatsAPI,
dataResetAPI,
getErrorLogsAPI,
clearErrorLogsAPI,
getSystemStatusAPI,
type UserItem,
type InviteCodeItem,
type DataStats,
type ErrorLog,
type SystemStatus,
@ -24,20 +27,23 @@ export default function UsersPage() {
const { user: currentUser } = useAuth();
const [tab, setTab] = useState<Tab>("users");
// ── Users state ──
const [users, setUsers] = useState<UserItem[]>([]);
const [inviteCodes, setInviteCodes] = useState<InviteCodeItem[]>([]);
const [loading, setLoading] = useState(true);
const [inviteLoading, setInviteLoading] = useState(true);
const [error, setError] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newRole, setNewRole] = useState("user");
const [createLoading, setCreateLoading] = useState(false);
const [createError, setCreateError] = useState("");
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
const [resetResult, setResetResult] = useState<{ username: string; password: string } | null>(null);
const [showCreateInvite, setShowCreateInvite] = useState(false);
const [inviteCode, setInviteCode] = useState("");
const [inviteDescription, setInviteDescription] = useState("");
const [inviteMaxUses, setInviteMaxUses] = useState("10");
const [createInviteLoading, setCreateInviteLoading] = useState(false);
const [createInviteError, setCreateInviteError] = useState("");
const [createdInviteCode, setCreatedInviteCode] = useState<string | null>(null);
const [resetResult, setResetResult] = useState<{ email: string; password: string } | null>(null);
const [copied, setCopied] = useState(false);
// ── Data reset state ──
const [dataStats, setDataStats] = useState<DataStats | null>(null);
const [resetMode, setResetMode] = useState<"all" | "recommendations" | "date_range" | "low_score">("low_score");
const [beforeDate, setBeforeDate] = useState("");
@ -45,7 +51,6 @@ export default function UsersPage() {
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
const [confirmReset, setConfirmReset] = useState(false);
// ── Logs state ──
const [logs, setLogs] = useState<ErrorLog[]>([]);
const [logsTotal, setLogsTotal] = useState(0);
const [logSources, setLogSources] = useState<string[]>([]);
@ -57,8 +62,8 @@ export default function UsersPage() {
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
function copyCredential(username: string, password: string) {
const text = `用户名:${username}\n密码${password}`;
function copyCredential(account: string, password: string) {
const text = `邮箱:${account}\n密码${password}`;
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@ -76,12 +81,23 @@ export default function UsersPage() {
}
}, []);
const fetchInviteCodes = useCallback(async () => {
try {
const data = await listInviteCodesAPI();
setInviteCodes(data);
} catch {
setError("加载邀请码失败");
} finally {
setInviteLoading(false);
}
}, []);
const fetchStats = useCallback(async () => {
try {
const stats = await getDataStatsAPI();
setDataStats(stats);
} catch {
// silently fail
// ignore
}
}, []);
@ -94,7 +110,7 @@ export default function UsersPage() {
setLogSources(result.sources);
setLogLevels(result.levels);
} catch {
// silently fail
// ignore
} finally {
setLogsLoading(false);
}
@ -105,17 +121,18 @@ export default function UsersPage() {
const status = await getSystemStatusAPI();
setSystemStatus(status);
} catch {
// silently fail
// ignore
}
}, []);
useEffect(() => {
if (currentUser?.role === "admin") {
fetchUsers();
fetchInviteCodes();
fetchStats();
fetchSystemStatus();
}
}, [currentUser, fetchUsers, fetchStats, fetchSystemStatus]);
}, [currentUser, fetchUsers, fetchInviteCodes, fetchStats, fetchSystemStatus]);
useEffect(() => {
if (currentUser?.role === "admin" && tab === "logs") {
@ -131,24 +148,35 @@ export default function UsersPage() {
);
}
async function handleCreate(e: React.FormEvent) {
async function handleCreateInvite(e: React.FormEvent) {
e.preventDefault();
setCreateError("");
if (!newUsername.trim()) {
setCreateError("请输入用户名");
setCreateInviteError("");
const normalizedCode = inviteCode.trim().toUpperCase();
const maxUses = Number(inviteMaxUses);
if (!normalizedCode) {
setCreateInviteError("请输入邀请码");
return;
}
setCreateLoading(true);
if (!Number.isFinite(maxUses) || maxUses < 1) {
setCreateInviteError("邀请人数上限至少为 1");
return;
}
setCreateInviteLoading(true);
try {
const result = await createUserAPI(newUsername.trim(), newRole);
setCreatedResult({ username: result.username, password: result.password });
setNewUsername("");
setNewRole("user");
const result = await createInviteCodeAPI(normalizedCode, inviteDescription.trim(), maxUses);
setCreatedInviteCode(result.code);
setInviteCode("");
setInviteDescription("");
setInviteMaxUses("10");
fetchInviteCodes();
fetchUsers();
} catch (err) {
setCreateError(err instanceof Error ? err.message : "创建失败");
setCreateInviteError(err instanceof Error ? err.message : "创建失败");
} finally {
setCreateLoading(false);
setCreateInviteLoading(false);
}
}
@ -164,13 +192,23 @@ export default function UsersPage() {
async function handleResetPassword(userId: number) {
try {
const result = await resetPasswordAPI(userId);
setResetResult({ username: result.username, password: result.password });
setCopied(false);
setResetResult({ email: result.email, password: result.password });
fetchUsers();
} catch (err) {
alert(err instanceof Error ? err.message : "操作失败");
}
}
async function handleToggleInvite(inviteId: number) {
try {
await toggleInviteCodeAPI(inviteId);
fetchInviteCodes();
} catch (err) {
alert(err instanceof Error ? err.message : "操作失败");
}
}
async function handleDataReset() {
setConfirmReset(false);
setResetLoading(true);
@ -205,14 +243,13 @@ export default function UsersPage() {
}
const tabs: { key: Tab; label: string }[] = [
{ key: "users", label: "用户管理" },
{ key: "users", label: "用户与邀请码" },
{ key: "data", label: "数据管理" },
{ key: "logs", label: "系统日志" },
];
return (
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
{/* Header + Tabs */}
<div className="max-w-6xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
<div className="animate-fade-in-up">
<h1 className="text-xl font-semibold tracking-tight"></h1>
<div className="flex gap-1.5 mt-4 overflow-x-auto pb-1">
@ -232,95 +269,235 @@ export default function UsersPage() {
</div>
</div>
{/* ── Tab: Users ── */}
{tab === "users" && (
<>
<div className="flex items-center justify-between animate-fade-in-up">
<button
onClick={() => { setShowCreate(true); setCreateError(""); setCreatedResult(null); }}
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
+
</button>
</div>
{error && <p className="text-sm text-amber-400/80 animate-fade-in-up">{error}</p>}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-16" />
))}
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.9fr] gap-6">
<section className="glass-card-static p-4 rounded-2xl space-y-4 animate-fade-in-up">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
<p className="text-xs text-text-muted mt-1"></p>
</div>
<span className="text-xs text-text-muted">{users.length} </span>
</div>
) : (
<div className="space-y-2 animate-fade-in-up delay-75">
{users.map((u) => (
<div
key={u.id}
className={`glass-card p-4 rounded-xl flex items-center justify-between gap-4 ${
!u.is_active ? "opacity-50" : ""
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-text-primary truncate">{u.username}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "admin" ? "bg-amber-500/10 text-amber-400/80" : "bg-surface-3 text-text-muted"}`}>{u.role}</span>
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80"></span>}
{error && <p className="text-sm text-amber-400/80">{error}</p>}
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-24" />
))}
</div>
) : users.length === 0 ? (
<div className="glass-card-static p-8 rounded-xl text-center">
<p className="text-sm text-text-muted"></p>
</div>
) : (
<div className="space-y-2">
{users.map((u) => (
<div
key={u.id}
className={`glass-card p-4 rounded-xl flex items-start justify-between gap-4 ${!u.is_active ? "opacity-50" : ""}`}
>
<div className="flex items-start gap-3 min-w-0">
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
{(u.email || u.username).charAt(0).toUpperCase()}
</div>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-text-primary truncate">{u.email || u.username}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "admin" ? "bg-amber-500/10 text-amber-400/80" : "bg-surface-3 text-text-muted"}`}>
{u.role}
</span>
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80"></span>}
</div>
<p className="text-xs text-text-muted truncate">
{u.invite_code_used || "系统初始化/手动创建"}
</p>
{u.created_at && (
<p className="text-xs text-text-muted"> {new Date(u.created_at).toLocaleString("zh-CN")}</p>
)}
</div>
{u.created_at && <p className="text-xs text-text-muted mt-0.5"> {new Date(u.created_at).toLocaleDateString("zh-CN")}</p>}
</div>
{u.id !== currentUser!.id && (
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleResetPassword(u.id)}
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"
>
</button>
{u.is_active ? (
<button
onClick={() => handleDisable(u.id)}
className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all"
>
</button>
) : (
<span className="text-xs text-text-muted"></span>
)}
</div>
)}
</div>
{u.id !== currentUser!.id && (
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleResetPassword(u.id)} className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"></button>
{u.is_active ? (
<button onClick={() => handleDisable(u.id)} className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all"></button>
) : (
<span className="text-xs text-text-muted"></span>
)}
</div>
)}
</div>
))}
{users.length === 0 && <div className="glass-card-static p-8 rounded-xl text-center"><p className="text-sm text-text-muted"></p></div>}
</div>
)}
))}
</div>
)}
</section>
{/* Create User Dialog */}
{showCreate && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
{createdResult ? (
<div className="space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm"><span className="text-text-muted"></span><span className="text-text-primary font-medium">{createdResult.username}</span></div>
<div className="flex justify-between text-sm items-center"><span className="text-text-muted"></span><span className="text-amber-400 font-mono text-xs">{createdResult.password}</span></div>
<section className="glass-card-static p-4 rounded-2xl space-y-4 animate-fade-in-up delay-75">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold text-text-primary"></h2>
<p className="text-xs text-text-muted mt-1"></p>
</div>
<button
onClick={() => {
setShowCreateInvite(true);
setCreatedInviteCode(null);
setCreateInviteError("");
}}
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
+
</button>
</div>
{inviteLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />
))}
</div>
) : inviteCodes.length === 0 ? (
<div className="glass-card-static p-8 rounded-xl text-center">
<p className="text-sm text-text-muted"></p>
</div>
) : (
<div className="space-y-2">
{inviteCodes.map((item) => {
const isExhausted = item.max_uses > 0 && item.used_count >= item.max_uses;
return (
<div key={item.id} className="glass-card p-4 rounded-xl space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-text-primary">{item.code}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${item.is_active ? "bg-emerald-500/10 text-emerald-400" : "bg-surface-3 text-text-muted"}`}>
{item.is_active ? "启用中" : "已停用"}
</span>
{isExhausted && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400"></span>}
</div>
<p className="text-xs text-text-muted mt-1">{item.description || "无说明"}</p>
</div>
<button
onClick={() => handleToggleInvite(item.id)}
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"
>
{item.is_active ? "停用" : "启用"}
</button>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50"></div>
<div className="text-sm font-mono tabular-nums text-text-primary">{item.used_count}</div>
</div>
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50"></div>
<div className="text-sm font-mono tabular-nums text-text-secondary">{item.max_uses}</div>
</div>
<div className="bg-surface-1 rounded-lg px-3 py-2">
<div className="text-[10px] text-text-muted/50"></div>
<div className="text-sm font-mono tabular-nums text-amber-400">
{Math.max(item.max_uses - item.used_count, 0)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</section>
{showCreateInvite && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateInvite(false)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
{createdInviteCode ? (
<div className="space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm">
<span className="text-text-muted"></span>
<span className="text-text-primary font-medium">{createdInviteCode}</span>
</div>
</div>
<p className="text-xs text-amber-400/60"></p>
<div className="flex gap-3">
<button onClick={() => copyCredential(createdResult.username, createdResult.password)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all">{copied ? "已复制" : "一键复制"}</button>
<button onClick={() => setShowCreate(false)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"></button>
<button
onClick={() => {
navigator.clipboard.writeText(createdInviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
{copied ? "已复制" : "复制邀请码"}
</button>
<button
onClick={() => {
setShowCreateInvite(false);
setCopied(false);
}}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
>
</button>
</div>
</div>
) : (
<>
<h3 className="text-base font-semibold text-text-primary mb-5"></h3>
<form onSubmit={handleCreate} className="space-y-3">
<input type="text" placeholder="用户名" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" />
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
<option value="user" className="bg-bg-card text-text-primary"></option>
<option value="admin" className="bg-bg-card text-text-primary"></option>
</select>
{createError && <p className="text-xs text-amber-400/80">{createError}</p>}
<h3 className="text-base font-semibold text-text-primary mb-5"></h3>
<form onSubmit={handleCreateInvite} className="space-y-3">
<input
type="text"
placeholder="邀请码,例如 ASTOCK-VIP-01"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/>
<input
type="text"
placeholder="说明,例如 内测用户"
value={inviteDescription}
onChange={(e) => setInviteDescription(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/>
<input
type="number"
min="1"
placeholder="邀请人数上限"
value={inviteMaxUses}
onChange={(e) => setInviteMaxUses(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
/>
{createInviteError && <p className="text-xs text-amber-400/80">{createInviteError}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={() => setShowCreate(false)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"></button>
<button type="submit" disabled={createLoading} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50">{createLoading ? "创建中..." : "创建"}</button>
<button
type="button"
onClick={() => setShowCreateInvite(false)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
>
</button>
<button
type="submit"
disabled={createInviteLoading}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50"
>
{createInviteLoading ? "创建中..." : "创建"}
</button>
</div>
</form>
</>
@ -329,28 +506,42 @@ export default function UsersPage() {
</div>
)}
{/* Reset Password Result Dialog */}
{resetResult && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} />
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card space-y-4">
<h3 className="text-base font-semibold text-text-primary"></h3>
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
<div className="flex justify-between text-sm"><span className="text-text-muted"></span><span className="text-text-primary font-medium">{resetResult.username}</span></div>
<div className="flex justify-between text-sm items-center"><span className="text-text-muted"></span><span className="text-amber-400 font-mono text-xs">{resetResult.password}</span></div>
<div className="flex justify-between text-sm">
<span className="text-text-muted"></span>
<span className="text-text-primary font-medium">{resetResult.email}</span>
</div>
<div className="flex justify-between text-sm items-center">
<span className="text-text-muted"></span>
<span className="text-amber-400 font-mono text-xs">{resetResult.password}</span>
</div>
</div>
<p className="text-xs text-amber-400/60"></p>
<div className="flex gap-3">
<button onClick={() => copyCredential(resetResult.username, resetResult.password)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all">{copied ? "已复制" : "一键复制"}</button>
<button onClick={() => setResetResult(null)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"></button>
<button
onClick={() => copyCredential(resetResult.email, resetResult.password)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
>
{copied ? "已复制" : "一键复制"}
</button>
<button
onClick={() => setResetResult(null)}
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
>
</button>
</div>
</div>
</div>
)}
</>
</div>
)}
{/* ── Tab: Data ── */}
{tab === "data" && dataStats && (
<div className="glass-card-static p-4 rounded-xl animate-fade-in-up">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"> & </h2>
@ -395,10 +586,8 @@ export default function UsersPage() {
</div>
)}
{/* ── Tab: Logs ── */}
{tab === "logs" && (
<div className="space-y-4 animate-fade-in-up">
{/* System Status */}
{systemStatus && (
<div className="glass-card-static p-4 rounded-xl">
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3"></h2>
@ -427,7 +616,6 @@ export default function UsersPage() {
</div>
)}
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap">
<select value={logFilterSource} onChange={(e) => setLogFilterSource(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
<option value=""></option>
@ -453,7 +641,6 @@ export default function UsersPage() {
<span className="text-xs text-text-muted ml-auto">{logsTotal} </span>
</div>
{/* Error list */}
{logsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />)}
@ -490,4 +677,4 @@ export default function UsersPage() {
)}
</div>
);
}
}

View File

@ -1,29 +1,53 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { registerAPI, sendRegisterCodeAPI } from "@/lib/api";
type Tab = "login" | "register";
export default function LoginPage() {
const [username, setUsername] = useState("");
const router = useRouter();
const { login } = useAuth();
const [tab, setTab] = useState<Tab>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
const [registerEmail, setRegisterEmail] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [emailCode, setEmailCode] = useState("");
const [registerPassword, setRegisterPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [registerSubmitting, setRegisterSubmitting] = useState(false);
const [registerError, setRegisterError] = useState("");
const [registerSuccess, setRegisterSuccess] = useState("");
const [sendingCode, setSendingCode] = useState(false);
const [cooldown, setCooldown] = useState(0);
useMemo(() => {
if (!cooldown) return;
const timer = window.setTimeout(() => setCooldown((v) => Math.max(v - 1, 0)), 1000);
return () => window.clearTimeout(timer);
}, [cooldown]);
async function handleLoginSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (!username.trim() || !password.trim()) {
setError("请输入用户名和密码");
if (!email.trim() || !password.trim()) {
setError("请输入邮箱和密码");
return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return;
}
setSubmitting(true);
try {
await login(username, password);
await login(email.trim(), password);
router.push("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败,请重试");
@ -32,80 +56,197 @@ export default function LoginPage() {
}
}
async function handleSendCode() {
setRegisterError("");
if (!registerEmail.trim() || !inviteCode.trim()) {
setRegisterError("请先填写邀请码和邮箱");
return;
}
setSendingCode(true);
try {
const result = await sendRegisterCodeAPI(registerEmail.trim(), inviteCode.trim());
setRegisterSuccess(result.message);
setCooldown(60);
} catch (err) {
setRegisterError(err instanceof Error ? err.message : "发送失败");
} finally {
setSendingCode(false);
}
}
async function handleRegisterSubmit(e: React.FormEvent) {
e.preventDefault();
setRegisterError("");
setRegisterSuccess("");
if (!inviteCode.trim() || !registerEmail.trim() || !emailCode.trim() || !registerPassword.trim() || !confirmPassword.trim()) {
setRegisterError("请填写完整注册信息");
return;
}
if (registerPassword.length < 6) {
setRegisterError("密码至少 6 位");
return;
}
if (registerPassword !== confirmPassword) {
setRegisterError("两次输入的密码不一致");
return;
}
setRegisterSubmitting(true);
try {
const result = await registerAPI(registerEmail.trim(), inviteCode.trim(), emailCode.trim(), registerPassword);
setRegisterSuccess(result.message);
setTab("login");
setEmail(registerEmail.trim());
setPassword(registerPassword);
setEmailCode("");
} catch (err) {
setRegisterError(err instanceof Error ? err.message : "注册失败");
} finally {
setRegisterSubmitting(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-bg-primary relative overflow-hidden">
{/* Ambient glow */}
<div className="fixed top-1/3 right-1/4 w-[500px] h-[500px] bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 w-full max-w-sm mx-4">
{/* Brand */}
<div className="relative z-10 w-full max-w-md mx-4">
<div className="flex flex-col items-center mb-8">
<div className="relative mb-5">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-lg font-bold text-white shadow-glow" style={{ boxShadow: "0 0 30px rgba(251,191,36,0.25)" }}>
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-lg font-bold text-white shadow-glow">
D
</div>
<div className="absolute inset-0 rounded-xl border border-amber-500/20 animate-pulse-ring" />
</div>
<h1 className="text-lg font-bold tracking-tight text-text-primary">
Dragon AI Agent
</h1>
<h1 className="text-lg font-bold tracking-tight text-text-primary">Dragon AI Agent</h1>
<p className="text-xs text-text-muted mt-1">A </p>
</div>
{/* Login card */}
<div className="glass-card-static p-7 rounded-2xl">
<h2 className="text-sm font-semibold text-text-primary mb-5 text-center"></h2>
<div className="flex gap-2 mb-5">
{[
{ key: "login", label: "登录" },
{ key: "register", label: "注册" },
].map((item) => (
<button
key={item.key}
onClick={() => setTab(item.key as Tab)}
className={`flex-1 rounded-xl py-2.5 text-sm font-medium transition-all ${
tab === item.key
? "bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10"
: "bg-surface-2 text-text-muted border border-transparent hover:text-text-secondary"
}`}
>
{item.label}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
{tab === "login" ? (
<form onSubmit={handleLoginSubmit} className="space-y-4">
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="email"
placeholder="输入邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="email"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="password"
placeholder="输入密码(至少 6 位)"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all disabled:opacity-50"
>
{submitting ? "登录中..." : "登录"}
</button>
{error ? (
<p className="text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10">{error}</p>
) : null}
</form>
) : (
<form onSubmit={handleRegisterSubmit} className="space-y-3">
<input
type="text"
placeholder="输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/20 placeholder-text-muted/40 transition-all"
autoComplete="username"
placeholder="邀请码"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block"></label>
<input
type="email"
placeholder="邮箱"
value={registerEmail}
onChange={(e) => setRegisterEmail(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="email"
/>
<div className="flex gap-2">
<input
type="text"
placeholder="邮箱验证码"
value={emailCode}
onChange={(e) => setEmailCode(e.target.value)}
className="flex-1 bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
/>
<button
type="button"
onClick={handleSendCode}
disabled={sendingCode || cooldown > 0}
className="px-4 rounded-xl text-xs font-medium bg-surface-2 border border-border-default text-text-secondary hover:text-text-primary disabled:opacity-50"
>
{cooldown > 0 ? `${cooldown}s` : sendingCode ? "发送中" : "发送验证码"}
</button>
</div>
<input
type="password"
placeholder="输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 focus:border-amber-500/20 placeholder-text-muted/40 transition-all"
autoComplete="current-password"
placeholder="设置密码(至少 6 位)"
value={registerPassword}
onChange={(e) => setRegisterPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all duration-200 shadow-glow-sm hover:shadow-glow disabled:opacity-50 disabled:cursor-not-allowed active:scale-95"
>
{submitting ? (
<span className="inline-flex items-center gap-2">
<span className="w-3.5 h-3.5 border border-white/40 border-t-white rounded-full animate-spin" />
...
</span>
) : "登录"}
</button>
</form>
{/* Error */}
{error && (
<p className="mt-4 text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10">
{error}
</p>
<input
type="password"
placeholder="确认密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
autoComplete="new-password"
/>
<button
type="submit"
disabled={registerSubmitting}
className="w-full bg-gradient-to-r from-amber-500 to-amber-600 text-white rounded-xl py-3 text-sm font-semibold hover:from-amber-400 hover:to-amber-500 transition-all disabled:opacity-50"
>
{registerSubmitting ? "注册中..." : "完成注册"}
</button>
{registerError ? (
<p className="text-center text-xs text-amber-400/80 bg-amber-500/5 rounded-lg py-2 border border-amber-500/10">{registerError}</p>
) : null}
{registerSuccess ? (
<p className="text-center text-xs text-emerald-400/80 bg-emerald-500/5 rounded-lg py-2 border border-emerald-500/10">{registerSuccess}</p>
) : null}
</form>
)}
</div>
{/* Footer link */}
<p className="mt-6 text-center text-xs text-text-muted/40">
<a href="/" className="hover:text-text-muted/60 transition-colors"></a>
</p>
</div>
</div>
);
}
}

View File

@ -16,7 +16,7 @@ export function UserMenu() {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted">{user.username}</span>
<span className="text-xs text-text-muted">{user.email || user.username}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400/80">
{user.role}
</span>

View File

@ -6,7 +6,7 @@ import { type AuthUser, loginAPI } from "@/lib/api";
interface AuthContextValue {
user: AuthUser | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
@ -35,8 +35,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false);
}, []);
const login = useCallback(async (username: string, password: string) => {
const res = await loginAPI(username, password);
const login = useCallback(async (email: string, password: string) => {
const res = await loginAPI(email, password);
localStorage.setItem("auth_token", res.token);
localStorage.setItem("auth_user", JSON.stringify(res.user));
setUser(res.user);

View File

@ -552,6 +552,7 @@ export async function* streamChat(
export interface AuthUser {
id: number;
username: string;
email: string;
role: "admin" | "user";
}
@ -560,11 +561,11 @@ export interface LoginResponse {
user: AuthUser;
}
export async function loginAPI(username: string, password: string): Promise<LoginResponse> {
export async function loginAPI(email: string, password: string): Promise<LoginResponse> {
const res = await fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@ -573,25 +574,56 @@ export async function loginAPI(username: string, password: string): Promise<Logi
return res.json();
}
export async function sendRegisterCodeAPI(email: string, inviteCode: string): Promise<{ message: string }> {
return postAPI<{ message: string }>("/api/auth/send-register-code", {
email,
invite_code: inviteCode,
});
}
export async function registerAPI(
email: string,
inviteCode: string,
emailCode: string,
password: string
): Promise<{ message: string }> {
return postAPI<{ message: string }>("/api/auth/register", {
email,
invite_code: inviteCode,
email_code: emailCode,
password,
});
}
// ---------- User Management ----------
export interface UserItem {
id: number;
username: string;
email: string;
role: "admin" | "user";
is_active: boolean;
invite_code_used?: string;
created_at: string | null;
}
export interface CreateUserResult {
username: string;
password: string;
role: string;
message: string;
code: string;
}
export interface InviteCodeItem {
id: number;
code: string;
description: string;
is_active: boolean;
max_uses: number;
used_count: number;
created_at: string | null;
}
export interface ResetPasswordResult {
username: string;
email: string;
password: string;
message: string;
}
@ -600,10 +632,6 @@ export async function listUsersAPI(): Promise<UserItem[]> {
return fetchAPI<UserItem[]>("/api/auth/users");
}
export async function createUserAPI(username: string, role: string): Promise<CreateUserResult> {
return postAPI<CreateUserResult>("/api/auth/users", { username, role });
}
export async function disableUserAPI(userId: number): Promise<{ message: string }> {
return deleteAPI<{ message: string }>(`/api/auth/users/${userId}`);
}
@ -612,6 +640,22 @@ export async function resetPasswordAPI(userId: number): Promise<ResetPasswordRes
return postAPI<ResetPasswordResult>(`/api/auth/users/${userId}/reset-password`);
}
export async function listInviteCodesAPI(): Promise<InviteCodeItem[]> {
return fetchAPI<InviteCodeItem[]>("/api/auth/invite-codes");
}
export async function createInviteCodeAPI(code: string, description: string, maxUses: number): Promise<CreateUserResult> {
return postAPI<CreateUserResult>("/api/auth/invite-codes", {
code,
description,
max_uses: maxUses,
});
}
export async function toggleInviteCodeAPI(inviteId: number): Promise<{ message: string }> {
return postAPI<{ message: string }>(`/api/auth/invite-codes/${inviteId}/toggle`);
}
export async function changePasswordAPI(oldPassword: string, newPassword: string): Promise<{ message: string }> {
return postAPI<{ message: string }>("/api/auth/change-password", {
old_password: oldPassword,