docs: yandex+vk oauth implementation plan

This commit is contained in:
Kirill
2026-05-20 10:35:43 +05:00
parent 01bd9f8968
commit d931545a2e
@@ -0,0 +1,886 @@
# 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:
```
<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:
```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 (
<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`:
```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 `</Stack>` on line 110, before the closing `</Box>` on line 111, add:
```tsx
<Divider sx={{ my: 2 }}>или</Divider>
<OAuthButtons />
```
So the structure becomes:
```tsx
<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**
```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(<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**
```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"
```