Files
shop-server/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md
T
2026-05-20 10:35:43 +05:00

23 KiB

Yandex ID + VK ID OAuth — 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: Add Yandex and VK OAuth login buttons to the client, enrich user profiles with firstName/lastName/gender/avatar from OAuth providers, and rename namedisplayName across the project.

Architecture: Server OAuth flow already exists (oauth-social.js). We update the User model (rename namedisplayName, add 4 fields), enrich findOrCreateUserFromOAuth() to save profile data, then add an FSD feature features/auth-oauth/ with Yandex/VK login buttons on the AuthPage.

Tech Stack: Prisma (SQLite), Fastify, React + MUI + Effector + TypeScript


Task 1: Update Prisma User model

Files:

  • Modify: server/prisma/schema.prisma:80

  • Step 1: Rename namedisplayName and add new fields

 model User {
   id           String   @id @default(cuid())
   email        String   @unique
-  name         String?
+  displayName  String?
+  firstName    String?
+  lastName     String?
+  gender       String?
+  avatar       String?
   phone        String?
   passwordHash String?
   createdAt    DateTime @default(now())
   updatedAt    DateTime @updatedAt

   codes AuthCode[]
   addresses ShippingAddress[]
   cartItems CartItem[]
   orders Order[]
   reviews Review[]
   orderMessageReadStates UserOrderMessageReadState[]
   oauthAccounts OAuthAccount[]
   notificationPreference NotificationPreference?
   notificationLogs NotificationLog[]
 }
  • Step 2: Reset DB to apply schema change
cd server && npm run db:reset:test

Expected: "Your database has been reset" or similar. Migration runs and seed applies.

  • Step 3: Commit
git add server/prisma/schema.prisma
git commit -m "feat: rename User.name→displayName, add firstName/lastName/gender/avatar"

Task 2: Update server mapUserForClient and profile handler in auth.js

Files:

  • Modify: server/src/routes/auth.js:5-15

  • Modify: server/src/routes/auth.js:116-138

  • Step 1: Update mapUserForClient to use displayName and add new fields

Replace lines 5-15 with:

function mapUserForClient(user) {
  const adminEmail = normalizeEmail(process.env.ADMIN_EMAIL)
  const userEmail = normalizeEmail(user.email)
  return {
    id: user.id,
    email: user.email,
    displayName: user.displayName,
    firstName: user.firstName,
    lastName: user.lastName,
    gender: user.gender,
    avatar: user.avatar,
    phone: user.phone,
    isAdmin: Boolean(adminEmail) && userEmail === adminEmail,
  }
}
  • Step 2: Update PATCH /api/me/profile to use displayName

Replace lines 114-138 with:

  fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => {
    const userId = request.user.sub
    const nameRaw = request.body?.displayName
    const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
    const phoneRaw = request.body?.phone
    const phone = phoneRaw === null || phoneRaw === undefined ? null : String(phoneRaw).trim()

    if (displayName !== null && displayName.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
    if (phone !== null) {
      const compact = phone.replace(/[\s()-]/g, '')
      if (compact.length > 20) return reply.code(400).send({ error: 'Телефон слишком длинный' })
      if (compact.length && !/^\+?\d{7,20}$/.test(compact)) {
        return reply.code(400).send({ error: 'Некорректный телефон' })
      }
    }

    const updated = await prisma.user.update({
      where: { id: userId },
      data: {
        displayName: displayName && displayName.length ? displayName : null,
        phone: phone && phone.length ? phone : null,
      },
    })
    return { user: mapUserForClient(updated) }
  })
  • Step 3: Commit
git add server/src/routes/auth.js
git commit -m "feat: use displayName in mapUserForClient and profile update"

Task 3: Update admin-users.js — rename namedisplayName

Files:

  • Modify: server/src/routes/api/admin-users.js

  • Step 1: Replace all namedisplayName throughout

Replace line 24 (OR clause):

          OR: [{ email: { contains: q } }, { displayName: { contains: q } }],

Replace line 35 (select):

        displayName: true,

Replace line 46 (map):

      displayName: u.displayName,

