пва
This commit is contained in:
@@ -8,8 +8,8 @@ import Stack from '@mui/material/Stack'
|
|||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { AppHeader } from '@/app/layout/AppHeader'
|
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 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 { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate'
|
||||||
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
import { ScrollToTop } from '@/shared/ui/ScrollToTop'
|
||||||
|
|
||||||
@@ -91,12 +91,7 @@ export function MainLayout({ children }: PropsWithChildren) {
|
|||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
|
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, '&:hover': { color: '#4A76A8' } }}
|
||||||
>
|
>
|
||||||
<Box
|
<Box component="img" src={vkLogoSrc} alt="VK" sx={{ width: 20, height: 20 }} />
|
||||||
component="img"
|
|
||||||
src={vkLogoSrc}
|
|
||||||
alt="VK"
|
|
||||||
sx={{ width: 20, height: 20 }}
|
|
||||||
/>
|
|
||||||
VK
|
VK
|
||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import Typography from '@mui/material/Typography'
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
$user,
|
$user,
|
||||||
changePasswordFx,
|
changePasswordFx,
|
||||||
fetchAuthMethodsFx,
|
fetchAuthMethodsFx,
|
||||||
|
requestEmailChangeFx,
|
||||||
setPasswordFx,
|
setPasswordFx,
|
||||||
unlinkOAuthFx,
|
unlinkOAuthFx,
|
||||||
type AuthMethod,
|
type AuthMethod,
|
||||||
@@ -77,11 +79,78 @@ export function AuthMethodsSection() {
|
|||||||
return authMethods.filter((m) => m.active).length
|
return authMethods.filter((m) => m.active).length
|
||||||
}, [authMethods])
|
}, [authMethods])
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const emailVerified = searchParams.get('emailVerified')
|
||||||
|
|
||||||
|
const emailForm = useForm<{ email: string }>({
|
||||||
|
defaultValues: { email: '' },
|
||||||
|
})
|
||||||
|
const [emailChangeError, setEmailChangeError] = useState<string | null>(null)
|
||||||
|
const [verificationUrl, setVerificationUrl] = useState<string | null>(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
|
if (!user) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Почта
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{emailVerified === '1' && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
Почта успешно подтверждена
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography sx={{ mb: 2 }} color="text.secondary">
|
||||||
|
{user.email}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{!verificationUrl && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Новая почта"
|
||||||
|
type="email"
|
||||||
|
size="small"
|
||||||
|
{...emailForm.register('email')}
|
||||||
|
error={Boolean(emailChangeError)}
|
||||||
|
helperText={emailChangeError}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
disabled={!emailForm.watch('email') || emailChangeMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const email = emailForm.getValues('email')
|
||||||
|
if (email) emailChangeMutation.mutate(email)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сменить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verificationUrl && (
|
||||||
|
<Alert severity="info" sx={{ mb: 2 }}>
|
||||||
|
<Stack spacing={1} direction="row" sx={{ alignItems: 'center' }}>
|
||||||
|
<span>Ссылка подтверждения готова.</span>
|
||||||
|
<Button size="small" variant="contained" href={verificationUrl}>
|
||||||
|
Подтвердить email
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||||
Методы входа
|
Методы входа
|
||||||
</Typography>
|
</Typography>
|
||||||
{fetchError && (
|
{fetchError && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { AuthMethodsSection } from '../AuthMethodsSection'
|
import { AuthMethodsSection } from '../AuthMethodsSection'
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ vi.mock('@/shared/model/auth', () => ({
|
|||||||
fetchAuthMethodsFx: vi.fn().mockResolvedValue([]),
|
fetchAuthMethodsFx: vi.fn().mockResolvedValue([]),
|
||||||
setPasswordFx: vi.fn(),
|
setPasswordFx: vi.fn(),
|
||||||
unlinkOAuthFx: vi.fn(),
|
unlinkOAuthFx: vi.fn(),
|
||||||
|
requestEmailChangeFx: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/shared/api/client', () => ({ apiClient: { post: 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 } } })
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
return render(
|
return render(
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<AuthMethodsSection />
|
<MemoryRouter>
|
||||||
|
<AuthMethodsSection />
|
||||||
|
</MemoryRouter>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ export const changePasswordFx = createEffect(async (params: { oldPassword: strin
|
|||||||
await apiClient.post('me/change-password', params)
|
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 -----
|
// ----- Error stores -----
|
||||||
|
|
||||||
export const $updateProfileError = createErrorStore(updateProfileFx).$error
|
export const $updateProfileError = createErrorStore(updateProfileFx).$error
|
||||||
|
|||||||
@@ -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");
|
||||||
Binary file not shown.
@@ -91,6 +91,7 @@ model User {
|
|||||||
reviews Review[]
|
reviews Review[]
|
||||||
orderMessageReadStates UserOrderMessageReadState[]
|
orderMessageReadStates UserOrderMessageReadState[]
|
||||||
oauthAccounts OAuthAccount[]
|
oauthAccounts OAuthAccount[]
|
||||||
|
pendingEmails PendingEmail[]
|
||||||
notificationPreference NotificationPreference?
|
notificationPreference NotificationPreference?
|
||||||
notificationLogs NotificationLog[]
|
notificationLogs NotificationLog[]
|
||||||
}
|
}
|
||||||
@@ -261,6 +262,20 @@ model OAuthAccount {
|
|||||||
@@index([userId])
|
@@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 {
|
model AuthCode {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String
|
email String
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { normalizeEmail } from '../lib/auth.js'
|
||||||
import { prisma } from '../lib/prisma.js'
|
import { prisma } from '../lib/prisma.js'
|
||||||
import { mapUserForClient } from './auth.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`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
|
|||||||
const norm = trimmed ? normalizeEmail(trimmed) : null
|
const norm = trimmed ? normalizeEmail(trimmed) : null
|
||||||
|
|
||||||
if (linkToUserId) {
|
if (linkToUserId) {
|
||||||
if (!norm) return null
|
|
||||||
await prisma.oAuthAccount.create({
|
await prisma.oAuthAccount.create({
|
||||||
data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken },
|
data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken },
|
||||||
})
|
})
|
||||||
@@ -84,13 +83,13 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!norm) return null
|
const email = norm || `${provider}_${providerUserId}@vk.local`
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email: norm,
|
email,
|
||||||
displayName: norm.split('@')[0],
|
displayName: norm ? norm.split('@')[0] : 'Пользователь',
|
||||||
avatar: await generateAvatar(norm),
|
avatar: await generateAvatar(email),
|
||||||
avatarStyle: 'avataaars',
|
avatarStyle: 'avataaars',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -206,7 +205,6 @@ export async function registerOAuthSocialRoutes(fastify) {
|
|||||||
const emailSuggestion = claims?.email ?? tokenBody?.email ?? null
|
const emailSuggestion = claims?.email ?? tokenBody?.email ?? null
|
||||||
|
|
||||||
if (!vkUserId) return oauthErrorRedirect(reply, 'no_user_id')
|
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
|
const linkToUserId = pkceEntry.meta?.action === 'link' ? pkceEntry.meta.userId : undefined
|
||||||
|
|
||||||
@@ -218,8 +216,6 @@ export async function registerOAuthSocialRoutes(fastify) {
|
|||||||
linkToUserId,
|
linkToUserId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK')
|
|
||||||
|
|
||||||
if (linkToUserId) {
|
if (linkToUserId) {
|
||||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||||
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
|
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
|
||||||
|
|||||||
Reference in New Issue
Block a user