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`)