Replace lines 63-64 (create — body field and validation):

    const nameRaw = body.displayName
    const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
    if (displayName !== null && displayName.length > 40) {

Replace line 79 (create data):

        displayName: displayName && displayName.length ? displayName : null,

Replace line 86 (create response):

      displayName: user.displayName,

Replace lines 120-127 (update):

    if (body.displayName !== undefined) {
      const nameRaw = body.displayName
      const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim()
      if (name !== null && name.length > 40) {
        reply.code(400).send({ error: 'Имя/ник максимум 40 символов' })
        return
      }
      data.displayName = name && name.length ? name : null
    }

Replace line 134 (update response):

      displayName: user.displayName,
  • Step 2: Commit
git add server/src/routes/api/admin-users.js
git commit -m "refactor: rename name→displayName in admin-users"

Task 4: Update admin-reviews.js and review-display.js — rename namedisplayName

Files:

  • Modify: server/src/routes/api/admin-reviews.js:59

  • Modify: server/src/lib/review-display.js:4

  • Step 1: Update admin-reviews.js line 59

Replace:

      userName: existing.user?.name || existing.user?.email || '',

With:

      userName: existing.user?.displayName || existing.user?.email || '',
  • Step 2: Update review-display.js line 4

Replace:

  const name = typeof user.name === 'string' ? user.name.trim() : ''

With:

  const name = typeof user.displayName === 'string' ? user.displayName.trim() : ''
  • Step 3: Commit
git add server/src/routes/api/admin-reviews.js server/src/lib/review-display.js
git commit -m "refactor: rename name→displayName in review files"

Task 5: Enrich VK OAuth — save firstName, lastName, gender, avatar

Files:

  • Modify: server/src/routes/oauth-social.js:119-152

  • Step 1: Update VK to fetch photo_200 instead of photo_50, extract sex, and save all new fields

Replace lines 119-152 with:

    let firstName = null
    let lastName = null
    let gender = null
    let avatar = null
    try {
      if (accessTokenVk && vkUserId) {
        const u = new URL('https://api.vk.com/method/users.get')
        u.searchParams.set('access_token', accessTokenVk)
        u.searchParams.set('users_ids', String(vkUserId))
        u.searchParams.set('fields', 'photo_200,sex')
        u.searchParams.set('v', '5.199')
        const profRes = await fetch(u.toString())
        const prof = await profRes.json()
        const u0 = prof?.response?.[0]
        if (u0) {
          firstName = u0.first_name ?? null
          lastName = u0.last_name ?? null
          avatar = u0.photo_200 ?? null
          if (u0.sex === 1) gender = 'female'
          else if (u0.sex === 2) gender = 'male'
        }
      }
    } catch {
      // ignore profile extras
    }

    const user = await findOrCreateUserFromOAuth({
      provider: 'vk',
      providerUserId: String(vkUserId),
      accessToken: accessTokenVk ?? null,
      suggestedEmail: emailSuggestion,
    })

    const displayName = [firstName, lastName].filter(Boolean).join(' ').trim()
    const updateData = {}
    if (displayName && !user.displayName) updateData.displayName = displayName
    if (firstName) updateData.firstName = firstName
    if (lastName) updateData.lastName = lastName
    if (gender) updateData.gender = gender
    if (avatar) updateData.avatar = avatar
    if (Object.keys(updateData).length > 0) {
      await prisma.user.update({ where: { id: user.id }, data: updateData })
    }
  • Step 2: Commit
git add server/src/routes/oauth-social.js
git commit -m "feat: enrich VK OAuth with firstName/lastName/gender/avatar"

Task 6: Enrich Yandex OAuth — save firstName, lastName, gender, avatar

Files:

  • Modify: server/src/routes/oauth-social.js:229-243

  • Step 1: Update Yandex to extract and save all new fields

Replace lines 229-243 (from const user = await findOrCreateUserFromOAuth(...) to the end of the Yandex callback) with:

    const user = await findOrCreateUserFromOAuth({
      provider: 'yandex',
      providerUserId: yaUserId,
      accessToken: yaToken,
      suggestedEmail: emailGuess || null,
    })

    const updateData = {}
    const displayName = [info.first_name, info.last_name].filter(Boolean).join(' ').trim() || info.display_name || info.real_name
    if (displayName && !user.displayName) updateData.displayName = displayName
    if (info.first_name) updateData.firstName = info.first_name
    if (info.last_name) updateData.lastName = info.last_name
    if (info.sex === 'male' || info.sex === 'female') updateData.gender = info.sex
    if (info.default_avatar_id && !info.is_avatar_empty) {
      updateData.avatar = `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200`
    }
    if (Object.keys(updateData).length > 0) {
      await prisma.user.update({ where: { id: user.id }, data: updateData })
    }
  • Step 2: Commit
git add server/src/routes/oauth-social.js
git commit -m "feat: enrich Yandex OAuth with firstName/lastName/gender/avatar"

Task 7: Update client AuthUser type and UpdateProfileParams

Files:

  • Modify: client/src/shared/model/auth.ts:6

  • Modify: client/src/shared/model/auth.ts:61

  • Step 1: Update AuthUser type

Replace line 6:

export type AuthUser = { id: string; email: string; name?: string | null; phone?: string | null; isAdmin?: boolean }

With:

export type AuthUser = {
  id: string
  email: string
  displayName?: string | null
  firstName?: string | null
  lastName?: string | null
  gender?: string | null
  avatar?: string | null
  phone?: string | null
  isAdmin?: boolean
}
  • Step 2: Update UpdateProfileParams

Replace line 61:

export type UpdateProfileParams = { name: string | null; phone?: string | null }

With:

export type UpdateProfileParams = { displayName: string | null; phone?: string | null }
  • Step 3: Commit
git add client/src/shared/model/auth.ts
git commit -m "refactor: rename name→displayName in AuthUser type"

Task 8: Update client files — rename namedisplayName

Files:

  • Modify: client/src/pages/auth/ui/AuthPage.tsx:15

  • Modify: client/src/pages/me/ui/MeLayoutPage.tsx:84

  • Modify: client/src/features/user/user-menu/ui/UserMenu.tsx:57

  • Modify: client/src/pages/me/ui/MePage.tsx:41-91

  • Modify: client/src/pages/me/ui/sections/SettingsPage.tsx:41-98

  • Modify: client/src/entities/order/api/admin-order-api.ts:40

  • Modify: client/src/entities/review/api/admin-review-api.ts:10

  • Step 1: Update AuthPage inline type

Replace client/src/pages/auth/ui/AuthPage.tsx line 15:

type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } }

