From 01bd9f8968b99a818dd0d16acdcc61f7a8355bed Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:26:45 +0500 Subject: [PATCH 01/19] docs: yandex+vk oauth design spec --- .../2026-05-20-yandex-vk-oauth-design.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-yandex-vk-oauth-design.md diff --git a/docs/superpowers/specs/2026-05-20-yandex-vk-oauth-design.md b/docs/superpowers/specs/2026-05-20-yandex-vk-oauth-design.md new file mode 100644 index 0000000..5217e5a --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-yandex-vk-oauth-design.md @@ -0,0 +1,92 @@ +# Yandex ID + VK ID OAuth — Design + +## Цель + +Подключить авторизацию через Яндекс ID и VK ID на клиенте (серверная часть OAuth уже реализована). Добавить поля профиля: имя, фамилия, пол, аватар. Админ продолжает входить только через email/код. + +## Объём + +### База данных + +- Переименовать `User.name` → `User.displayName` +- Добавить поля: `firstName String?`, `lastName String?`, `gender String?`, `avatar String?` +- Сбросить БД (`prisma migrate reset --force`), прода нет + +### Сервер + +1. **`server/prisma/schema.prisma`** — обновить модель User +2. **`server/src/routes/oauth-social.js`** — обновить `findOrCreateUserFromOAuth()`: + - Яндекс: сохранять `firstName`, `lastName`, `gender` (`sex`), `avatar` (`https://avatars.yandex.net/get-yapic/{default_avatar_id}/islands-200`), `displayName` ← `real_name` или `display_name` + - VK: сохранять `firstName` (`first_name`), `lastName` (`last_name`), `gender` (`sex`: 1→female, 2→male), `avatar` (`photo_200`), `displayName` ← `first_name + ' ' + last_name` + - Gender: если провайдер не вернул — оставлять `null` +3. **`server/src/routes/auth.js`** — обновить `mapUserForClient()`: добавить новые поля в ответ `/api/me`; переименовать `name` → `displayName` +4. **`server/src/**/*.js`** — найти и заменить все использования `user.name` на `user.displayName` +5. **`server/.env.example`** — документировать redirect URI для Яндекс и VK + +### Клиент + +1. **`client/src/shared/model/auth.ts`** — обновить тип `AuthUser`, добавить `displayName`, `firstName`, `lastName`, `gender`, `avatar`; убрать `name` +2. **`client/src/features/auth-oauth/`** — новая FSD-фича: + - `lib/oauth-providers.ts` — конфигурация: `{ id, label, icon, color }` для yandex и vk + - `ui/OAuthButtons.tsx` — компонент с двумя кнопками (Stack + Button variant="outlined"), каждая редиректит на `/api/auth/oauth/{provider}` + - `index.ts` — barrel экспорт +3. **`client/src/pages/auth/ui/AuthPage.tsx`** — добавить `` после формы email-кода, разделив Divider'ом с текстом «или» +4. **`client/src/**/*.tsx`** — найти и заменить все использования `user.name` → `user.displayName` + +### ENV + +Переменные в `server/.env` (из примера): + +``` +SERVER_PUBLIC_URL=http://127.0.0.1:3333 +CLIENT_PUBLIC_URL=http://127.0.0.1:5173 +YANDEX_CLIENT_ID=<значение> +YANDEX_CLIENT_SECRET=<значение> +VK_CLIENT_ID=<значение> +VK_CLIENT_SECRET=<значение> +``` + +Redirect URI для настройки в кабинетах провайдеров: +- Яндекс (локально): `http://127.0.0.1:3333/api/auth/oauth/yandex/callback` +- VK (локально): `http://127.0.0.1:3333/api/auth/oauth/vk/callback` +- Яндекс (прод): `https://любимыйкреатив.рф/api/auth/oauth/yandex/callback` +- VK (прод): `https://любимыйкреатив.рф/api/auth/oauth/vk/callback` + +## Структура фичи `features/auth-oauth/` + +``` +features/auth-oauth/ + index.ts — barrel: export { OAuthButtons } + ui/ + OAuthButtons.tsx — Stack из 2 кнопок (Яндекс, VK) + lib/ + oauth-providers.ts — массив провайдеров: { id, label, icon, color } +``` + +## Data flow (OAuth) + +``` +Клиент: кнопка «Войти через Яндекс/VK» + → редирект на /api/auth/oauth/{yandex|vk} +Сервер: формирует state JWT, редиректит на Яндекс/VK + → пользователь авторизуется у провайдера + → провайдер редиректит на /api/auth/oauth/{yandex|vk}/callback +Сервер: обменивает code на токен → получает профиль → findOrCreateUserFromOAuth() + → генерирует JWT → редиректит на {CLIENT_PUBLIC_URL}/auth/callback?token= +Клиент: AuthCallbackPage читает token → сохраняет в localStorage → редирект на / +``` + +## Не входит в scope + +- Отображение аватара в хедере/UserMenu (будет отдельно) +- Страница профиля с новыми полями (будет отдельно) +- OAuth для админа (админ только email/код) + +## Примечания + +- `gender` — nullable, если провайдер не вернул пол +- VK: `sex: 1` = female, `sex: 2` = male → нормализуем в `female` / `male` +- Яндекс: avatar — конструируем URL из `default_avatar_id`, поле `is_avatar_empty` подскажет, загружен ли аватар +- Яндекс scopes: `login:email login:info` +- VK scopes: `email` +- OAuth state — JWT с `expiresIn: 15m` From d931545a2e1f1ee91d58fe5424e46ba41fc32d46 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:35:43 +0500 Subject: [PATCH 02/19] docs: yandex+vk oauth implementation plan --- .../plans/2026-05-20-yandex-vk-oauth.md | 886 ++++++++++++++++++ 1 file changed, 886 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md diff --git a/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md b/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md new file mode 100644 index 0000000..1cbcd6f --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-yandex-vk-oauth.md @@ -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: +``` + +``` +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" +``` From 36880c298c5c0a936b636471fb09f2d9aa7451c2 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:39:01 +0500 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20rename=20User.name=E2=86=92displa?= =?UTF-8?q?yName,=20add=20firstName/lastName/gender/avatar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 28 +++++++++++++++++++ server/prisma/schema.prisma | 6 +++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql diff --git a/server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql b/server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql new file mode 100644 index 0000000..23ac31e --- /dev/null +++ b/server/prisma/migrations/20260520053830_rename_user_name_to_display_name_add_fields/migration.sql @@ -0,0 +1,28 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "displayName" TEXT, + "firstName" TEXT, + "lastName" TEXT, + "gender" TEXT, + "avatar" TEXT, + "phone" TEXT, + "passwordHash" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("createdAt", "email", "id", "passwordHash", "phone", "updatedAt") SELECT "createdAt", "email", "id", "passwordHash", "phone", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index c2c6a83..5403c8c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -77,7 +77,11 @@ model CatalogSliderSlide { 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()) From ce49f75100cdcd689ca6180a1d9aa1007ac31a4a Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:46:31 +0500 Subject: [PATCH 04/19] feat: use displayName in mapUserForClient and profile update --- server/src/routes/auth.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 74b18d5..db286d7 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -8,7 +8,11 @@ function mapUserForClient(user) { return { id: user.id, email: user.email, - name: user.name, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + gender: user.gender, + avatar: user.avatar, phone: user.phone, isAdmin: Boolean(adminEmail) && userEmail === adminEmail, } @@ -113,12 +117,12 @@ export async function registerAuthRoutes(fastify) { fastify.patch('/api/me/profile', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub - const nameRaw = request.body?.name - const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + 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 (name !== null && name.length > 40) return reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) + 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: 'Телефон слишком длинный' }) @@ -130,7 +134,7 @@ export async function registerAuthRoutes(fastify) { const updated = await prisma.user.update({ where: { id: userId }, data: { - name: name && name.length ? name : null, + displayName: displayName && displayName.length ? displayName : null, phone: phone && phone.length ? phone : null, }, }) From cc7e46b4476bd8d08caf8e44e8ffa51c98ff6364 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:50:38 +0500 Subject: [PATCH 05/19] =?UTF-8?q?refactor:=20rename=20name=E2=86=92display?= =?UTF-8?q?Name=20in=20admin-users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/routes/api/admin-users.js | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/server/src/routes/api/admin-users.js b/server/src/routes/api/admin-users.js index 38f50c0..f6b3f6d 100644 --- a/server/src/routes/api/admin-users.js +++ b/server/src/routes/api/admin-users.js @@ -21,7 +21,7 @@ export async function registerAdminUserRoutes(fastify) { const where = q ? { - OR: [{ email: { contains: q } }, { name: { contains: q } }], + OR: [{ email: { contains: q } }, { displayName: { contains: q } }], } : undefined @@ -32,7 +32,7 @@ export async function registerAdminUserRoutes(fastify) { select: { id: true, email: true, - name: true, + displayName: true, createdAt: true, updatedAt: true, }, @@ -43,7 +43,7 @@ export async function registerAdminUserRoutes(fastify) { const items = users.map((u) => ({ id: u.id, email: u.email, - name: u.name, + displayName: u.displayName, createdAt: u.createdAt, updatedAt: u.updatedAt, })) @@ -60,9 +60,9 @@ export async function registerAdminUserRoutes(fastify) { return } - const nameRaw = body.name - const name = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() - if (name !== null && name.length > 40) { + const nameRaw = body.displayName + const displayName = nameRaw === null || nameRaw === undefined ? null : String(nameRaw).trim() + if (displayName !== null && displayName.length > 40) { reply.code(400).send({ error: 'Имя/ник максимум 40 символов' }) return } @@ -76,14 +76,14 @@ export async function registerAdminUserRoutes(fastify) { const user = await prisma.user.create({ data: { email, - name: name && name.length ? name : null, + displayName: displayName && displayName.length ? displayName : null, }, }) reply.code(201).send({ id: user.id, email: user.email, - name: user.name, + displayName: user.displayName, createdAt: user.createdAt, updatedAt: user.updatedAt, }) @@ -117,21 +117,21 @@ export async function registerAdminUserRoutes(fastify) { } } - if (body.name !== undefined) { - const nameRaw = body.name + 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.name = name && name.length ? name : null + data.displayName = name && name.length ? name : null } const user = await prisma.user.update({ where: { id }, data }) return { id: user.id, email: user.email, - name: user.name, + displayName: user.displayName, createdAt: user.createdAt, updatedAt: user.updatedAt, } From 32a4406cb8c7dd20e69ad174c01a50481cded393 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:51:48 +0500 Subject: [PATCH 06/19] =?UTF-8?q?refactor:=20rename=20name=E2=86=92display?= =?UTF-8?q?Name=20in=20review=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/lib/review-display.js | 2 +- server/src/routes/api/admin-reviews.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/lib/review-display.js b/server/src/lib/review-display.js index 25e059e..ddcc05c 100644 --- a/server/src/lib/review-display.js +++ b/server/src/lib/review-display.js @@ -1,7 +1,7 @@ /** Публичное отображение автора отзыва (без «голого» email). */ export function publicReviewAuthorDisplay(user) { if (!user || typeof user !== 'object') return 'Покупатель' - const name = typeof user.name === 'string' ? user.name.trim() : '' + const name = typeof user.displayName === 'string' ? user.displayName.trim() : '' if (name) return name const email = typeof user.email === 'string' ? user.email.trim() : '' const at = email.indexOf('@') diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js index aedbe98..f6f6e24 100644 --- a/server/src/routes/api/admin-reviews.js +++ b/server/src/routes/api/admin-reviews.js @@ -56,7 +56,7 @@ export async function registerAdminReviewRoutes(fastify) { rating: updated.rating, text: updated.text || '', productTitle: existing.product?.title || '', - userName: existing.user?.name || existing.user?.email || '', + userName: existing.user?.displayName || existing.user?.email || '', reviewId: updated.id, }) From d2d2f721cdfd740f526c737e09467b458aa5bd54 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:53:58 +0500 Subject: [PATCH 07/19] feat: enrich VK OAuth with firstName/lastName/gender/avatar --- server/src/routes/oauth-social.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 2f85a59..82dfb57 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -118,12 +118,14 @@ export async function registerOAuthSocialRoutes(fastify) { 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_50') + u.searchParams.set('fields', 'photo_200,sex') u.searchParams.set('v', '5.199') const profRes = await fetch(u.toString()) const prof = await profRes.json() @@ -131,6 +133,9 @@ export async function registerOAuthSocialRoutes(fastify) { 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 { @@ -144,11 +149,15 @@ export async function registerOAuthSocialRoutes(fastify) { suggestedEmail: emailSuggestion, }) - if (firstName || lastName) { - const name = [firstName, lastName].filter(Boolean).join(' ').trim() - if (name && !user.name) { - await prisma.user.update({ where: { id: user.id }, data: { name } }) - } + 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 }) } const token = await issueUserJwt(fastify, user.id, user.email) From 6fde248dc5f9b0adc7ddd7a96b21c4e2f6680b00 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:55:37 +0500 Subject: [PATCH 08/19] feat: enrich Yandex OAuth with firstName/lastName/gender/avatar --- server/src/routes/oauth-social.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 82dfb57..c0f72b1 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -242,9 +242,18 @@ export async function registerOAuthSocialRoutes(fastify) { suggestedEmail: emailGuess || null, }) - const dn = `${info.first_name ?? ''} ${info.last_name ?? ''}`.trim() - if (dn && !user.name) { - await prisma.user.update({ where: { id: user.id }, data: { name: dn } }) + 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 }) } const token = await issueUserJwt(fastify, user.id, user.email) From 8d9c250eb71c2152df9ec51304cb93ed361ab507 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 10:57:11 +0500 Subject: [PATCH 09/19] =?UTF-8?q?refactor:=20rename=20name=E2=86=92display?= =?UTF-8?q?Name=20in=20AuthUser=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/shared/model/auth.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index be9b4fe..3c4540f 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -3,7 +3,17 @@ 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; name?: string | null; phone?: string | null; isAdmin?: boolean } +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 +} export const tokenSet = createEvent() export const logout = createEvent() @@ -58,7 +68,7 @@ export const verifyEmailChangeFx = createEffect(async (params: { newEmail: strin // ----- Profile update ----- -export type UpdateProfileParams = { name: string | null; phone?: string | null } +export type UpdateProfileParams = { displayName: string | null; phone?: string | null } export const updateProfileFx = createEffect(async (params: UpdateProfileParams) => { const { data } = await apiClient.patch<{ user: AuthUser }>('me/profile', params) From 00b74e56d7f93aafad6ff225bffc3bce6c6eee49 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 11:00:28 +0500 Subject: [PATCH 10/19] =?UTF-8?q?refactor:=20rename=20name=E2=86=92display?= =?UTF-8?q?Name=20across=20client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/entities/order/api/admin-order-api.ts | 2 +- client/src/entities/review/api/admin-review-api.ts | 2 +- client/src/features/user/user-menu/ui/UserMenu.tsx | 2 +- client/src/pages/auth/ui/AuthPage.tsx | 5 ++++- client/src/pages/me/ui/MeLayoutPage.tsx | 2 +- client/src/pages/me/ui/MePage.tsx | 10 +++++----- client/src/pages/me/ui/sections/SettingsPage.tsx | 13 ++++++++----- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/client/src/entities/order/api/admin-order-api.ts b/client/src/entities/order/api/admin-order-api.ts index 2d5b2f6..43df1d6 100644 --- a/client/src/entities/order/api/admin-order-api.ts +++ b/client/src/entities/order/api/admin-order-api.ts @@ -37,7 +37,7 @@ export type AdminOrderDetailResponse = { comment: string | null createdAt: string updatedAt: string - user: { id: string; email: string; name: string | null; phone: string | null } + user: { id: string; email: string; displayName: string | null; phone: string | null } items: Array<{ id: string productId: string diff --git a/client/src/entities/review/api/admin-review-api.ts b/client/src/entities/review/api/admin-review-api.ts index fdad3b6..b41c1ed 100644 --- a/client/src/entities/review/api/admin-review-api.ts +++ b/client/src/entities/review/api/admin-review-api.ts @@ -7,7 +7,7 @@ export type AdminReview = { status: string createdAt: string moderatedAt: string | null - user: { id: string; email: string; name: string | null } + user: { id: string; email: string; displayName: string | null } product: { id: string; title: string } } diff --git a/client/src/features/user/user-menu/ui/UserMenu.tsx b/client/src/features/user/user-menu/ui/UserMenu.tsx index 65607dd..ed93c19 100644 --- a/client/src/features/user/user-menu/ui/UserMenu.tsx +++ b/client/src/features/user/user-menu/ui/UserMenu.tsx @@ -54,7 +54,7 @@ export function UserMenu({ user, onNavigate, onLogout }: Props) { {user ? ( <> go('/me')}> - + Выход diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 11687da..2ada1ab 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -12,7 +12,10 @@ import { useNavigate, useSearchParams } from 'react-router-dom' import { apiClient } from '@/shared/api/client' import { $user, tokenSet } from '@/shared/model/auth' -type AuthResponse = { token: string; user: { id: string; email: string; name?: string | null; phone?: string | null } } +type AuthResponse = { + token: string + user: { id: string; email: string; displayName?: string | null; phone?: string | null } +} function getApiErrorMessage(err: unknown): string | null { if (!err || typeof err !== 'object') return null diff --git a/client/src/pages/me/ui/MeLayoutPage.tsx b/client/src/pages/me/ui/MeLayoutPage.tsx index 5998257..0c24ad1 100644 --- a/client/src/pages/me/ui/MeLayoutPage.tsx +++ b/client/src/pages/me/ui/MeLayoutPage.tsx @@ -81,7 +81,7 @@ export function MeLayoutPage() { Кабинет - {user.name?.trim() || user.email} + {user.displayName?.trim() || user.email} diff --git a/client/src/pages/me/ui/MePage.tsx b/client/src/pages/me/ui/MePage.tsx index 3a40bb2..107da67 100644 --- a/client/src/pages/me/ui/MePage.tsx +++ b/client/src/pages/me/ui/MePage.tsx @@ -38,8 +38,8 @@ export function MePage() { mode: 'onChange', }) - const profileForm = useForm<{ name: string }>({ - defaultValues: { name: user?.name ? String(user.name) : '' }, + const profileForm = useForm<{ displayName: string }>({ + defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' }, mode: 'onChange', }) @@ -80,15 +80,15 @@ export function MePage() { label="Имя или ник" helperText="До 40 символов" slotProps={{ htmlInput: { maxLength: 40 } }} - {...profileForm.register('name')} + {...profileForm.register('displayName')} /> + ))} + + ) +} From e8f5bba9bfba8218dfab989e5a69727a03ebe317 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 11:07:18 +0500 Subject: [PATCH 13/19] feat: add OAuth buttons to AuthPage --- client/src/pages/auth/ui/AuthPage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 2ada1ab..49edb29 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import Divider from '@mui/material/Divider' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' import Typography from '@mui/material/Typography' @@ -9,6 +10,7 @@ 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, tokenSet } from '@/shared/model/auth' @@ -111,6 +113,12 @@ export function AuthPage() { + + + или + + + ) } From 76d215e4dc79c07be1e34e5f5d3c45535a409517 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 11:08:18 +0500 Subject: [PATCH 14/19] docs: add Yandex OAuth scopes to .env.example --- server/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/server/.env.example b/server/.env.example index 81fea73..c1c4c99 100644 --- a/server/.env.example +++ b/server/.env.example @@ -26,6 +26,7 @@ VK_CLIENT_ID= VK_CLIENT_SECRET= # Yandex OAuth: redirect URI = SERVER_PUBLIC_URL + /api/auth/oauth/yandex/callback +# Scopes: login:email login:info YANDEX_CLIENT_ID= YANDEX_CLIENT_SECRET= From bf22aaf9174e253568d5c5c2f52fbc2a33a23741 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 11:10:18 +0500 Subject: [PATCH 15/19] test: OAuth user model fields --- .../src/routes/__tests__/oauth-social.test.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 server/src/routes/__tests__/oauth-social.test.js diff --git a/server/src/routes/__tests__/oauth-social.test.js b/server/src/routes/__tests__/oauth-social.test.js new file mode 100644 index 0000000..9e60924 --- /dev/null +++ b/server/src/routes/__tests__/oauth-social.test.js @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { prisma } from '../../lib/prisma.js' + +describe('OAuth — User model fields', () => { + it('stores displayName, firstName, lastName, gender, avatar fields on User model', async () => { + 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') + + 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 } }) + }) +}) From 1873681fa6b77cf2d7cbff93a397052c49b78ef1 Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 11:12:13 +0500 Subject: [PATCH 16/19] test: OAuthButtons component --- .../__tests__/OAuthButtons.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx diff --git a/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx b/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx new file mode 100644 index 0000000..3ebb11f --- /dev/null +++ b/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx @@ -0,0 +1,19 @@ +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') + }) +}) From c32d5e6afff6d1c354d6200f337021d13e8f3f7d Mon Sep 17 00:00:00 2001 From: Kirill Date: Wed, 20 May 2026 11:14:36 +0500 Subject: [PATCH 17/19] fix: use sx for justifyContent in OAuthButtons, fix import order in test --- .../__tests__/OAuthButtons.test.tsx | 2 +- .../features/auth-oauth/ui/OAuthButtons.tsx | 2 +- server/prisma/prisma/dev.db | Bin 311296 -> 311296 bytes server/src/routes/auth.js | 3 ++- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx b/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx index 3ebb11f..d095761 100644 --- a/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx +++ b/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' import { OAuthButtons } from '../ui/OAuthButtons' describe('OAuthButtons', () => { diff --git a/client/src/features/auth-oauth/ui/OAuthButtons.tsx b/client/src/features/auth-oauth/ui/OAuthButtons.tsx index 0ba4be5..18ccc53 100644 --- a/client/src/features/auth-oauth/ui/OAuthButtons.tsx +++ b/client/src/features/auth-oauth/ui/OAuthButtons.tsx @@ -4,7 +4,7 @@ import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers' export function OAuthButtons() { return ( - + {oauthProviders.map((p) => (