# 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 `name` → `displayName` across the project. **Architecture:** Server OAuth flow already exists (`oauth-social.js`). We update the User model (rename `name`→`displayName`, 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 `name` → `displayName` and add new fields** ```diff 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** ```bash cd server && npm run db:reset:test ``` Expected: "Your database has been reset" or similar. Migration runs and seed applies. - [ ] **Step 3: Commit** ```bash 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: ```js 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: ```js 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** ```bash 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 `name` → `displayName` **Files:** - Modify: `server/src/routes/api/admin-users.js` - [ ] **Step 1: Replace all `name` → `displayName` 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** ```bash 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 `name` → `displayName` **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** ```bash 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: ```js 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** ```bash 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: ```js 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** ```bash 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: ```ts 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: ```ts export type UpdateProfileParams = { displayName: string | null; phone?: string | null } ``` - [ ] **Step 3: Commit** ```bash git add client/src/shared/model/auth.ts git commit -m "refactor: rename name→displayName in AuthUser type" ``` --- ### Task 8: Update client files — rename `name` → `displayName` **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: ```ts 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: ``` ``` With: ``` ``` - [ ] **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: ``` ``` 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: ``` ``` 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: ```ts 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: ```ts user: { id: string; email: string; displayName: string | null } ``` - [ ] **Step 8: Run client lint to verify** ```bash cd client && npm run lint ``` Expected: no errors (or fix any found). - [ ] **Step 9: Commit** ```bash 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** ```ts 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** ```bash 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** ```tsx import Stack from '@mui/material/Stack' import Button from '@mui/material/Button' import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers' export function OAuthButtons() { return ( {oauthProviders.map((p) => ( ))} ) } ``` - [ ] **Step 2: Create barrel export** Create `client/src/features/auth-oauth/index.ts`: ```ts export { OAuthButtons } from './ui/OAuthButtons' ``` - [ ] **Step 3: Commit** ```bash 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): ```ts import { OAuthButtons } from '@/features/auth-oauth' ``` Add MUI Divider import (add to existing MUI imports): ``` import Divider from '@mui/material/Divider' ``` After the closing `` on line 110, before the closing `` on line 111, add: ```tsx или ``` So the structure becomes: ```tsx {/* ... existing email/code form ... */} {/* ... buttons ... */} или ``` - [ ] **Step 2: Run client lint** ```bash cd client && npm run lint ``` - [ ] **Step 3: Commit** ```bash 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: ```env # 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** ```bash 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** ```js 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** ```bash cd server && npm test ``` Expected: 2 passing tests. - [ ] **Step 3: Commit** ```bash 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** ```tsx 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() expect(screen.getByText('Войти через Яндекс ID')).toBeDefined() expect(screen.getByText('Войти через VK ID')).toBeDefined() }) it('buttons have correct href', () => { render() 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** ```bash cd client && npm test ``` Expected: 2 passing tests. - [ ] **Step 3: Commit** ```bash 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** ```bash cd server && npm run lint ``` Expected: no errors. - [ ] **Step 2: Run server tests** ```bash cd server && npm test ``` Expected: all tests pass. - [ ] **Step 3: Run client lint + format check** ```bash cd client && npm run lint && npm run format:check ``` Expected: no errors. - [ ] **Step 4: Run client tests** ```bash cd client && npm test ``` Expected: all tests pass. - [ ] **Step 5: Run client build (full typecheck)** ```bash cd client && npm run build ``` Expected: build succeeds with no TypeScript errors. - [ ] **Step 6: Final commit (if any fixups were needed)** ```bash git add -A && git commit -m "chore: final verification fixes" || echo "No fixups needed" ```