diff --git a/docs/superpowers/plans/2026-05-22-auth-redesign.md b/docs/superpowers/plans/2026-05-22-auth-redesign.md new file mode 100644 index 0000000..4e2cf6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-auth-redesign.md @@ -0,0 +1,1775 @@ +# Auth Redesign — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Переработать аутентификацию: OAuth только email, внутренние аватары, вход по email+паролю, связывание методов входа в ЛК. + +**Architecture:** Server: Fastify + Prisma + bcrypt + in-memory rate limiter. Client: React + Effector + MUI tabs. Email остаётся единым идентификатором. + +**Tech Stack:** Node.js, Fastify, Prisma, bcrypt, React, MUI, Effector, effector-react + +--- + +### Task 1: Install dependencies + +**Files:** +- Modify: `server/package.json` + +- [ ] **Step 1: Install bcrypt** + +```bash +cd server && npm install bcrypt +``` + +- [ ] **Step 2: Verify install** + +```bash +cd server && node -e "require('bcrypt')" +``` + +Expected: no error. + +--- + +### Task 2: Prisma schema — remove avatarType + +**Files:** +- Modify: `server/prisma/schema.prisma` + +- [ ] **Step 1: Remove avatarType from User model** + +Edit `server/prisma/schema.prisma`: remove line `avatarType String?`. + +```diff + avatar String? +- avatarType String? + avatarStyle String? +``` + +- [ ] **Step 2: Add data migration comments to migration** + +The migration SQL must also clean up existing OAuth avatar URLs. After Prisma generates the migration, edit the SQL file to include: + +```sql +-- Before ALTER TABLE DROP COLUMN: +UPDATE User SET avatar = NULL WHERE avatarType = 'oauth'; +``` + +- [ ] **Step 3: Run migration** + +```bash +cd server && npx prisma migrate dev --name remove_avatarType +``` + +Check the generated migration SQL in `server/prisma/migrations/`. Edit it if Prisma didn't include the cleanup SQL. + +Expected: migration runs successfully. + +- [ ] **Step 4: Verify Prisma client regenerated** + +```bash +cd server && node -e "const {prisma} = require('./src/lib/prisma.js'); prisma.user.findFirst().then(u => { console.log('avatarType' in (u||{})); process.exit(0) })" +``` + +Expected: `false` (avatarType not in prisma client). + +--- + +### Task 3: Add password validation and bcrypt helpers to lib/auth.js + +**Files:** +- Modify: `server/src/lib/auth.js` + +- [ ] **Step 1: Add imports and helpers** + +```js +import bcrypt from 'bcrypt' + +const PASSWORD_MIN_LEN = 8 + +const PASSWORD_REGEX = { + letter: /[a-zа-яё]/i, + digit: /[0-9]/, + special: /[^a-zа-яё0-9\s]/i, +} + +export function validatePassword(password) { + if (typeof password !== 'string') return 'Пароль обязателен' + if (password.length < PASSWORD_MIN_LEN) return `Пароль должен быть не менее ${PASSWORD_MIN_LEN} символов` + if (!PASSWORD_REGEX.letter.test(password)) return 'Пароль должен содержать хотя бы одну букву' + if (!PASSWORD_REGEX.digit.test(password)) return 'Пароль должен содержать хотя бы одну цифру' + if (!PASSWORD_REGEX.special.test(password)) return 'Пароль должен содержать хотя бы один спецсимвол' + return null +} + +export async function hashPassword(password) { + return bcrypt.hash(password, 10) +} + +export async function comparePassword(password, hash) { + return bcrypt.compare(password, hash) +} + +export function isAdminEmail(email) { + const adminEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() + if (!adminEmail) return false + return normalizeEmail(email) === adminEmail +} +``` + +--- + +### Task 4: Add in-memory rate limiter for login + +**Files:** +- Create: `server/src/lib/rate-limit.js` + +- [ ] **Step 1: Create rate limiter** + +```js +const windows = new Map() + +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +export function checkLoginRateLimit(ip) { + const now = Date.now() + const entry = windows.get(ip) + if (!entry || now - entry.start > WINDOW_MS) { + windows.set(ip, { start: now, count: 1 }) + return { allowed: true } + } + entry.count += 1 + if (entry.count > MAX_ATTEMPTS) { + const retryAfter = Math.ceil((entry.start + WINDOW_MS - now) / 1000) + return { allowed: false, retryAfter } + } + return { allowed: true } +} +``` + +- [ ] **Step 2: Test rate limiter** + +```bash +cd server && node -e " +const { checkLoginRateLimit } = require('./src/lib/rate-limit.js'); +checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); +checkLoginRateLimit('test1'); checkLoginRateLimit('test1'); +const r = checkLoginRateLimit('test1'); console.log('5th allowed:', r.allowed); +const r2 = checkLoginRateLimit('test1'); console.log('6th blocked:', !r2.allowed, 'retryAfter:', r2.retryAfter > 0); +" +``` + +Expected: `5th allowed: true`, `6th blocked: true retryAfter: ` + +--- + +### Task 5: Add register endpoint + +**Files:** +- Modify: `server/src/routes/auth.js` + +- [ ] **Step 1: Add POST /api/auth/register** + +Add after existing imports, before `export async function registerAuthRoutes`: + +Add import: +```js +import { hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js' +``` + +Add route inside `registerAuthRoutes`, after the `verify-code` route and before `/api/me`: + +```js +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 user = await prisma.user.create({ + data: { + email, + passwordHash, + displayName: displayName || null, + avatar: null, + 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) }) +}) +``` + +--- + +### Task 6: Add login endpoint + +**Files:** +- Modify: `server/src/routes/auth.js` +- Create: `server/src/lib/rate-limit.js` + +- [ ] **Step 1: Add POST /api/auth/login** + +Add import at top of auth.js: +```js +import { comparePassword, isAdminEmail } from '../lib/auth.js' +import { checkLoginRateLimit } from '../lib/rate-limit.js' +``` + +Add route inside `registerAuthRoutes`, after register route: + +```js +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) } +}) +``` + +--- + +### Task 7: Remove avatarType from mapUserForClient and profile routes + +**Files:** +- Modify: `server/src/routes/auth.js` +- Modify: `server/src/routes/api/admin-profile.js` + +- [ ] **Step 1: Remove avatarType from mapUserForClient** + +Edit `mapUserForClient` in `server/src/routes/auth.js`: + +```diff + avatar: user.avatar, +- avatarType: user.avatarType, + avatarStyle: user.avatarStyle, +``` + +Edit the profile PATCH route in same file, remove all `avatarType` handling: + +```diff + const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() + +- if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { +- return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) +- } + if (avatar !== undefined && avatar.length > 200000) return reply.code(400).send({ error: 'Аватар слишком большой' }) +``` + +Also remove `avatarType` from body destructuring and data object: + +```diff +- const avatarTypeRaw = request.body?.avatarType +- const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() + const avatarStyleRaw = request.body?.avatarStyle +``` + +And in data construction: +```diff +- if (avatarType !== undefined) { +- data.avatarType = avatarType === '' ? null : avatarType +- } +``` + +- [ ] **Step 2: Remove avatarType from admin-profile routes** + +Edit `server/src/routes/api/admin-profile.js`: + +Remove `avatarType` from GET `/api/admin/profile` response (line 14): +```diff + avatar: user.avatar, +- avatarType: user.avatarType, + avatarStyle: user.avatarStyle, +``` + +Remove `avatarType` from GET `/api/admin/avatar` response (line 28): +```diff + avatar: user.avatar, +- avatarType: user.avatarType, + avatarStyle: user.avatarStyle, +``` + +Remove `avatarType` handling from PATCH `/api/admin/profile`: +- Remove destructuring lines for `avatarTypeRaw`/`avatarType` (lines 40-41) +- Remove validation check (lines 48-50) +- Remove `avatarType` from data object (lines 60-62) +- Remove `avatarType` from response (line 76) + +--- + +### Task 8: OAuth — remove profile requests, only email + +**Files:** +- Modify: `server/src/routes/oauth-social.js` + +- [ ] **Step 1: Update findOrCreateUserFromOAuth to remove fallback email** + +Replace `findOrCreateUserFromOAuth` function: + +```js +async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) { + const existingLink = await prisma.oAuthAccount.findUnique({ + where: { provider_providerUserId: { provider, providerUserId } }, + include: { user: true }, + }) + if (existingLink?.user) { + if (accessToken !== undefined) { + await prisma.oAuthAccount.update({ + where: { provider_providerUserId: { provider, providerUserId } }, + data: { accessToken }, + }) + } + return existingLink.user + } + + const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' + const norm = trimmed ? normalizeEmail(trimmed) : null + + if (linkToUserId) { + if (!norm) return null + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, + }) + return prisma.user.findUnique({ where: { id: linkToUserId } }) + } + + let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null + if (user) { + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, + }) + return user + } + + if (!norm) return null + + user = await prisma.user.create({ + data: { + email: norm, + displayName: norm.split('@')[0], + avatar: null, + avatarStyle: 'avataaars', + }, + }) + await prisma.oAuthAccount.create({ + data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, + }) + await prisma.notificationPreference.create({ + data: { userId: user.id, globalEnabled: true }, + }) + return user +} +``` + +- [ ] **Step 2: Update VK callback — remove users.get and profile fields** + +Replace VK callback body after token exchange (from line 115), removing the users.get call and profile field extraction: + +```js +const vkUserId = tokenBody?.user_id +const accessTokenVk = tokenBody?.access_token +const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null + +if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') + +// statePayload already parsed in the state verify block above (see Step 4) +const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined + +const user = await findOrCreateUserFromOAuth({ + provider: 'vk', + providerUserId: String(vkUserId), + accessToken: accessTokenVk ?? null, + suggestedEmail: emailSuggestion, + linkToUserId, +}) + +if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') + +if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`) +} + +const token = await issueUserJwt(fastify, user.id, user.email) +return clientRedirect(fastify, reply, token) +``` + +- [ ] **Step 3: Update Yandex callback — remove profile fields, only email** + +Update the authorize scope (line 179): +```diff +- url.searchParams.set('scope', 'login:email login:info') ++ url.searchParams.set('scope', 'login:email') +``` + +Replace Yandex callback body after `/info` call (from line 230), removing profile field extraction: + +```js +const yaUserId = String(info?.id || '') +if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex') + +const emailGuess = + (Array.isArray(info?.emails) && info.emails[0]) || + info?.default_email || + null + +if (!emailGuess) return oauthErrorRedirect(reply, 'no_email') + +// statePayload already parsed in the state verify block (see Step 4) +const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined + +const user = await findOrCreateUserFromOAuth({ + provider: 'yandex', + providerUserId: yaUserId, + accessToken: yaToken, + suggestedEmail: emailGuess, + linkToUserId, +}) + +if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс') + +if (linkToUserId) { + const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' + return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`) +} + +const token = await issueUserJwt(fastify, user.id, user.email) +return clientRedirect(fastify, reply, token) +``` + +- [ ] **Step 4: Update state verify to parse state payload** + +In VK callback, replace lines 89-93: +```js +let statePayload = null +try { + const raw = typeof query.state === 'string' ? query.state : '' + statePayload = fastify.jwt.verify(raw || '') +} catch { + return oauthErrorRedirect(reply, 'Недействительный state OAuth') +} +``` + +In Yandex callback, replace lines 189-194 the same way: +```js +let statePayload = null +try { + const raw = typeof query.state === 'string' ? query.state : '' + statePayload = fastify.jwt.verify(raw || '') +} catch { + return oauthErrorRedirect(reply, 'Недействительный state OAuth') +} +``` + +--- + +### Task 9: OAuth — add link route + +**Files:** +- Modify: `server/src/routes/oauth-social.js` +- Modify: `server/src/plugins/auth.js` + +- [ ] **Step 1: Add GET /api/auth/oauth/{provider}/link** + +Add route in `registerOAuthSocialRoutes`, after each provider's main route but before the callback: + +```js +fastify.get('/api/auth/oauth/vk/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.VK_CLIENT_ID + const clientSecret = process.env.VK_CLIENT_SECRET + if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback` + const state = fastify.jwt.sign( + { oauth: 'vk', action: 'link', userId: request.user.sub }, + { expiresIn: '15m' }, + ) + + const url = new URL('https://oauth.vk.com/authorize') + url.searchParams.set('client_id', clientId) + url.searchParams.set('display', 'page') + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'email') + url.searchParams.set('response_type', 'code') + url.searchParams.set('v', '5.199') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) +}) +``` + +And for Yandex: + +```js +fastify.get('/api/auth/oauth/yandex/link', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL) + if (request.user.email === adminEmail) { + return reply.code(403).send({ error: 'Администратор не может привязывать OAuth' }) + } + + const clientId = process.env.YANDEX_CLIENT_ID + if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен' }) + + const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback` + const state = fastify.jwt.sign( + { oauth: 'yandex', action: 'link', userId: request.user.sub }, + { expiresIn: '15m' }, + ) + + const url = new URL('https://oauth.yandex.ru/authorize') + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', clientId) + url.searchParams.set('redirect_uri', redirectUri) + url.searchParams.set('scope', 'login:email') + url.searchParams.set('state', state) + + return reply.redirect(url.toString()) +}) +``` + +--- + +### Task 10: Account linking API — auth-methods, password, unlink + +**Files:** +- Modify: `server/src/routes/auth.js` + +- [ ] **Step 1: Add GET /api/me/auth-methods** + +Add route in `registerAuthRoutes`, after `/api/me`: + +```js +fastify.get('/api/me/auth-methods', { preHandler: [fastify.authenticate] }, async (request) => { + const userId = request.user.sub + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { oauthAccounts: { select: { provider: true } } }, + }) + if (!user) return { methods: [] } + + const providers = user.oauthAccounts.map((a) => a.provider) + return { + methods: [ + { type: 'password', active: Boolean(user.passwordHash) }, + { type: 'vk', active: providers.includes('vk') }, + { type: 'yandex', active: providers.includes('yandex') }, + ], + } +}) +``` + +- [ ] **Step 2: Add POST /api/me/password** + +Add route after auth-methods: + +```js +fastify.post('/api/me/password', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может устанавливать пароль' }) + } + + const user = await prisma.user.findUnique({ where: { id: userId } }) + if (!user) return reply.code(404).send({ error: 'Пользователь не найден' }) + if (user.passwordHash) return reply.code(409).send({ error: 'Пароль уже установлен' }) + + const password = String(request.body?.password || '') + const passwordErr = validatePassword(password) + if (passwordErr) return reply.code(400).send({ error: passwordErr }) + + const passwordHash = await hashPassword(password) + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }) + + return { ok: true } +}) +``` + +- [ ] **Step 3: Add DELETE /api/me/oauth/{provider}** + +Add route after /api/me/password: + +```js +fastify.delete('/api/me/oauth/:provider', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const provider = request.params?.provider + + if (isAdminEmail(request.user.email)) { + return reply.code(403).send({ error: 'Администратор не может отвязывать OAuth' }) + } + if (provider !== 'vk' && provider !== 'yandex') { + return reply.code(400).send({ error: 'Неизвестный провайдер' }) + } + + const oauth = await prisma.oAuthAccount.findFirst({ + where: { userId, provider }, + }) + if (!oauth) return reply.code(404).send({ error: 'Аккаунт не привязан' }) + + const remainingOAuth = await prisma.oAuthAccount.count({ + where: { userId, provider: { not: provider } }, + }) + const user = await prisma.user.findUnique({ where: { id: userId }, select: { passwordHash: true } }) + if (!user?.passwordHash && remainingOAuth === 0) { + return reply.code(400).send({ error: 'Нельзя удалить последний метод входа' }) + } + + await prisma.oAuthAccount.delete({ where: { id: oauth.id } }) + return { ok: true } +}) +``` + +--- + +### Task 11: Server tests — password auth endpoints + +**Files:** +- Create: `server/src/routes/__tests__/auth-password.test.js` + +- [ ] **Step 1: Write tests** + +```js +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerAuthRoutes } from '../auth.js' + +const JWT_SECRET = 'test-secret' +const TEST_EMAIL = `test-reg-${Date.now()}@example.com` + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +describe('POST /api/auth/register', () => { + let app + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + afterEach(async () => { + await prisma.user.deleteMany({ where: { email: TEST_EMAIL } }) + }) + + it('registers a new user with password', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(201) + const body = JSON.parse(res.body) + expect(body.token).toBeTruthy() + expect(body.user.email).toBe(TEST_EMAIL) + expect(body.user.displayName).toBe('test-reg') + }) + + it('rejects duplicate email', async () => { + await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) + + it('rejects weak password — too short', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Ab1!' }, + }) + expect(res.statusCode).toBe(400) + const body = JSON.parse(res.body) + expect(body.error).toContain('не менее 8') + }) + + it('rejects weak password — no digit', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefgh!' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('цифру') + }) + + it('rejects weak password — no special char', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: TEST_EMAIL, password: 'Abcdefg1' }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('спецсимвол') + }) +}) + +describe('POST /api/auth/login', () => { + let app + const loginEmail = `test-login-${Date.now()}@example.com` + + beforeAll(async () => { + app = await buildApp() + await app.inject({ + method: 'POST', url: '/api/auth/register', + payload: { email: loginEmail, password: 'Test123!@' }, + }) + }) + afterAll(async () => { + await prisma.user.deleteMany({ where: { email: loginEmail } }) + await app.close() + }) + + it('logs in with correct password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: loginEmail, password: 'Test123!@' }, + headers: { 'x-forwarded-for': '1.1.1.1' }, + }) + expect(res.statusCode).toBe(200) + expect(JSON.parse(res.body).token).toBeTruthy() + }) + + it('rejects wrong password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: loginEmail, password: 'Wrong!!1!' }, + headers: { 'x-forwarded-for': '2.2.2.2' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('rejects non-existent email', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: 'nobody@nowhere.test', password: 'Test123!@' }, + headers: { 'x-forwarded-for': '3.3.3.3' }, + }) + expect(res.statusCode).toBe(401) + }) + + it('returns 403 for admin email', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { email: process.env.ADMIN_EMAIL || 'admin@test.local', password: 'Test123!@' }, + headers: { 'x-forwarded-for': '4.4.4.4' }, + }) + if (process.env.ADMIN_EMAIL) { + expect(res.statusCode).toBe(403) + } + }) +}) +``` + +- [ ] **Step 2: Run tests** + +```bash +cd server && npx vitest run src/routes/__tests__/auth-password.test.js +``` + +Expected: all tests pass. + +--- + +### Task 12: Server tests — auth-methods, password, unlink + +**Files:** +- Create: `server/src/routes/__tests__/auth-methods.test.js` + +- [ ] **Step 1: Write tests** + +```js +import Fastify from 'fastify' +import jwt from '@fastify/jwt' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { prisma } from '../../lib/prisma.js' +import { registerAuthRoutes } from '../auth.js' + +const JWT_SECRET = 'test-secret' + +async function buildApp() { + const app = Fastify({ logger: false }) + await app.register(jwt, { secret: JWT_SECRET }) + app.decorate('authenticate', async function (request, reply) { + try { await request.jwtVerify() } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + }) + app.decorate('eventBus', { emit: () => {} }) + await registerAuthRoutes(app) + await app.ready() + return app +} + +function signToken(app, userId, email) { + return app.jwt.sign({ sub: userId, email }) +} + +async function createUser(email) { + const user = await prisma.user.create({ + data: { email, displayName: 'Test', avatar: null, avatarStyle: 'avataaars' }, + }) + await prisma.notificationPreference.create({ data: { userId: user.id, globalEnabled: true } }) + return user +} + +describe('GET /api/me/auth-methods', () => { + let app, user, token + const email = `test-methods-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + + beforeEach(async () => { + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns methods for user without any method', async () => { + const res = await app.inject({ + method: 'GET', url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + const body = JSON.parse(res.body) + expect(body.methods.find((m) => m.type === 'password').active).toBe(false) + expect(body.methods.find((m) => m.type === 'vk').active).toBe(false) + expect(body.methods.find((m) => m.type === 'yandex').active).toBe(false) + }) + + it('returns password as active after setting it', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'hashed' } }) + const res = await app.inject({ + method: 'GET', url: '/api/me/auth-methods', + headers: { authorization: `Bearer ${token}` }, + }) + expect(JSON.parse(res.body).methods.find((m) => m.type === 'password').active).toBe(true) + }) +}) + +describe('POST /api/me/password', () => { + let app, user, token + const email = `test-set-pw-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + + beforeEach(async () => { + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('sets password', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(200) + + const u = await prisma.user.findUnique({ where: { id: user.id } }) + expect(u.passwordHash).toBeTruthy() + }) + + it('rejects if password already set', async () => { + await prisma.user.update({ where: { id: user.id }, data: { passwordHash: 'existing' } }) + const res = await app.inject({ + method: 'POST', url: '/api/me/password', + headers: { authorization: `Bearer ${token}` }, + payload: { password: 'Test123!@' }, + }) + expect(res.statusCode).toBe(409) + }) +}) + +describe('DELETE /api/me/oauth/:provider', () => { + let app, user, token + const email = `test-unlink-${Date.now()}@example.com` + + beforeAll(async () => { app = await buildApp() }) + afterAll(async () => { await app.close() }) + + beforeEach(async () => { + await prisma.user.deleteMany({ where: { email } }) + user = await createUser(email) + token = signToken(app, user.id, email) + }) + + it('returns 404 for non-linked provider', async () => { + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(404) + }) + + it('unlinks a provider', async () => { + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(200) + + const count = await prisma.oAuthAccount.count({ where: { userId: user.id } }) + expect(count).toBe(0) + }) + + it('rejects removing last method without password', async () => { + await prisma.oAuthAccount.create({ + data: { provider: 'vk', providerUserId: '123', userId: user.id }, + }) + const res = await app.inject({ + method: 'DELETE', url: '/api/me/oauth/vk', + headers: { authorization: `Bearer ${token}` }, + }) + expect(res.statusCode).toBe(400) + expect(JSON.parse(res.body).error).toContain('последний метод') + }) +}) +``` + +- [ ] **Step 2: Run tests** + +```bash +cd server && npx vitest run src/routes/__tests__/auth-methods.test.js +``` + +Expected: all tests pass. + +--- + +### Task 13: Run all server tests together + +- [ ] **Step 1: Run full server test suite** + +```bash +cd server && npx vitest run +``` + +Expected: all existing and new tests pass. + +--- + +### Task 14: Client — update Effector auth model + +**Files:** +- Modify: `client/src/shared/model/auth.ts` + +- [ ] **Step 1: Remove avatarType from AuthUser type and add new effects** + +```ts +import { createEffect, createEvent, createStore, sample } from 'effector' +import { apiClient } from '@/shared/api/client' +import { createErrorStore } from '@/shared/lib/create-error-store' +import { persistToken } from '@/shared/lib/persist-token' + +export type AuthUser = { + id: string + email: string + displayName?: string | null + firstName?: string | null + lastName?: string | null + gender?: string | null + avatar?: string | null + avatarStyle?: string | null + isAdmin?: boolean +} + +export type AuthMethod = { + type: 'password' | 'vk' | 'yandex' + active: boolean +} + +export const tokenSet = createEvent() +export const logout = createEvent() + +// ----- Token persistence ----- + +const persistTokenFx = createEffect({ + handler: (token) => persistToken(token), +}) + +export const $token = createStore(null) + .on(tokenSet, (_, t) => t) + .reset(logout) + +sample({ clock: $token, target: persistTokenFx }) + +// ----- User ----- + +export const $user = createStore(null).reset(logout) + +export const meFx = createEffect(async (token: string) => { + const { data } = await apiClient.get<{ user: AuthUser | null }>('me', { + headers: { Authorization: `Bearer ${token}` }, + }) + return data.user +}) + +sample({ clock: tokenSet, filter: (t): t is string => Boolean(t), target: meFx }) + +sample({ clock: meFx.doneData, target: $user }) + +// ----- Email change ----- + +export const requestEmailChangeCodeFx = createEffect(async (newEmail: string) => { + await apiClient.post('me/change-email/request-code', { newEmail }) +}) + +export const verifyEmailChangeFx = createEffect(async (params: { newEmail: string; code: string }) => { + const { data } = await apiClient.post<{ user: AuthUser }>('me/change-email/verify', params) + return data.user +}) + +// ----- Profile update ----- + +export type UpdateProfileParams = { + displayName: string | null + avatar?: string | null + avatarStyle?: string | null +} + +export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { + const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) + return data.user +}) + +// ----- Login / Register ----- + +export const loginFx = createEffect(async (params: { email: string; password: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/login', params) + tokenSet(data.token) + return data.user +}) + +export const registerFx = createEffect( + async (params: { email: string; password: string; displayName?: string }) => { + const { data } = await apiClient.post<{ token: string; user: AuthUser }>('auth/register', params) + tokenSet(data.token) + return data.user + }, +) + +// ----- Auth methods ----- + +export const fetchAuthMethodsFx = createEffect(async () => { + const { data } = await apiClient.get<{ methods: AuthMethod[] }>('me/auth-methods') + return data.methods +}) + +export const setPasswordFx = createEffect(async (password: string) => { + await apiClient.post('me/password', { password }) +}) + +export const unlinkOAuthFx = createEffect(async (provider: 'vk' | 'yandex') => { + await apiClient.delete(`me/oauth/${provider}`) +}) + +// ----- Error stores ----- + +export const $requestEmailChangeCodeError = createErrorStore(requestEmailChangeCodeFx).$error +export const $verifyEmailChangeError = createErrorStore(verifyEmailChangeFx).$error +export const $updateProfileError = createErrorStore(updateProfileFx).$error + +// ----- Re-exports ----- + +export { readStoredToken } from '@/shared/lib/persist-token' + +// ----- Sync user from profile/email changes ----- + +sample({ clock: [verifyEmailChangeFx.doneData, updateProfileFx.doneData], target: $user }) +``` + +--- + +### Task 15: Client — update UserAvatar (remove avatarType) + +**Files:** +- Modify: `client/src/shared/ui/UserAvatar.tsx` + +- [ ] **Step 1: Remove avatarType prop and always use DiceBear fallback** + +```tsx +import { useMemo } from 'react' +import Avatar from '@mui/material/Avatar' +import type { SxProps, Theme } from '@mui/material/styles' +import { createAvatar } from '@dicebear/core' +import { DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles' + +type UserAvatarProps = { + userId: string + avatarUrl?: string | null + avatarStyle?: string | null + size?: number + sx?: SxProps +} + +export function UserAvatar({ userId, avatarUrl, avatarStyle, size = 40, sx }: UserAvatarProps) { + const generatedSrc = useMemo(() => { + const styleDef = getStyleById(avatarStyle || DEFAULT_STYLE_ID) + const avatar = createAvatar(styleDef.style, { seed: userId }) + return avatar.toDataUri() + }, [userId, avatarStyle]) + + const src = avatarUrl || generatedSrc + + return ( + + ? + + ) +} +``` + +--- + +### Task 16: Client — update UserAvatar usages (remove avatarType prop) + +- [ ] **Step 1: Find and update all UserAvatar usages** + +Search for all `avatarType` passed to `UserAvatar` and remove them. Files to modify: + +- `client/src/pages/me/ui/sections/SettingsPage.tsx` — lines 131, 148 (remove `avatarType` prop) +- `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` — lines 147, 164 (remove `avatarType` prop) +- `client/src/features/user/user-menu/ui/UserMenu.tsx` — check for UserAvatar usage +- Any other files using UserAvatar with avatarType + +Remove `avatarType` prop from each `` usage. The prop no longer exists on the component. + +--- + +### Task 17: Client — rewrite AuthPage with tabs + +**Files:** +- Modify: `client/src/pages/auth/ui/AuthPage.tsx` + +- [ ] **Step 1: Rewrite complete AuthPage** + +```tsx +import { useEffect, useState } from 'react' +import Alert from '@mui/material/Alert' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import Tab from '@mui/material/Tab' +import Tabs from '@mui/material/Tabs' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useMutation } from '@tanstack/react-query' +import { useUnit } from 'effector-react' +import { useForm } from 'react-hook-form' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { OAuthButtons } from '@/features/auth-oauth' +import { apiClient } from '@/shared/api/client' +import { $user, loginFx, registerFx, tokenSet } from '@/shared/model/auth' + +type AuthResponse = { + token: string + user: { + id: string + email: string + displayName?: string | null + avatar?: string | null + avatarStyle?: string | null + } +} + +function getApiErrorMessage(err: unknown): string | null { + if (!err || typeof err !== 'object') return null + const anyErr = err as Record + const response = anyErr.response as Record | undefined + const data = response?.data as Record | undefined + const msg = data?.error + return typeof msg === 'string' ? msg : null +} + +export function AuthPage() { + const [message, setMessage] = useState(null) + const [oauthError, setOauthError] = useState(null) + const [tab, setTab] = useState(0) + const [isRegister, setIsRegister] = useState(false) + const [searchParams, setSearchParams] = useSearchParams() + const navigate = useNavigate() + const user = useUnit($user) + + const { register, watch } = useForm<{ + email: string + password: string + passwordConfirm: string + displayName: string + code: string + }>({ + defaultValues: { email: '', password: '', passwordConfirm: '', displayName: '', code: '' }, + mode: 'onChange', + }) + + const email = watch('email') + const password = watch('password') + const passwordConfirm = watch('passwordConfirm') + const code = watch('code') + + useEffect(() => { + if (user) navigate('/', { replace: true }) + }, [navigate, user]) + + useEffect(() => { + const err = searchParams.get('oauthError') + if (!err) return + setOauthError(err) + setSearchParams({}, { replace: true }) + }, [searchParams, setSearchParams]) + + const loginMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/login', { email, password }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const registerMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/register', { + email, + password, + displayName: watch('displayName') || undefined, + }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const requestCode = useMutation({ + mutationFn: async () => { + await apiClient.post('auth/request-code', { email }) + }, + onSuccess: () => setMessage('Код отправлен. Проверьте почту (в dev может быть в логах сервера).'), + }) + + const verifyCode = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.post('auth/verify-code', { email, code }) + tokenSet(data.token) + navigate('/', { replace: true }) + }, + }) + + const errMsg = + getApiErrorMessage(loginMutation.error) || + getApiErrorMessage(registerMutation.error) || + getApiErrorMessage(requestCode.error) || + getApiErrorMessage(verifyCode.error) + + const passwordError = + isRegister && passwordConfirm && password !== passwordConfirm ? 'Пароли не совпадают' : null + + return ( + + + Вход / регистрация + + + {message && {message}} + {oauthError && ( + setOauthError(null)}>{oauthError} + )} + {errMsg && {errMsg}} + + setTab(v)} sx={{ mb: 3 }}> + + + + + + {tab === 0 && ( + + + + + + + + + {isRegister && ( + + )} + + + + {isRegister && ( + + )} + + {isRegister ? ( + + ) : ( + + )} + + )} + + {tab === 1 && ( + + + + + + + + + )} + + {tab === 2 && ( + + + + )} + + ) +} +``` + +--- + +### Task 18: Client — add auth methods section to SettingsPage + +**Files:** +- Modify: `client/src/pages/me/ui/sections/SettingsPage.tsx` + +- [ ] **Step 1: Simplify avatar section — remove OAuth avatar switching** + +Replace the avatar section's state variables (lines 59-61): +```tsx +const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') +const useOAuth = user?.avatarType === 'oauth' +const useGenerated = user?.avatarType === 'generated' +``` + +With: +```tsx +// no more avatarType — always internal avatars +``` + +And replace the caption (lines 140): +```diff +- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} ++ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} +``` + +Remove the "Use OAuth" button block entirely (lines 208-222): +```diff +- {hasOAuthAvatar && !hasUnsavedPreview && ( +- +- )} +``` + +And in UserAvatar usage, remove `avatarType` prop (lines 131, 148): +```diff +- avatarType={hasUnsavedPreview ? 'generated' : user.avatarType} +``` +(just delete that prop line) + +Remove `avatarType` from updateProfileFx calls (lines 191-197 and any other): +```diff + updateProfileFx({ + displayName: user.displayName?.trim() || null, + avatar: previewSrc, +- avatarType: 'generated', + avatarStyle: previewStyle, + }) +``` + +- [ ] **Step 2: Add auth methods section — imports and state** + +Add imports at top: +```tsx +import { useCallback } from 'react' +import Chip from '@mui/material/Chip' +import { + fetchAuthMethodsFx, + setPasswordFx, + unlinkOAuthFx, + type AuthMethod, +} from '@/shared/model/auth' +``` + +Add state and data loading after existing hooks: +```tsx +const [authMethods, setAuthMethods] = useState([]) +const [showSetPassword, setShowSetPassword] = useState(false) +const passwordForm = useForm<{ password: string; passwordConfirm: string }>({ + defaultValues: { password: '', passwordConfirm: '' }, +}) + +useEffect(() => { + fetchAuthMethodsFx().then(setAuthMethods).catch(() => { + setAuthMethods([]) + }) +}, []) + +const setPasswordMutation = useMutation({ + mutationFn: async (pw: string) => { + await setPasswordFx(pw) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + setShowSetPassword(false) + }, + onError: () => {}, +}) + +const unlinkMutation = useMutation({ + mutationFn: async (provider: 'vk' | 'yandex') => { + await unlinkOAuthFx(provider) + const methods = await fetchAuthMethodsFx() + setAuthMethods(methods) + }, + onError: () => {}, +}) + +const linkedCount = useCallback(() => { + return authMethods.filter((m) => m.active).length +}, [authMethods]) + +const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } +``` + +- [ ] **Step 3: Add auth methods section UI** + +Insert after the avatar section's closing `` + `` (before email change section), but only if `!user.isAdmin`: + +```tsx +{!user.isAdmin && ( + <> + + + + Методы входа + + + {authMethods.map((m) => ( + + {METHOD_LABELS[m.type] || m.type} + + {m.active && m.type !== 'password' && ( + + )} + {!m.active && m.type === 'password' && ( + + )} + {!m.active && m.type !== 'password' && ( + + )} + + ))} + + + {showSetPassword && ( + + + + + + + + + )} + + +)} +``` + +--- + +### Task 19: Client — update AdminSettingsPage (remove avatarType) + +**Files:** +- Modify: `client/src/pages/admin-settings/ui/AdminSettingsPage.tsx` + +- [ ] **Step 1: Remove avatarType and OAuth avatar references** + +The AdminSettingsPage mirrors SettingsPage. Make these specific changes: + +1. Remove state lines (find the equivalent of `hasOAuthAvatar`, `useOAuth`, `useGenerated`): +```diff +- const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated') +- const useOAuth = user?.avatarType === 'oauth' +- const useGenerated = user?.avatarType === 'generated' +``` + +2. Remap the caption line: +```diff +- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'} ++ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'} +``` + +3. Remove the "Use OAuth" button block (find lines starting with `{hasOAuthAvatar && !hasUnsavedPreview`). + +4. Remove `avatarType` prop from all `` usages in this file. + +5. Remove `avatarType` from any `updateProfileFx` or admin profile API calls in this file. Find the PATCH call payload and remove the `avatarType` field. + +--- + +### Task 20: Client tests + +**Files:** +- Create: `client/src/pages/auth/__tests__/AuthPage.test.tsx` + +- [ ] **Step 1: Write AuthPage tests** + +```tsx +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { AuthPage } from '../ui/AuthPage' + +vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) +vi.mock('effector-react', async () => { + const actual = await vi.importActual('effector-react') + return { ...actual, useUnit: () => null } +}) + +function renderPage() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + , + ) +} + +describe('AuthPage', () => { + it('renders three tabs', () => { + renderPage() + expect(screen.getByText('Пароль')).toBeTruthy() + expect(screen.getByText('Код')).toBeTruthy() + expect(screen.getByText('Другой способ')).toBeTruthy() + }) + + it('shows login form by default on tab 0', () => { + renderPage() + expect(screen.getByText('Вход')).toBeTruthy() + expect(screen.getByText('Регистрация')).toBeTruthy() + const buttons = screen.getAllByRole('button') + const loginBtn = buttons.find((b) => b.textContent === 'Войти') + expect(loginBtn).toBeTruthy() + }) + + it('switches to register form', () => { + renderPage() + fireEvent.click(screen.getByText('Регистрация')) + expect(screen.getByText('Зарегистрироваться')).toBeTruthy() + }) + + it('switches to code tab', () => { + renderPage() + fireEvent.click(screen.getByText('Код')) + expect(screen.getByText('Отправить код')).toBeTruthy() + }) + + it('switches to OAuth tab', () => { + renderPage() + fireEvent.click(screen.getByText('Другой способ')) + expect(screen.getByText('Войти через VK ID')).toBeTruthy() + expect(screen.getByText('Войти через Яндекс ID')).toBeTruthy() + }) +}) +``` + +- [ ] **Step 2: Run client tests** + +```bash +cd client && npx vitest run src/pages/auth/__tests__/AuthPage.test.tsx +``` + +Expected: all 5 tests pass. + +--- + +### Task 21: Run full test suite + +- [ ] **Step 1: Run server tests** + +```bash +cd server && npx vitest run +``` + +- [ ] **Step 2: Run client tests** + +```bash +cd client && npx vitest run +``` + +- [ ] **Step 3: Run client lint + format check** + +```bash +cd client && npm run lint && npm run format:check +``` + +- [ ] **Step 4: Run client build** + +```bash +cd client && npm run build +``` + +All must pass. diff --git a/docs/superpowers/specs/2026-05-22-auth-redesign-design.md b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md new file mode 100644 index 0000000..af2cad9 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-auth-redesign-design.md @@ -0,0 +1,300 @@ +# Auth Redesign — Spec + +**Date:** 2026-05-22 +**Goal:** Переработать систему аутентификации: OAuth запрашивает только email, убрать внешние аватары, добавить вход по email+паролю, дать пользователям связывать методы входа в ЛК. + +--- + +## 1. Data Model (Prisma) + +### 1.1. Модель `User` — изменения + +| Поле | Было | Стало | +|------|------|-------| +| `passwordHash` | `String?` (не использовалось) | Задействуем. Хранит bcrypt-хеш. `null` если пароль не установлен. | +| `avatarType` | `String?` (`'oauth'` / `'generated'`) | **Удалить.** Все аватары внутренние (DiceBear). | +| `avatar` | `String?` (URL или data:uri) | Только DiceBear URL или `null` (генерируется на лету) | +| `avatarStyle` | `String?` | Без изменений. | + +Остальные поля (`id`, `email`, `displayName`, `firstName`, `lastName`, `gender`, `createdAt`, `updatedAt`) — без изменений. `firstName`, `lastName`, `gender` больше не заполняются при OAuth, но остаются в БД (могут быть заполнены пользователем вручную позже). + +### 1.2. Модель `OAuthAccount` — без изменений + +```prisma +model OAuthAccount { + id String @id @default(cuid()) + provider String // 'vk' | 'yandex' + providerUserId String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accessToken String? + refreshToken String? // зарезервировано, не используется сейчас + createdAt DateTime @default(now()) + + @@unique([provider, providerUserId]) +} +``` + +### 1.3. Модель `AuthCode` — без изменений + +### 1.4. Миграции + +1. Удаление колонки `avatarType` из `User` +2. Миграция данных: для всех пользователей с `avatarType = 'oauth'` установить `avatar = null` (внешние URL больше не используются, аватар перегенерируется DiceBear) + +--- + +## 2. Авторизация по email+паролю + +### 2.1. Регистрация + +`POST /api/auth/register` (новый, без аутентификации) + +**Request:** +```json +{ + "email": "user@example.com", + "password": "Abcdef1!", + "displayName": "Иван" // optional +} +``` + +**Валидация:** +- `email`: валидный email, нормализация (trim + lowercase), уникальность +- `password`: минимум 8 символов, минимум 1 буква, 1 цифра, 1 спецсимвол +- `displayName`: опционально, строка до 100 символов. Если не передан — берётся часть email до `@` + +**Логика:** +1. Проверка, что email не занят → 409 если занят +2. `passwordHash = await bcrypt.hash(password, 10)` +3. Создание пользователя: `email`, `passwordHash`, `displayName`, `avatar = null`, `avatarStyle = 'avataaars'` +4. Создание `NotificationPreference` (как сейчас в verify-code) +5. Возврат JWT + user + +**Response 201:** +```json +{ + "token": "jwt...", + "user": { "id", "email", "displayName", "avatar", "avatarStyle", "isAdmin": false } +} +``` + +### 2.2. Вход + +`POST /api/auth/login` (новый, без аутентификации) + +**Request:** +```json +{ + "email": "user@example.com", + "password": "Abcdef1!" +} +``` + +**Rate limit:** максимум 5 попыток в минуту с одного IP (использовать `@fastify/rate-limit`). При превышении — `429 Too Many Requests`. + +**Логика:** +1. Нормализация email +2. Поиск пользователя по email +3. Если пользователь не найден ИЛИ `passwordHash === null` → `401 Invalid email or password` (одинаковый ответ для безопасности) +4. `await bcrypt.compare(password, user.passwordHash)` → если не совпадает → `401` +5. Возврат JWT + user + +### 2.3. Админ и пароль + +- Админ (`email === ADMIN_EMAIL`) **не может** зарегистрироваться или войти по паролю +- `POST /api/auth/register` и `POST /api/auth/login` возвращают `403` для админского email +- Админ также **не может** установить пароль через `POST /api/me/password` + +--- + +## 3. OAuth (только email) + +### 3.1. Scope + +| Провайдер | Было | Стало | +|-----------|------|-------| +| VK | `email` | `email` (без изменений, но больше не запрашиваем профиль) | +| Яндекс | `login:email login:info` | `login:email` | + +### 3.2. Callback — что убираем + +**VK:** +- Больше не делаем `users.get` после получения токена +- Не сохраняем: `first_name`, `last_name`, `photo_200`, `sex` + +**Яндекс:** +- Всё ещё вызываем `GET https://login.yandex.ru/info` (нужен для получения email) +- Из ответа берём только `default_email` или первый из `emails` +- **Не сохраняем:** `first_name`, `last_name`, `display_name`, `sex`, `default_avatar_id` + +### 3.3. Callback — новая логика + +``` +1. Обмен code на access_token (как сейчас) +2. Извлечение email из ответа провайдера: + - VK: поле `email` в ответе access_token + - Яндекс: вызываем `/info`, из ответа берём `default_email` или первый из `emails` +3. Если email отсутствует → редирект с ?oauthError=no_email +4. Нормализация email +5. Поиск пользователя по email: + a) Найден → привязываем OAuthAccount (если ещё не привязан), возвращаем JWT + b) Не найден → создаём нового: + - email + - displayName = часть email до @ + - avatar = null + - avatarStyle = 'avataaars' + - Создаём OAuthAccount + - Создаём NotificationPreference + - Возвращаем JWT +6. Редирект на CLIENT_PUBLIC_URL/auth/callback?token=... +``` + +**Fallback-email `{provider}_{id}@oauth.craftshop.local` — убираем.** Если провайдер не дал email — ошибка. + +### 3.4. State-параметр + +Без изменений. JWT с `expiresIn: 15m` для CSRF-защиты. + +--- + +## 4. Связывание аккаунтов + +### 4.1. Авто-связывание + +При OAuth-входе: если email из OAuth совпадает с email существующего пользователя — автоматически создаётся `OAuthAccount`, связывающий провайдера с пользователем. Вход происходит мгновенно. + +### 4.2. Ручное связывание — страница настроек `/me` + +**Получение статуса методов:** `GET /api/me/auth-methods` (новый, требует `authenticate`) + +**Response:** +```json +{ + "methods": [ + { "type": "password", "active": true }, + { "type": "vk", "active": false }, + { "type": "yandex", "active": true } + ] +} +``` + +Логика: +- `type: "password"` — `active: user.passwordHash !== null` +- `type: "vk"` / `type: "yandex"` — `active: exists(OAuthAccount)` + +### 4.3. Привязка OAuth + +`GET /api/auth/oauth/{provider}/link` (новый, требует `authenticate`) + +1. Генерирует state-JWT с `{ userId, provider, action: 'link' }`, `expiresIn: 15m` +2. Редиректит на страницу авторизации провайдера +3. После callback проверяет `action: 'link'` в state +4. Создаёт `OAuthAccount` для указанного `userId` (нормальный upsert) +5. Редиректит на `/me?linked={provider}` + +### 4.4. Привязка пароля + +`POST /api/me/password` (новый, требует `authenticate`) + +**Request:** `{ "password": "Abcdef1!" }` + +**Логика:** +1. Если `user.email === ADMIN_EMAIL` → `403` +2. Если `user.passwordHash !== null` → `409 Password already set` (для смены использовать отдельный метод) +3. Валидация пароля (8+, буква+цифра+спецсимвол) +4. `user.passwordHash = await bcrypt.hash(password, 10)` +5. Сохранение пользователя + +### 4.5. Отвязывание + +`DELETE /api/me/oauth/{provider}` (новый, требует `authenticate`) + +**Логика:** +1. Удаление `OAuthAccount` по `userId + provider` +2. Если `OAuthAccount` не найден → `404` +3. **Проверка последнего метода:** после удаления, если у пользователя нет ни `passwordHash`, ни других `OAuthAccount` → `400 Cannot remove last auth method` +4. Возврат `200` + +**Примечание:** отвязывание пароля (установка `passwordHash = null`) пока не делаем — можно добавить позже. + +### 4.6. Админ и связывание + +- `POST /api/me/password` → `403` для админа +- OAuth-привязка через `/link` → `403` для админа +- OAuth-отвязывание → `403` для админа + +--- + +## 5. Email-код (без изменений логики) + +`POST /api/auth/request-code` и `POST /api/auth/verify-code` работают как раньше, изменений нет. Админ входит только этим способом. + +--- + +## 6. Изменения на клиенте + +### 6.1. Страница `/auth` + +**3 вкладки:** +- **«Пароль»** — переключатель Вход/Регистрация. Вход: email + пароль. Регистрация: email + пароль + подтверждение пароля + имя (опционально). +- **«Код»** — как сейчас: email → отправить код → ввести код. +- **«Другой способ»** — кнопки Войти через VK / Яндекс. + +### 6.2. Страница `/me` (настройки) + +Новая секция «Методы входа»: +- Список методов с индикаторами «привязан» / «не привязан» +- Кнопки «Привязать» (редирект на OAuth или форма пароля) +- Кнопки «Отвязать» (disabled если это последний метод) +- Для админа — секция скрыта + +### 6.3. Effector-стейт + +- `$token`, `$user`, `tokenSet`, `logout` — без изменений +- Добавить эффекты: `loginFx`, `registerFx`, `linkOAuthFx`, `setPasswordFx`, `unlinkOAuthFx` + +### 6.4. Компоненты + +- `UserAvatar` — убрать проверку `avatarType`, всегда использовать DiceBear (сохранённый `avatar` или генерация на лету) +- `OAuthButtons` — без изменений (URL те же) + +--- + +## 7. Тестирование + +### 7.1. Серверные тесты + +- `POST /api/auth/register` — успешная регистрация, дубликат email, слабый пароль +- `POST /api/auth/login` — успешный вход, неверный пароль, несуществующий email, превышение rate limit +- OAuth callback — создание нового пользователя с email, авто-связывание по email, ошибка при отсутствии email от провайдера +- `POST /api/me/password` — установка, повторная установка (409), админ (403) +- `GET /api/me/auth-methods` — корректный список методов +- `DELETE /api/me/oauth/{provider}` — отвязывание, последний метод (400), админ (403) +- Админ не может войти через `/login` (403) + +### 7.2. Клиентские тесты + +- Страница `/auth` — наличие трёх вкладок, переключение +- Форма регистрации — валидация пароля, подтверждение +- Форма входа — обработка ошибок +- `/me` — отображение методов, кнопки привязки/отвязки + +--- + +## 8. Миграция существующих пользователей + +1. Все пользователи с `avatarType = 'oauth'`: `avatar = null`, `avatarType = null`. Аватар перегенерируется DiceBear при следующем отображении. +2. `avatarType` колонка удаляется из БД. +3. Существующие OAuth-аккаунты работают как раньше, но при следующем входе через OAuth обновляется логика (не запрашиваем профиль). +4. `firstName`, `lastName`, `gender` у существующих OAuth-пользователей остаются в БД (не удаляем, просто больше не пополняем из OAuth). + +--- + +## 9. Заметки + +- **bcrypt** — не установлен, нужно добавить `npm install bcrypt` в server. +- **Rate limit** — `@fastify/rate-limit` не установлен. Добавить или реализовать самодельный in-memory rate limiter (5 попыток/мин/IP). +- Все новые эндпоинты должны валидироваться через JSON Schema (как существующие). +- Пароль никогда не возвращается в ответах API и не логируется. +- Существующий `User.passwordHash` (null у всех) — колонка уже есть, миграция БД не нужна, просто начинаем использовать. diff --git a/server/package-lock.json b/server/package-lock.json index 1186824..48993e2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,6 +13,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/static": "^9.1.3", "@prisma/client": "5.22.0", + "bcrypt": "^6.0.0", "dotenv": "^17.4.2", "fastify": "^5.8.5", "nodemailer": "^8.0.7", @@ -2130,6 +2131,29 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3605,6 +3629,17 @@ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemailer": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz", diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 53dd97b..e8e8ce5 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/lib/rate-limit.js b/server/src/lib/rate-limit.js index e37a9f0..7198aaa 100644 --- a/server/src/lib/rate-limit.js +++ b/server/src/lib/rate-limit.js @@ -3,6 +3,13 @@ const windows = new Map() const MAX_ATTEMPTS = 5 const WINDOW_MS = 60_000 +setInterval(() => { + const now = Date.now() + for (const [ip, entry] of windows) { + if (now - entry.start > WINDOW_MS) windows.delete(ip) + } +}, 5 * 60_000).unref() + export function checkLoginRateLimit(ip) { const now = Date.now() const entry = windows.get(ip) diff --git a/server/src/routes/api/admin-profile.js b/server/src/routes/api/admin-profile.js index d69f0c5..748d45c 100644 --- a/server/src/routes/api/admin-profile.js +++ b/server/src/routes/api/admin-profile.js @@ -11,7 +11,6 @@ export async function registerAdminProfileRoutes(fastify) { email: user.email, displayName: user.displayName, avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, } }) @@ -25,7 +24,6 @@ export async function registerAdminProfileRoutes(fastify) { return { avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, } }) @@ -37,17 +35,12 @@ export async function registerAdminProfileRoutes(fastify) { nameRaw === undefined ? undefined : nameRaw === null ? null : nameRaw === '' ? null : String(nameRaw).trim() const avatarRaw = request.body?.avatar const avatar = avatarRaw === null || avatarRaw === undefined ? undefined : String(avatarRaw).trim() - const avatarTypeRaw = request.body?.avatarType - const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim() const avatarStyleRaw = request.body?.avatarStyle const avatarStyle = avatarStyleRaw === null || avatarStyleRaw === undefined ? undefined : String(avatarStyleRaw).trim() if (displayName !== undefined && displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) - if (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { - return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) - } 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: 'Стиль аватара слишком длинный' }) @@ -57,9 +50,6 @@ export async function registerAdminProfileRoutes(fastify) { if (displayName !== undefined) { data.displayName = displayName && displayName.length ? displayName : null } - if (avatarType !== undefined) { - data.avatarType = avatarType === '' ? null : avatarType - } if (avatar !== undefined) { data.avatar = avatar === '' ? null : avatar } @@ -73,7 +63,6 @@ export async function registerAdminProfileRoutes(fastify) { email: updated.email, displayName: updated.displayName, avatar: updated.avatar, - avatarType: updated.avatarType, avatarStyle: updated.avatarStyle, } }) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index ce870a8..5d44129 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -22,7 +22,6 @@ function mapUserForClient(user) { lastName: user.lastName, gender: user.gender, avatar: user.avatar, - avatarType: user.avatarType, avatarStyle: user.avatarStyle, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } @@ -197,17 +196,12 @@ export async function registerAuthRoutes(fastify) { 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 avatarTypeRaw = request.body?.avatarType - const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).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 (avatarType !== undefined && avatarType !== 'oauth' && avatarType !== 'generated' && avatarType !== '') { - return reply.code(400).send({ error: 'avatarType должен быть oauth | generated | пустая строка' }) - } 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: 'Стиль аватара слишком длинный' }) @@ -217,9 +211,6 @@ export async function registerAuthRoutes(fastify) { displayName: displayName && displayName.length ? displayName : null, } - if (avatarType !== undefined) { - data.avatarType = avatarType === '' ? null : avatarType - } if (avatar !== undefined) { data.avatar = avatar === '' ? null : avatar }