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

887 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```