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/auth-oauth/__tests__/OAuthButtons.test.tsx b/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx new file mode 100644 index 0000000..d095761 --- /dev/null +++ b/client/src/features/auth-oauth/__tests__/OAuthButtons.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from '@testing-library/react' +import { describe, it, expect } from 'vitest' +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') + }) +}) diff --git a/client/src/features/auth-oauth/index.ts b/client/src/features/auth-oauth/index.ts new file mode 100644 index 0000000..bbe2712 --- /dev/null +++ b/client/src/features/auth-oauth/index.ts @@ -0,0 +1 @@ +export { OAuthButtons } from './ui/OAuthButtons' diff --git a/client/src/features/auth-oauth/lib/oauth-providers.ts b/client/src/features/auth-oauth/lib/oauth-providers.ts new file mode 100644 index 0000000..2d1a510 --- /dev/null +++ b/client/src/features/auth-oauth/lib/oauth-providers.ts @@ -0,0 +1,24 @@ +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) +} diff --git a/client/src/features/auth-oauth/ui/OAuthButtons.tsx b/client/src/features/auth-oauth/ui/OAuthButtons.tsx new file mode 100644 index 0000000..18ccc53 --- /dev/null +++ b/client/src/features/auth-oauth/ui/OAuthButtons.tsx @@ -0,0 +1,27 @@ +import Button from '@mui/material/Button' +import Stack from '@mui/material/Stack' +import { getOAuthUrl, oauthProviders } from '../lib/oauth-providers' + +export function OAuthButtons() { + return ( + + {oauthProviders.map((p) => ( + + ))} + + ) +} 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..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,10 +10,14 @@ 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' -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 @@ -108,6 +113,12 @@ export function AuthPage() { + + + или + + + ) } 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')} /> + ))} + + ) +} +``` + +- [ ] **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" +``` 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` 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= 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/prisma/dev.db b/server/prisma/prisma/dev.db index e72aaa9..224aa14 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ 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()) 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/__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 } }) + }) +}) diff --git a/server/src/routes/api/admin-orders.js b/server/src/routes/api/admin-orders.js index 0e01e5e..8db819f 100644 --- a/server/src/routes/api/admin-orders.js +++ b/server/src/routes/api/admin-orders.js @@ -73,7 +73,7 @@ export async function registerAdminOrderRoutes(fastify) { const order = await prisma.order.findUnique({ where: { id }, include: { - user: { select: { id: true, email: true, name: true, phone: true } }, + user: { select: { id: true, email: true, displayName: true, phone: true } }, items: true, messages: { orderBy: { createdAt: 'asc' } }, }, diff --git a/server/src/routes/api/admin-reviews.js b/server/src/routes/api/admin-reviews.js index aedbe98..7cb840b 100644 --- a/server/src/routes/api/admin-reviews.js +++ b/server/src/routes/api/admin-reviews.js @@ -18,7 +18,7 @@ export async function registerAdminReviewRoutes(fastify) { const items = await prisma.review.findMany({ where, include: { - user: { select: { id: true, email: true, name: true } }, + user: { select: { id: true, email: true, displayName: true } }, product: { select: { id: true, title: true } }, }, orderBy: { createdAt: 'desc' }, @@ -40,7 +40,7 @@ export async function registerAdminReviewRoutes(fastify) { where: { id }, include: { product: { select: { title: true } }, - user: { select: { name: true, email: true } }, + user: { select: { displayName: true, email: true } }, }, }) if (!existing) return reply.code(404).send({ error: 'Отзыв не найден' }) @@ -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, }) 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, } diff --git a/server/src/routes/api/public-reviews.js b/server/src/routes/api/public-reviews.js index 1019ebe..ef535b2 100644 --- a/server/src/routes/api/public-reviews.js +++ b/server/src/routes/api/public-reviews.js @@ -40,7 +40,7 @@ export async function registerPublicReviewRoutes(fastify) { const rows = await prisma.review.findMany({ where: { status: 'approved', product: { published: true } }, include: { - user: { select: { email: true, name: true } }, + user: { select: { email: true, displayName: true } }, product: { select: { id: true, title: true } }, }, orderBy: { createdAt: 'desc' }, @@ -80,7 +80,7 @@ export async function registerPublicReviewRoutes(fastify) { const total = await prisma.review.count({ where }) const rawItems = await prisma.review.findMany({ where, - include: { user: { select: { email: true, name: true } } }, + include: { user: { select: { email: true, displayName: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 74b18d5..385371f 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,13 @@ 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 +135,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, }, }) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 2f85a59..dba2db1 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -18,13 +18,13 @@ async function issueUserJwt(fastify, userId, email) { } async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) { - const existingLink = await prisma.oauthAccount.findUnique({ + const existingLink = await prisma.oAuthAccount.findUnique({ where: { provider_providerUserId: { provider, providerUserId } }, include: { user: true }, }) if (existingLink?.user) { if (accessToken !== undefined) { - await prisma.oauthAccount.update({ + await prisma.oAuthAccount.update({ where: { provider_providerUserId: { provider, providerUserId } }, data: { accessToken }, }) @@ -36,7 +36,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken const norm = trimmed ? normalizeEmail(trimmed) : null let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null if (user) { - await prisma.oauthAccount.create({ + await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, }) return user @@ -49,7 +49,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local` } user = await prisma.user.create({ data: { email } }) - await prisma.oauthAccount.create({ + await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, }) return user @@ -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) @@ -233,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)