- 新增 POST /auth/set-password 端点(仅限无密码用户) - /auth/me 返回 hasPassword 字段 - SettingsDialog:无密码用户显示"设置密码"表单(无需当前密码) - API Key reveal/copy:无密码时引导用户先设置密码 - 中英双语 i18n 支持 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
266 lines
8.9 KiB
TypeScript
266 lines
8.9 KiB
TypeScript
import { Router, type Router as RouterType } from 'express';
|
|
import { z } from 'zod';
|
|
import { prisma } from '@agent-fox/shared';
|
|
import { hashPassword, verifyPassword } from '../lib/password.js';
|
|
import { generateTokenPair, verifyRefreshToken } from '../lib/jwt.js';
|
|
import { requireAuth } from '../middleware/auth.js';
|
|
import { generateApiKey } from '../lib/api-key.js';
|
|
import { encryptApiKey, decryptApiKey } from '../lib/crypto.js';
|
|
|
|
const router: RouterType = Router();
|
|
|
|
const registerSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
name: z.string().min(1).max(100),
|
|
});
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string(),
|
|
});
|
|
|
|
router.post('/register', async (req, res) => {
|
|
const parsed = registerSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
|
return;
|
|
}
|
|
|
|
const { email, password, name } = parsed.data;
|
|
|
|
const existing = await prisma.user.findUnique({ where: { email } });
|
|
if (existing) {
|
|
res.status(409).json({ success: false, error: { code: 'CONFLICT', message: 'Email already registered' } });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await hashPassword(password);
|
|
const user = await prisma.user.create({
|
|
data: { email, passwordHash, name },
|
|
});
|
|
|
|
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
|
res.status(201).json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
|
|
});
|
|
|
|
router.post('/login', async (req, res) => {
|
|
const parsed = loginSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
|
return;
|
|
}
|
|
|
|
const { email, password } = parsed.data;
|
|
const user = await prisma.user.findUnique({ where: { email } });
|
|
|
|
if (!user || !user.passwordHash) {
|
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
|
return;
|
|
}
|
|
|
|
const valid = await verifyPassword(password, user.passwordHash);
|
|
if (!valid) {
|
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid credentials' } });
|
|
return;
|
|
}
|
|
|
|
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
|
res.json({ success: true, data: { user: { id: user.id, email: user.email, name: user.name }, ...tokens } });
|
|
});
|
|
|
|
router.post('/refresh', async (req, res) => {
|
|
const { refreshToken } = req.body;
|
|
if (!refreshToken) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Refresh token required' } });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const payload = verifyRefreshToken(refreshToken);
|
|
const user = await prisma.user.findUnique({ where: { id: payload.userId } });
|
|
if (!user) {
|
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'User not found' } });
|
|
return;
|
|
}
|
|
const tokens = generateTokenPair({ userId: user.id, email: user.email });
|
|
res.json({ success: true, data: tokens });
|
|
} catch {
|
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Invalid refresh token' } });
|
|
}
|
|
});
|
|
|
|
const setPasswordSchema = z.object({
|
|
password: z.string().min(8),
|
|
});
|
|
|
|
router.post('/set-password', requireAuth, async (req, res) => {
|
|
const parsed = setPasswordSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
|
return;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
|
|
if (!user) {
|
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
|
return;
|
|
}
|
|
|
|
if (user.passwordHash) {
|
|
res.status(400).json({ success: false, error: { code: 'ALREADY_HAS_PASSWORD', message: 'Password already set. Use change-password instead.' } });
|
|
return;
|
|
}
|
|
|
|
const passwordHash = await hashPassword(parsed.data.password);
|
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
|
|
res.json({ success: true, data: { message: 'Password set successfully' } });
|
|
});
|
|
|
|
const changePasswordSchema = z.object({
|
|
currentPassword: z.string(),
|
|
newPassword: z.string().min(8),
|
|
});
|
|
|
|
router.post('/change-password', requireAuth, async (req, res) => {
|
|
const parsed = changePasswordSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
|
return;
|
|
}
|
|
|
|
const { currentPassword, newPassword } = parsed.data;
|
|
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } });
|
|
|
|
if (!user || !user.passwordHash) {
|
|
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set for this account' } });
|
|
return;
|
|
}
|
|
|
|
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
|
if (!valid) {
|
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Current password is incorrect' } });
|
|
return;
|
|
}
|
|
|
|
const newHash = await hashPassword(newPassword);
|
|
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: newHash } });
|
|
res.json({ success: true, data: { message: 'Password changed' } });
|
|
});
|
|
|
|
const profileSchema = z.object({
|
|
name: z.string().min(1).max(100),
|
|
});
|
|
|
|
router.put('/profile', requireAuth, async (req, res) => {
|
|
const parsed = profileSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: parsed.error.issues[0].message } });
|
|
return;
|
|
}
|
|
|
|
const user = await prisma.user.update({
|
|
where: { id: req.user!.userId },
|
|
data: { name: parsed.data.name },
|
|
select: { id: true, email: true, name: true },
|
|
});
|
|
res.json({ success: true, data: user });
|
|
});
|
|
|
|
router.get('/me', requireAuth, async (req, res) => {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user!.userId },
|
|
select: { id: true, email: true, name: true, avatarUrl: true, passwordHash: true },
|
|
});
|
|
if (!user) {
|
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
|
return;
|
|
}
|
|
const { passwordHash, ...rest } = user;
|
|
res.json({ success: true, data: { ...rest, hasPassword: !!passwordHash } });
|
|
});
|
|
|
|
// --- API Key Management ---
|
|
|
|
router.get('/api-key/status', requireAuth, async (req, res) => {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user!.userId },
|
|
select: { apiKeyPrefix: true, apiKeyHash: true },
|
|
});
|
|
res.json({
|
|
success: true,
|
|
data: { hasKey: !!user?.apiKeyHash, prefix: user?.apiKeyPrefix || null },
|
|
});
|
|
});
|
|
|
|
router.post('/api-key/generate', requireAuth, async (req, res) => {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user!.userId },
|
|
select: { apiKeyHash: true },
|
|
});
|
|
if (user?.apiKeyHash) {
|
|
res.status(400).json({ success: false, error: { code: 'ALREADY_EXISTS', message: 'API key already exists. Use rotate to replace it.' } });
|
|
return;
|
|
}
|
|
|
|
const { raw, hash } = generateApiKey();
|
|
const encrypted = encryptApiKey(raw);
|
|
const prefix = raw.slice(0, 12);
|
|
|
|
await prisma.user.update({
|
|
where: { id: req.user!.userId },
|
|
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
|
|
});
|
|
|
|
res.json({ success: true, data: { apiKey: raw } });
|
|
});
|
|
|
|
router.post('/api-key/rotate', requireAuth, async (req, res) => {
|
|
const { raw, hash } = generateApiKey();
|
|
const encrypted = encryptApiKey(raw);
|
|
const prefix = raw.slice(0, 12);
|
|
|
|
await prisma.user.update({
|
|
where: { id: req.user!.userId },
|
|
data: { apiKeyHash: hash, apiKeyEncrypted: encrypted, apiKeyPrefix: prefix },
|
|
});
|
|
|
|
res.json({ success: true, data: { apiKey: raw } });
|
|
});
|
|
|
|
const revealSchema = z.object({ password: z.string() });
|
|
|
|
router.post('/api-key/reveal', requireAuth, async (req, res) => {
|
|
const parsed = revealSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
res.status(400).json({ success: false, error: { code: 'VALIDATION', message: 'Password is required' } });
|
|
return;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: req.user!.userId },
|
|
select: { passwordHash: true, apiKeyEncrypted: true },
|
|
});
|
|
|
|
if (!user?.passwordHash) {
|
|
res.status(400).json({ success: false, error: { code: 'NO_PASSWORD', message: 'No password set' } });
|
|
return;
|
|
}
|
|
|
|
const valid = await verifyPassword(parsed.data.password, user.passwordHash);
|
|
if (!valid) {
|
|
res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Incorrect password' } });
|
|
return;
|
|
}
|
|
|
|
if (!user.apiKeyEncrypted) {
|
|
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'No API key generated' } });
|
|
return;
|
|
}
|
|
|
|
const apiKey = decryptApiKey(user.apiKeyEncrypted);
|
|
res.json({ success: true, data: { apiKey } });
|
|
});
|
|
|
|
export default router;
|