Files
shop-server/docs/superpowers/specs/2026-05-22-vk-no-email-fix-design.md
T
2026-05-22 22:51:03 +05:00

5.5 KiB
Raw Blame History

VK OAuth без email — Design

Проблема

VK ID не всегда возвращает email (необязательное поле). Текущий код требует email на трёх уровнях:

  1. Callback (oauth-social.js:209) — if (!emailSuggestion) return oauthErrorRedirect(...)
  2. findOrCreateUserFromOAuth (oauth-social.js:72,87) — if (!norm) return null
  3. Схема БД — email String @unique (NOT NULL)

Результат: пользователь, у которого VK не отдал email, видит ошибку no_email и не может войти.

Решение

Три изменения:

  1. Новый пользователь без email — генерировать синтетический email vk_<providerUserId>@vk.local
  2. Привязка VK к существующему аккаунту (link) — не требовать email от VK
  3. Смена email в профиле — дать пользователю возможность сменить синтетический email на настоящий, с верификацией

Часть 1: OAuth flow (сервер)

server/src/routes/oauth-social.js

findOrCreateUserFromOAuth (стр. 53-104):

  • Режим link (стр. 71-77): убрать if (!norm) return null. Если linkToUserId передан — email не нужен, создаём OAuthAccount и возвращаем пользователя.
  • Новый пользователь без email (стр. 87): вместо if (!norm) return null — если norm отсутствует, генерируем vk_<providerUserId>@vk.local и создаём пользователя с displayName = 'Пользователь'.

VK callback (стр. 206-209): убрать строку if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email').

Yandex callback — без изменений (Яндекс всегда возвращает email).


Часть 2: Смена email с верификацией

Схема БД — новая модель PendingEmail

model PendingEmail {
  id        String   @id @default(cuid())
  userId    String
  email     String
  token     String   @unique
  expiresAt DateTime
  createdAt DateTime @default(now())

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

Миграция

cd server && npx prisma migrate dev --name pending_email

Серверные роуты

Новые роуты в server/src/routes/auth-session.js (рядом с /api/me и /api/me/auth-methods):

PATCH /api/me/email (requireAuth):

  • Тело: { email: string }
  • Валидация: нормализовать через normalizeEmail(), проверить формат
  • Проверить, что email не занят (findUnique({ email })) → 409 Conflict
  • Удалить предыдущие PendingEmail для этого пользователя
  • Создать PendingEmail с token = crypto.randomUUID(), expiresAt = now + 24h
  • Ответ: { verificationUrl: '/api/me/verify-email?token=<uuid>' } (отправка email не реализуем, токен возвращаем в ответе API)

GET /api/me/verify-email (без авторизации, только по токену):

  • Искать PendingEmail по токену, проверить expiresAt > now
  • Обновить User.email, удалить PendingEmail
  • Редирект: {CLIENT_PUBLIC_URL}/me?emailVerified=1

Клиент

client/src/shared/model/auth.ts — добавить эффекты:

export const requestEmailChangeFx = createEffect(async (email: string) => {
  const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email })
  return data.verificationUrl
})

export const verifyEmailFx = createEffect(async (token: string) => {
  window.location.href = `/api/me/verify-email?token=${token}`
})

AuthMethodsSection.tsx — добавить секцию смены email:

  • Текстовое поле (email) + кнопка «Сменить email»
  • После успешного запроса — показать кнопку «Подтвердить email» (переход по verificationUrl)
  • Ошибки: неверный формат, email занят
  • После успешной верификации — обновить $user (подгрузить через meFx заново)

Структура изменений

server/
  prisma/schema.prisma              — модель PendingEmail
  prisma/migrations/                — миграция (авто)
  src/routes/oauth-social.js        — findOrCreateUserFromOAuth + VK callback fix
  src/routes/auth-session.js        — PATCH /api/me/email, GET /api/me/verify-email
  __tests__/                        — тесты на новый flow

client/
  src/shared/model/auth.ts          — requestEmailChangeFx, verifyEmailFx
  src/pages/me/ui/sections/
    AuthMethodsSection.tsx          — UI для смены email
    __tests__/                      — тесты UI

Не входит в scope

  • Отправка email с кодом подтверждения (верификация через ссылку в ответе API)
  • OAuth для админа (админ только email/код)
  • Синтетический email для Яндекса (Яндекс всегда возвращает email)