diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 7c8aaed..d497721 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -8,8 +8,8 @@ import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' -import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' import vkLogoSrc from '@/shared/assets/vk-logo.svg' +import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' import { ScrollToTop } from '@/shared/ui/ScrollToTop' @@ -91,12 +91,7 @@ export function MainLayout({ children }: PropsWithChildren) { color="text.secondary" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }} > - + VK diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx index 79ae7f1..8df4011 100644 --- a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -9,10 +9,12 @@ import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' +import { useSearchParams } from 'react-router-dom' import { $user, changePasswordFx, fetchAuthMethodsFx, + requestEmailChangeFx, setPasswordFx, unlinkOAuthFx, type AuthMethod, @@ -77,11 +79,78 @@ export function AuthMethodsSection() { return authMethods.filter((m) => m.active).length }, [authMethods]) + const [searchParams] = useSearchParams() + const emailVerified = searchParams.get('emailVerified') + + const emailForm = useForm<{ email: string }>({ + defaultValues: { email: '' }, + }) + const [emailChangeError, setEmailChangeError] = useState(null) + const [verificationUrl, setVerificationUrl] = useState(null) + + const emailChangeMutation = useMutation({ + mutationFn: async (email: string) => { + setEmailChangeError(null) + const url = await requestEmailChangeFx(email) + return url + }, + onSuccess: (url) => setVerificationUrl(url), + onError: (err) => setEmailChangeError(err?.message || 'Не удалось сменить email'), + }) + if (!user) return null return ( + Почта + + + {emailVerified === '1' && ( + + Почта успешно подтверждена + + )} + + + {user.email} + + + {!verificationUrl && ( + + + + + )} + + {verificationUrl && ( + + + Ссылка подтверждения готова. + + + + )} + + Методы входа {fetchError && ( diff --git a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx index 06c425d..693756d 100644 --- a/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx +++ b/client/src/pages/me/ui/sections/__tests__/AuthMethodsSection.test.tsx @@ -1,5 +1,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' import { describe, expect, it, vi } from 'vitest' import { AuthMethodsSection } from '../AuthMethodsSection' @@ -15,6 +16,7 @@ vi.mock('@/shared/model/auth', () => ({ fetchAuthMethodsFx: vi.fn().mockResolvedValue([]), setPasswordFx: vi.fn(), unlinkOAuthFx: vi.fn(), + requestEmailChangeFx: vi.fn(), })) vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } })) @@ -29,7 +31,9 @@ function renderSection() { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) return render( - + + + , ) } diff --git a/client/src/shared/model/auth.ts b/client/src/shared/model/auth.ts index bb3d6c4..2e91529 100644 --- a/client/src/shared/model/auth.ts +++ b/client/src/shared/model/auth.ts @@ -101,6 +101,11 @@ export const changePasswordFx = createEffect(async (params: { oldPassword: strin await apiClient.post('me/change-password', params) }) +export const requestEmailChangeFx = createEffect(async (email: string) => { + const { data } = await apiClient.patch<{ verificationUrl: string }>('me/email', { email }) + return data.verificationUrl +}) + // ----- Error stores ----- export const $updateProfileError = createErrorStore(updateProfileFx).$error diff --git a/server/prisma/migrations/20260522175250_pending_email/migration.sql b/server/prisma/migrations/20260522175250_pending_email/migration.sql new file mode 100644 index 0000000..b6dfb3c --- /dev/null +++ b/server/prisma/migrations/20260522175250_pending_email/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "PendingEmail" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PendingEmail_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingEmail_token_key" ON "PendingEmail"("token"); + +-- CreateIndex +CREATE INDEX "PendingEmail_token_idx" ON "PendingEmail"("token"); + +-- CreateIndex +CREATE INDEX "PendingEmail_userId_idx" ON "PendingEmail"("userId"); diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index d5a37a2..873e50e 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 1a21149..dc7ffde 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -91,6 +91,7 @@ model User { reviews Review[] orderMessageReadStates UserOrderMessageReadState[] oauthAccounts OAuthAccount[] + pendingEmails PendingEmail[] notificationPreference NotificationPreference? notificationLogs NotificationLog[] } @@ -261,6 +262,20 @@ model OAuthAccount { @@index([userId]) } +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) + + @@index([token]) + @@index([userId]) +} + model AuthCode { id String @id @default(cuid()) email String diff --git a/server/src/routes/auth-session.js b/server/src/routes/auth-session.js index 636f712..e12dabc 100644 --- a/server/src/routes/auth-session.js +++ b/server/src/routes/auth-session.js @@ -1,3 +1,5 @@ +import crypto from 'node:crypto' +import { normalizeEmail } from '../lib/auth.js' import { prisma } from '../lib/prisma.js' import { mapUserForClient } from './auth.js' @@ -26,4 +28,54 @@ export async function registerAuthSessionRoutes(fastify) { ], } }) + + fastify.patch('/api/me/email', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + const rawEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : '' + + if (!rawEmail || !rawEmail.includes('@')) { + return reply.code(400).send({ error: 'Некорректная почта' }) + } + + const email = normalizeEmail(rawEmail) + + const existing = await prisma.user.findUnique({ where: { email } }) + if (existing && existing.id !== userId) { + return reply.code(409).send({ error: 'Эта почта уже используется' }) + } + + await prisma.pendingEmail.deleteMany({ where: { userId } }) + + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + + await prisma.pendingEmail.create({ + data: { userId, email, token, expiresAt }, + }) + + return { verificationUrl: `/api/me/verify-email?token=${token}` } + }) + + fastify.get('/api/me/verify-email', async (request, reply) => { + const token = typeof request.query?.token === 'string' ? request.query.token : '' + + if (!token) { + return reply.code(400).send({ error: 'Отсутствует токен подтверждения' }) + } + + const pending = await prisma.pendingEmail.findUnique({ where: { token } }) + if (!pending || pending.expiresAt < new Date()) { + return reply.code(400).send({ error: 'Токен подтверждения недействителен или истёк' }) + } + + await prisma.user.update({ + where: { id: pending.userId }, + data: { email: pending.email }, + }) + + await prisma.pendingEmail.delete({ where: { id: pending.id } }) + + const clientUrl = (process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173').replace(/\/$/, '') + return reply.redirect(`${clientUrl}/me?emailVerified=1`) + }) } diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index 5e6137e..faf74b3 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -69,7 +69,6 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken const norm = trimmed ? normalizeEmail(trimmed) : null if (linkToUserId) { - if (!norm) return null await prisma.oAuthAccount.create({ data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken }, }) @@ -84,13 +83,13 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken return user } - if (!norm) return null + const email = norm || `${provider}_${providerUserId}@vk.local` user = await prisma.user.create({ data: { - email: norm, - displayName: norm.split('@')[0], - avatar: await generateAvatar(norm), + email, + displayName: norm ? norm.split('@')[0] : 'Пользователь', + avatar: await generateAvatar(email), avatarStyle: 'avataaars', }, }) @@ -206,7 +205,6 @@ export async function registerOAuthSocialRoutes(fastify) { const emailSuggestion = claims?.email ?? tokenBody?.email ?? null if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id') - if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email') const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined @@ -218,8 +216,6 @@ export async function registerOAuthSocialRoutes(fastify) { linkToUserId, }) - if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK') - if (linkToUserId) { const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173' return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)