With:

type AuthResponse = { token: string; user: { id: string; email: string; displayName?: string | null; phone?: string | null } }
  • Step 2: Update MeLayoutPage

Replace client/src/pages/me/ui/MeLayoutPage.tsx line 84:

          {user.name?.trim() || user.email}

With:

          {user.displayName?.trim() || user.email}
  • Step 3: Update UserMenu

Replace client/src/features/user/user-menu/ui/UserMenu.tsx line 57:

              <ListItemText primary={(user.name && user.name.trim()) || user.email} secondary="Профиль" />

With:

              <ListItemText primary={(user.displayName && user.displayName.trim()) || user.email} secondary="Профиль" />
  • Step 4: Update MePage (profile page)

Replace client/src/pages/me/ui/MePage.tsx — the form field name and all related code:

Line 41 — form type:

  const profileForm = useForm<{ displayName: string }>({

Line 42 — defaultValues:

    defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' },

Line 79-83 — TextField:

            <TextField
              label="Имя или ник"
              helperText="До 40 символов"
              slotProps={{ htmlInput: { maxLength: 40 } }}
              {...profileForm.register('displayName')}
            />

Lines 88-91 — onClick:

              onClick={() => {
                const raw = profileForm.getValues('displayName')
                const name = raw.trim()
                updateProfileFx({ displayName: name.length ? name : null })
              }}
  • Step 5: Update SettingsPage

Replace client/src/pages/me/ui/sections/SettingsPage.tsx — same pattern as MePage:

Line 41 — form type:

  const profileForm = useForm<{ displayName: string; phone: string }>({

Line 42 — defaultValues:

    defaultValues: { displayName: user?.displayName ? String(user.displayName) : '', phone: user?.phone ? String(user.phone) : '' },

Lines 79-83 — TextField:

            <TextField
              label="Имя или ник"
              helperText="До 40 символов"
              slotProps={{ htmlInput: { maxLength: 40 } }}
              {...profileForm.register('displayName')}
            />

Lines 93-98 — onClick:

              onClick={() => {
                const raw = profileForm.getValues('displayName')
                const name = raw.trim()
                const phoneRaw = profileForm.getValues('phone')
                const phone = phoneRaw.trim()
                updateProfileFx({ displayName: name.length ? name : null, phone: phone.length ? phone : null })
              }}
  • Step 6: Update admin-order-api.ts type

Replace client/src/entities/order/api/admin-order-api.ts line 40:

    user: { id: string; email: string; name: string | null; phone: string | null }

With:

    user: { id: string; email: string; displayName: string | null; phone: string | null }
  • Step 7: Update admin-review-api.ts type

Replace client/src/entities/review/api/admin-review-api.ts line 10:

  user: { id: string; email: string; name: string | null }

With:

  user: { id: string; email: string; displayName: string | null }
  • Step 8: Run client lint to verify
cd client && npm run lint

Expected: no errors (or fix any found).

  • Step 9: Commit
git add client/src/pages/auth/ui/AuthPage.tsx client/src/pages/me/ui/MeLayoutPage.tsx client/src/features/user/user-menu/ui/UserMenu.tsx client/src/pages/me/ui/MePage.tsx client/src/pages/me/ui/sections/SettingsPage.tsx client/src/entities/order/api/admin-order-api.ts client/src/entities/review/api/admin-review-api.ts
git commit -m "refactor: rename name→displayName across client"

Task 9: Create OAuth providers config

Files:

  • Create: client/src/features/auth-oauth/lib/oauth-providers.ts

  • Step 1: Create the providers config

import { oauthAuthorizeUrl } from '@/shared/lib/oauth-authorize-url'

export type OAuthProvider = {
  id: 'yandex' | 'vk'
  label: string
  color: string
}

export const oauthProviders: OAuthProvider[] = [
  {
    id: 'yandex',
    label: 'Яндекс ID',
    color: '#FC3F1D',
  },
  {
    id: 'vk',
    label: 'VK ID',
    color: '#0077FF',
  },
]

export function getOAuthUrl(provider: 'yandex' | 'vk'): string {
  return oauthAuthorizeUrl(provider)
}
  • Step 2: Commit
git add client/src/features/auth-oauth/lib/oauth-providers.ts
git commit -m "feat: add oauth providers config"

Task 10: Create OAuthButtons component

Files:

  • Create: client/src/features/auth-oauth/ui/OAuthButtons.tsx

  • Step 1: Create the OAuthButtons component

import Stack from '@mui/material/Stack'
import Button from '@mui/material/Button'
import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers'

export function OAuthButtons() {
  return (
    <Stack direction="row" spacing={1} justifyContent="center">
      {oauthProviders.map((p) => (
        <Button
          key={p.id}
          variant="outlined"
          href={getOAuthUrl(p.id)}
          sx={{
            borderColor: p.color,
            color: p.color,
            '&:hover': {
              borderColor: p.color,
              bgcolor: `${p.color}14`,
            },
          }}
        >
          Войти через {p.label}
        </Button>
      ))}
    </Stack>
  )
}
  • Step 2: Create barrel export

Create client/src/features/auth-oauth/index.ts:

export { OAuthButtons } from './ui/OAuthButtons'
  • Step 3: Commit
git add client/src/features/auth-oauth/
git commit -m "feat: add OAuthButtons component"

Task 11: Integrate OAuthButtons into AuthPage

Files:

  • Modify: client/src/pages/auth/ui/AuthPage.tsx

  • Step 1: Add import and component after the email-code form

Add import at top (after existing imports):

import { OAuthButtons } from '@/features/auth-oauth'

Add MUI Divider import (add to existing MUI imports):

import Divider from '@mui/material/Divider'

After the closing </Stack> on line 110, before the closing </Box> on line 111, add:

        <Divider sx={{ my: 2 }}>или</Divider>

        <OAuthButtons />

So the structure becomes:

      <Stack spacing={2} sx={{ maxWidth: 520 }}>
        {/* ... existing email/code form ... */}
        <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
          {/* ... buttons ... */}
        </Stack>
      </Stack>

      <Stack sx={{ maxWidth: 520 }}>
        <Divider sx={{ my: 2 }}>или</Divider>

        <OAuthButtons />
      </Stack>
  • Step 2: Run client lint
cd client && npm run lint
  • Step 3: Commit
git add client/src/pages/auth/ui/AuthPage.tsx
git commit -m "feat: add OAuth buttons to AuthPage"

Task 12: Update server .env.example

Files:

  • Modify: server/.env.example:28-30

  • Step 1: Add scope documentation

Replace lines 28-30:

# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=

With:

# Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback
# Scopes: login:email login:info
YANDEX_CLIENT_ID=
YANDEX_CLIENT_SECRET=
  • Step 2: Commit
git add server/.env.example
git commit -m "docs: add Yandex OAuth scopes to .env.example"

Task 13: Write server tests for OAuth profile enrichment

Files:

  • Create: server/src/routes/__tests__/oauth-social.test.js

  • Step 1: Create test file

import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../../lib/prisma.js'

describe('OAuth — findOrCreateUserFromOAuth (indirect via DB checks)', () => {
  it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => {
    // Verify new fields exist on the schema by creating a user with them
    const user = await prisma.user.create({
      data: {
        email: 'test-oauth@example.com',
        displayName: 'Test User',
        firstName: 'Test',
        lastName: 'User',
        gender: 'male',
        avatar: 'https://example.com/avatar.jpg',
      },
    })

    expect(user.displayName).toBe('Test User')
    expect(user.firstName).toBe('Test')
    expect(user.lastName).toBe('User')
    expect(user.gender).toBe('male')
    expect(user.avatar).toBe('https://example.com/avatar.jpg')

    // Cleanup
    await prisma.user.delete({ where: { id: user.id } })
  })

  it('allows nullable fields', async () => {
    const user = await prisma.user.create({
      data: {
        email: 'test-oauth-null@example.com',
      },
    })

    expect(user.displayName).toBeNull()
    expect(user.firstName).toBeNull()
    expect(user.lastName).toBeNull()
    expect(user.gender).toBeNull()
    expect(user.avatar).toBeNull()

    await prisma.user.delete({ where: { id: user.id } })
  })
})
  • Step 2: Run server tests
cd server && npm test

Expected: 2 passing tests.

  • Step 3: Commit
git add server/src/routes/__tests__/oauth-social.test.js
git commit -m "test: OAuth user model fields"

Task 14: Write client test for OAuthButtons

Files:

  • Create: client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx

  • Step 1: Create test file

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { OAuthButtons } from '../ui/OAuthButtons'

describe('OAuthButtons', () => {
  it('renders Yandex and VK buttons', () => {
    render(<OAuthButtons />)
    expect(screen.getByText('Войти через Яндекс ID')).toBeDefined()
    expect(screen.getByText('Войти через VK ID')).toBeDefined()
  })

  it('buttons have correct href', () => {
    render(<OAuthButtons />)
    const yaBtn = screen.getByText('Войти через Яндекс ID').closest('a')
    const vkBtn = screen.getByText('Войти через VK ID').closest('a')
    expect(yaBtn?.getAttribute('href')).toContain('/auth/oauth/yandex')
    expect(vkBtn?.getAttribute('href')).toContain('/auth/oauth/vk')
  })
})
  • Step 2: Run client tests
cd client && npm test

Expected: 2 passing tests.

  • Step 3: Commit
git add client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx
git commit -m "test: OAuthButtons component"

Task 15: Final verification — full build and lint

Files: none (verification only)

  • Step 1: Run server lint
cd server && npm run lint

Expected: no errors.

  • Step 2: Run server tests
cd server && npm test

Expected: all tests pass.

  • Step 3: Run client lint + format check
cd client && npm run lint && npm run format:check

Expected: no errors.

  • Step 4: Run client tests
cd client && npm test

Expected: all tests pass.

  • Step 5: Run client build (full typecheck)
cd client && npm run build

Expected: build succeeds with no TypeScript errors.

  • Step 6: Final commit (if any fixups were needed)
git add -A && git commit -m "chore: final verification fixes" || echo "No fixups needed"