Files
shop-server/docs/superpowers/plans/2026-05-22-auth-redesign.md
T

54 KiB
Raw Blame History

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

cd server && npm install bcrypt
  • Step 2: Verify install
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?.

  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:

-- Before ALTER TABLE DROP COLUMN:
UPDATE User SET avatar = NULL WHERE avatarType = 'oauth';
  • Step 3: Run migration
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
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

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

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
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: <some positive number>


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:

import { hashPassword, isAdminEmail, validatePassword } from '../lib/auth.js'

Add route inside registerAuthRoutes, after the verify-code route and before /api/me:

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:

import { comparePassword, isAdminEmail } from '../lib/auth.js'
import { checkLoginRateLimit } from '../lib/rate-limit.js'

Add route inside registerAuthRoutes, after register route:

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:

    avatar: user.avatar,
-   avatarType: user.avatarType,
    avatarStyle: user.avatarStyle,

Edit the profile PATCH route in same file, remove all avatarType handling:

    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:

-   const avatarTypeRaw = request.body?.avatarType
-   const avatarType = avatarTypeRaw === null || avatarTypeRaw === undefined ? undefined : String(avatarTypeRaw).trim()
    const avatarStyleRaw = request.body?.avatarStyle

And in data construction:

-   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):

      avatar: user.avatar,
-     avatarType: user.avatarType,
      avatarStyle: user.avatarStyle,

Remove avatarType from GET /api/admin/avatar response (line 28):

      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:

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:

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):

-   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:

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:

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:

let statePayload = null
try {
  const raw = typeof query.state === 'string' ? query.state : ''
  statePayload = fastify.jwt.verify(raw || '')
} catch {
  return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}

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:

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:

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())
})

Files:

  • Modify: server/src/routes/auth.js

  • Step 1: Add GET /api/me/auth-methods

Add route in registerAuthRoutes, after /api/me:

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:

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:

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

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
cd server && npx vitest run src/routes/__tests__/auth-password.test.js

Expected: all tests pass.


Files:

  • Create: server/src/routes/__tests__/auth-methods.test.js

  • Step 1: Write tests

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
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
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

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<string | null>()
export const logout = createEvent()

// ----- Token persistence -----

const persistTokenFx = createEffect<string | null, void>({
  handler: (token) => persistToken(token),
})

export const $token = createStore<string | null>(null)
  .on(tokenSet, (_, t) => t)
  .reset(logout)

sample({ clock: $token, target: persistTokenFx })

// ----- User -----

export const $user = createStore<AuthUser | null>(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

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<Theme>
}

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 (
    <Avatar src={src} sx={{ width: size, height: size, bgcolor: 'primary.main', ...sx }}>
      ?
    </Avatar>
  )
}

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 <UserAvatar> 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

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<string, unknown>
  const response = anyErr.response as Record<string, unknown> | undefined
  const data = response?.data as Record<string, unknown> | undefined
  const msg = data?.error
  return typeof msg === 'string' ? msg : null
}

