import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' import { comparePassword, hashPassword, isAdminEmail, issueEmailCode, normalizeEmail, validatePassword, verifyEmailCode, } from '../lib/auth.js' import { generateAvatar } from '../lib/generate-avatar.js' import { prisma } from '../lib/prisma.js' import { checkLoginRateLimit } from '../lib/rate-limit.js' export function mapUserForClient(user) { const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) const userEmail = normalizeEmail(user.email) return { id: user.id, email: user.email, displayName: user.displayName, avatar: user.avatar, avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } } export async function registerAuthRoutes(fastify) { fastify.post('/api/auth/request-code', async (request, reply) => { const email = normalizeEmail(request.body?.email) if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) const code = await issueEmailCode({ email, purpose: 'login' }) const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() const isAdmin = email === adminEmail request.server.eventBus.emit(NOTIFICATION_EVENTS.AUTH_CODE_REQUESTED, { email, code, isAdmin, }) return { ok: true } }) fastify.post('/api/auth/verify-code', async (request, reply) => { const email = normalizeEmail(request.body?.email) const code = String(request.body?.code || '').trim() if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) const ok = await verifyEmailCode({ email, purpose: 'login', code }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) const avatarUri = await generateAvatar(email) const user = await prisma.user.upsert({ where: { email }, update: {}, create: { email, avatar: avatarUri, avatarStyle: 'avataaars' }, }) // Ensure notification preference exists await prisma.notificationPreference.upsert({ where: { userId: user.id }, create: { userId: user.id, globalEnabled: true }, update: {}, }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) return { token, user: mapUserForClient(user) } }) fastify.post('/api/auth/register', async (request, reply) => { const email = normalizeEmail(request.body?.email) const password = String(request.body?.password || '') const displayNameRaw = request.body?.displayName const displayName = displayNameRaw ? String(displayNameRaw).trim().slice(0, 100) : email.split('@')[0] if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор не может регистрироваться с паролем' }) const passwordErr = validatePassword(password) if (passwordErr) return reply.code(400).send({ error: passwordErr }) const exists = await prisma.user.findUnique({ where: { email } }) if (exists) return reply.code(409).send({ error: 'Эта почта уже зарегистрирована' }) const passwordHash = await hashPassword(password) const avatarUri = await generateAvatar(email) const user = await prisma.user.create({ data: { email, passwordHash, displayName: displayName || null, avatar: avatarUri, avatarStyle: 'avataaars', }, }) await prisma.notificationPreference.upsert({ where: { userId: user.id }, create: { userId: user.id, globalEnabled: true }, update: {}, }) const token = fastify.jwt.sign({ sub: user.id, email: user.email }) return reply.code(201).send({ token, user: mapUserForClient(user) }) }) fastify.post('/api/auth/login', async (request, reply) => { const email = normalizeEmail(request.body?.email) const password = String(request.body?.password || '') const ip = request.ip if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (isAdminEmail(email)) return reply.code(403).send({ error: 'Администратор входит только по коду' }) const rate = checkLoginRateLimit(ip) if (!rate.allowed) { return reply .code(429) .header('Retry-After', String(rate.retryAfter)) .send({ error: `Слишком много попыток. Попробуйте через ${rate.retryAfter} сек.` }) } const user = await prisma.user.findUnique({ where: { email } }) if (!user || !user.passwordHash) { return reply.code(401).send({ error: 'Неверная почта или пароль' }) } const valid = await comparePassword(password, user.passwordHash) if (!valid) { return reply.code(401).send({ error: 'Неверная почта или пароль' }) } const token = fastify.jwt.sign({ sub: user.id, email: user.email }) return { token, user: mapUserForClient(user) } }) fastify.post('/api/auth/forgot-password', async (request) => { const email = normalizeEmail(request.body?.email) if (!email || !email.includes('@')) return { ok: true } if (isAdminEmail(email)) return { ok: true } const user = await prisma.user.findUnique({ where: { email } }) if (!user || !user.passwordHash) return { ok: true } await issueEmailCode({ email, purpose: 'reset_password' }) return { ok: true } }) fastify.post('/api/auth/reset-password', async (request, reply) => { const email = normalizeEmail(request.body?.email) const code = String(request.body?.code || '').trim() const newPassword = String(request.body?.newPassword || '') if (!email || !email.includes('@')) return reply.code(400).send({ error: 'Некорректная почта' }) if (!code || code.length !== 6) return reply.code(400).send({ error: 'Код должен быть из 6 цифр' }) const ok = await verifyEmailCode({ email, purpose: 'reset_password', code }) if (!ok) return reply.code(401).send({ error: 'Неверный или истёкший код' }) const passwordErr = validatePassword(newPassword) if (passwordErr) return reply.code(400).send({ error: passwordErr }) const passwordHash = await hashPassword(newPassword) await prisma.user.update({ where: { email }, data: { passwordHash } }) return { ok: true } }) fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const nameRaw = request.body?.displayName const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() const avatarRaw = request.body?.avatar const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() const avatarStyleRaw = request.body?.avatarStyle const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() if (displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) if (avatarStyle !== undefined && avatarStyle !== '' && avatarStyle.length > 30) { return reply.code(400).send({ error: 'Стиль аватара слишком длинный' }) } const data = { displayName: displayName && displayName.length ? displayName : null, } if (avatar !== undefined) { data.avatar = avatar === '' ? null : avatar } if (avatarStyle !== undefined) { data.avatarStyle = avatarStyle === '' ? null : avatarStyle } const updated = await prisma.user.update({ where: { id: userId }, data, }) return { user: mapUserForClient(updated) } }) fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP'] const activeOrders = await prisma.order.findMany({ where: { userId, status: { in: ACTIVE_STATUSES } }, select: { id: true }, }) await prisma.user.delete({ where: { id: userId } }) return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) } }) }