export function AuthPage() {
  const [message, setMessage] = useState<string | null>(null)
  const [oauthError, setOauthError] = useState<string | null>(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<AuthResponse>('auth/login', { email, password })
      tokenSet(data.token)
      navigate('/', { replace: true })
    },
  })

  const registerMutation = useMutation({
    mutationFn: async () => {
      const { data } = await apiClient.post<AuthResponse>('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<AuthResponse>('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 (
    <Box>
      <Typography variant="h4" gutterBottom>
        Вход / регистрация
      </Typography>

      {message && <Alert severity="success" sx={{ mb: 2 }}>{message}</Alert>}
      {oauthError && (
        <Alert severity="error" sx={{ mb: 2 }} onClose={() => setOauthError(null)}>{oauthError}</Alert>
      )}
      {errMsg && <Alert severity="error" sx={{ mb: 2 }}>{errMsg}</Alert>}

      <Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
        <Tab label="Пароль" />
        <Tab label="Код" />
        <Tab label="Другой способ" />
      </Tabs>

      {tab === 0 && (
        <Stack spacing={2} sx={{ maxWidth: 520 }}>
          <Stack direction="row" spacing={1}>
            <Button variant={!isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(false)}>
              Вход
            </Button>
            <Button variant={isRegister ? 'contained' : 'outlined'} onClick={() => setIsRegister(true)}>
              Регистрация
            </Button>
          </Stack>

          <TextField label="Email" {...register('email')} fullWidth />

          {isRegister && (
            <TextField
              label="Имя (необязательно)"
              {...register('displayName')}
              fullWidth
              helperText="Если не указать, будет использована часть email до @"
            />
          )}

          <TextField label="Пароль" type="password" {...register('password')} fullWidth />

          {isRegister && (
            <TextField
              label="Подтверждение пароля"
              type="password"
              {...register('passwordConfirm')}
              fullWidth
              error={Boolean(passwordError)}
              helperText={passwordError}
            />
          )}

          {isRegister ? (
            <Button
              variant="contained"
              disabled={
                !email ||
                !password ||
                password.length < 8 ||
                (isRegister && password !== passwordConfirm) ||
                registerMutation.isPending
              }
              onClick={() => registerMutation.mutate()}
            >
              Зарегистрироваться
            </Button>
          ) : (
            <Button
              variant="contained"
              disabled={!email || !password || loginMutation.isPending}
              onClick={() => loginMutation.mutate()}
            >
              Войти
            </Button>
          )}
        </Stack>
      )}

      {tab === 1 && (
        <Stack spacing={2} sx={{ maxWidth: 520 }}>
          <TextField label="Email" {...register('email')} fullWidth />
          <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
            <Button
              variant="outlined"
              onClick={() => requestCode.mutate()}
              disabled={!email || requestCode.isPending}
            >
              Отправить код
            </Button>
            <TextField label="Код (6 цифр)" inputMode="numeric" {...register('code')} />
            <Button
              variant="contained"
              onClick={() => verifyCode.mutate()}
              disabled={!email || code.length !== 6 || verifyCode.isPending}
            >
              Войти
            </Button>
          </Stack>
        </Stack>
      )}

      {tab === 2 && (
        <Stack sx={{ maxWidth: 520 }}>
          <OAuthButtons />
        </Stack>
      )}
    </Box>
  )
}

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):

const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
const useOAuth = user?.avatarType === 'oauth'
const useGenerated = user?.avatarType === 'generated'

With:

// no more avatarType — always internal avatars

And replace the caption (lines 140):

- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}

Remove the "Use OAuth" button block entirely (lines 208-222):

- {hasOAuthAvatar && !hasUnsavedPreview && (
-   <Button variant="outlined" ...>Использовать OAuth</Button>
- )}

And in UserAvatar usage, remove avatarType prop (lines 131, 148):

- avatarType={hasUnsavedPreview ? 'generated' : user.avatarType}

(just delete that prop line)

Remove avatarType from updateProfileFx calls (lines 191-197 and any other):

  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:

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:

const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
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<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
  • Step 3: Add auth methods section UI

Insert after the avatar section's closing </Box> + <Divider /> (before email change section), but only if !user.isAdmin:

{!user.isAdmin && (
  <>
    <Divider />
    <Box>
      <Typography variant="h6" gutterBottom>
        Методы входа
      </Typography>
      <Stack spacing={1}>
        {authMethods.map((m) => (
          <Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
            <Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
            <Chip
              label={m.active ? 'Привязан' : 'Не привязан'}
              color={m.active ? 'success' : 'default'}
              size="small"
            />
            {m.active && m.type !== 'password' && (
              <Button
                size="small"
                variant="outlined"
                color="error"
                disabled={linkedCount() <= 1}
                onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
              >
                Отвязать
              </Button>
            )}
            {!m.active && m.type === 'password' && (
              <Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
                Установить пароль
              </Button>
            )}
            {!m.active && m.type !== 'password' && (
              <Button
                size="small"
                variant="outlined"
                component="a"
                href={`/api/auth/oauth/${m.type}/link`}
              >
                Привязать
              </Button>
            )}
          </Stack>
        ))}
      </Stack>

      {showSetPassword && (
        <Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
          <TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
          <TextField
            label="Подтверждение пароля"
            type="password"
            {...passwordForm.register('passwordConfirm')}
            fullWidth
            error={
              Boolean(passwordForm.watch('passwordConfirm')) &&
              passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
            }
            helperText={
              passwordForm.watch('passwordConfirm') &&
              passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
                ? 'Пароли не совпадают'
                : null
            }
          />
          <Stack direction="row" spacing={1}>
            <Button
              variant="contained"
              disabled={
                !passwordForm.watch('password') ||
                passwordForm.watch('password').length < 8 ||
                passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
                setPasswordMutation.isPending
              }
              onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
            >
              Сохранить
            </Button>
            <Button variant="text" onClick={() => setShowSetPassword(false)}>
              Отмена
            </Button>
          </Stack>
        </Stack>
      )}
    </Box>
  </>
)}

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):
- const hasOAuthAvatar = Boolean(user?.avatar && user.avatarType !== 'generated')
- const useOAuth = user?.avatarType === 'oauth'
- const useGenerated = user?.avatarType === 'generated'
  1. Remap the caption line:
- {hasUnsavedPreview ? 'Предпросмотр' : useOAuth ? 'Сохранён' : useGenerated ? 'Сохранён' : 'Авто'}
+ {hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
  1. Remove the "Use OAuth" button block (find lines starting with {hasOAuthAvatar && !hasUnsavedPreview).

  2. Remove avatarType prop from all <UserAvatar> usages in this file.

  3. 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

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(
    <QueryClientProvider client={qc}>
      <MemoryRouter>
        <AuthPage />
      </MemoryRouter>
    </QueryClientProvider>,
  )
}

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
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
cd server && npx vitest run
  • Step 2: Run client tests
cd client && npx vitest run
  • Step 3: Run client lint + format check
cd client && npm run lint && npm run format:check
  • Step 4: Run client build
cd client && npm run build

All must pass.