refactor(SettingsPage): split into ProfileSection, AvatarSection, AuthMethodsSection
- Extract ProfileSection (45 lines): display name form with save button - Extract AvatarSection (114 lines): avatar preview, style selector, generate/save/cancel - Extract AuthMethodsSection (204 lines): auth methods list, set/change password forms - Rewrite SettingsPage as composer (41 lines): composes 3 sections with dividers - Add tests for all 3 sections
This commit is contained in:
@@ -0,0 +1,204 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { apiClient } from '@/shared/api/client'
|
||||||
|
import { $user, fetchAuthMethodsFx, setPasswordFx, unlinkOAuthFx, type AuthMethod } from '@/shared/model/auth'
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
|
||||||
|
|
||||||
|
export function AuthMethodsSection() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
|
||||||
|
const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
|
||||||
|
const [showSetPassword, setShowSetPassword] = useState(false)
|
||||||
|
const passwordForm = useForm<{ password: string; passwordConfirm: string }>({
|
||||||
|
defaultValues: { password: '', passwordConfirm: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAuthMethodsFx()
|
||||||
|
.then(setAuthMethods)
|
||||||
|
.catch(() => {
|
||||||
|
setAuthMethods([])
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setPasswordMutation = useMutation({
|
||||||
|
mutationFn: async (pw: string) => {
|
||||||
|
await setPasswordFx(pw)
|
||||||
|
const methods = await fetchAuthMethodsFx()
|
||||||
|
setAuthMethods(methods)
|
||||||
|
setShowSetPassword(false)
|
||||||
|
},
|
||||||
|
onError: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const unlinkMutation = useMutation({
|
||||||
|
mutationFn: async (provider: 'vk' | 'yandex') => {
|
||||||
|
await unlinkOAuthFx(provider)
|
||||||
|
const methods = await fetchAuthMethodsFx()
|
||||||
|
setAuthMethods(methods)
|
||||||
|
},
|
||||||
|
onError: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [showChangePassword, setShowChangePassword] = useState(false)
|
||||||
|
const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({
|
||||||
|
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const changePasswordMutation = useMutation({
|
||||||
|
mutationFn: async (params: { oldPassword: string; newPassword: string }) => {
|
||||||
|
await apiClient.post('me/change-password', params)
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowChangePassword(false)
|
||||||
|
changePasswordForm.reset()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkedCount = useCallback(() => {
|
||||||
|
return authMethods.filter((m) => m.active).length
|
||||||
|
}, [authMethods])
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Методы входа
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={1}>
|
||||||
|
{authMethods.map((m) => (
|
||||||
|
<Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
||||||
|
<Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
|
||||||
|
<Chip label={m.active ? 'Привязан' : 'Не привязан'} color={m.active ? 'success' : 'default'} size="small" />
|
||||||
|
{m.active && m.type !== 'password' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
disabled={linkedCount() <= 1}
|
||||||
|
onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
|
||||||
|
>
|
||||||
|
Отвязать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{m.active && m.type === 'password' && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setShowChangePassword(true)}>
|
||||||
|
Сменить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!m.active && m.type === 'password' && (
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
|
||||||
|
Установить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!m.active && m.type !== 'password' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={`/api/auth/oauth/${m.type}/link?token=${localStorage.getItem('craftshop_auth_token') || ''}`}
|
||||||
|
>
|
||||||
|
Привязать
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{showSetPassword && (
|
||||||
|
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...passwordForm.register('passwordConfirm')}
|
||||||
|
fullWidth
|
||||||
|
error={
|
||||||
|
Boolean(passwordForm.watch('passwordConfirm')) &&
|
||||||
|
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
passwordForm.watch('passwordConfirm') &&
|
||||||
|
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
||||||
|
? 'Пароли не совпадают'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!passwordForm.watch('password') ||
|
||||||
|
passwordForm.watch('password').length < 8 ||
|
||||||
|
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
|
||||||
|
setPasswordMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => setShowSetPassword(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showChangePassword && (
|
||||||
|
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
||||||
|
<TextField label="Текущий пароль" type="password" {...changePasswordForm.register('oldPassword')} fullWidth />
|
||||||
|
<TextField label="Новый пароль" type="password" {...changePasswordForm.register('newPassword')} fullWidth />
|
||||||
|
<TextField
|
||||||
|
label="Подтверждение пароля"
|
||||||
|
type="password"
|
||||||
|
{...changePasswordForm.register('confirmPassword')}
|
||||||
|
fullWidth
|
||||||
|
error={
|
||||||
|
Boolean(changePasswordForm.watch('confirmPassword')) &&
|
||||||
|
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
||||||
|
}
|
||||||
|
helperText={
|
||||||
|
changePasswordForm.watch('confirmPassword') &&
|
||||||
|
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
||||||
|
? 'Пароли не совпадают'
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={
|
||||||
|
!changePasswordForm.watch('oldPassword') ||
|
||||||
|
!changePasswordForm.watch('newPassword') ||
|
||||||
|
changePasswordForm.watch('newPassword').length < 8 ||
|
||||||
|
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword') ||
|
||||||
|
changePasswordMutation.isPending
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
changePasswordMutation.mutate({
|
||||||
|
oldPassword: changePasswordForm.getValues('oldPassword'),
|
||||||
|
newPassword: changePasswordForm.getValues('newPassword'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => setShowChangePassword(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import FormControl from '@mui/material/FormControl'
|
||||||
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
|
import Select from '@mui/material/Select'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { createAvatar } from '@dicebear/core'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
|
||||||
|
import { $user, updateProfileFx } from '@/shared/model/auth'
|
||||||
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
|
|
||||||
|
export function AvatarSection() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
const pendingProfile = useUnit(updateProfileFx.pending)
|
||||||
|
|
||||||
|
const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID)
|
||||||
|
const [previewSrc, setPreviewSrc] = useState<string | null>(null)
|
||||||
|
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
||||||
|
|
||||||
|
const hasUnsavedPreview = previewSrc !== null
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Аватар
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<UserAvatar
|
||||||
|
userId={user.id}
|
||||||
|
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
|
||||||
|
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
|
||||||
|
size={80}
|
||||||
|
sx={{
|
||||||
|
border: 2,
|
||||||
|
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{hasUnsavedPreview && (
|
||||||
|
<Box sx={{ textAlign: 'center' }}>
|
||||||
|
<UserAvatar
|
||||||
|
userId={user.id}
|
||||||
|
avatarUrl={user.avatar}
|
||||||
|
avatarStyle={user.avatarStyle}
|
||||||
|
size={80}
|
||||||
|
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
|
Текущий
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||||
|
<InputLabel>Стиль</InputLabel>
|
||||||
|
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
|
||||||
|
{AVATAR_STYLES.map((s) => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => {
|
||||||
|
const seed = `${user.id}_${Date.now()}`
|
||||||
|
const styleDef = getStyleById(selectedStyle)
|
||||||
|
const avatar = createAvatar(styleDef.style, { seed })
|
||||||
|
setPreviewSrc(avatar.toDataUri())
|
||||||
|
setPreviewStyle(selectedStyle)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сгенерировать
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{hasUnsavedPreview && (
|
||||||
|
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={pendingProfile}
|
||||||
|
onClick={() => {
|
||||||
|
updateProfileFx({
|
||||||
|
displayName: user.displayName?.trim() || null,
|
||||||
|
avatar: previewSrc,
|
||||||
|
avatarStyle: previewStyle,
|
||||||
|
})
|
||||||
|
setPreviewSrc(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
<Button variant="text" onClick={() => setPreviewSrc(null)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Stack from '@mui/material/Stack'
|
||||||
|
import TextField from '@mui/material/TextField'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { $user, updateProfileFx } from '@/shared/model/auth'
|
||||||
|
|
||||||
|
export function ProfileSection() {
|
||||||
|
const user = useUnit($user)
|
||||||
|
const pendingProfile = useUnit(updateProfileFx.pending)
|
||||||
|
|
||||||
|
const profileForm = useForm<{ displayName: string }>({
|
||||||
|
defaultValues: { displayName: user?.displayName ? String(user.displayName) : '' },
|
||||||
|
mode: 'onChange',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Профиль
|
||||||
|
</Typography>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Имя или ник"
|
||||||
|
helperText="До 40 символов"
|
||||||
|
slotProps={{ htmlInput: { maxLength: 40 } }}
|
||||||
|
{...profileForm.register('displayName')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
disabled={pendingProfile}
|
||||||
|
onClick={() => {
|
||||||
|
const raw = profileForm.getValues('displayName')
|
||||||
|
const name = raw.trim()
|
||||||
|
updateProfileFx({ displayName: name.length ? name : null })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,113 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
|
||||||
import Chip from '@mui/material/Chip'
|
|
||||||
import Divider from '@mui/material/Divider'
|
import Divider from '@mui/material/Divider'
|
||||||
import FormControl from '@mui/material/FormControl'
|
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
|
||||||
import Select from '@mui/material/Select'
|
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import TextField from '@mui/material/TextField'
|
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { createAvatar } from '@dicebear/core'
|
|
||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { $user } from '@/shared/model/auth'
|
||||||
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
|
import { AuthMethodsSection } from './AuthMethodsSection'
|
||||||
import {
|
import { AvatarSection } from './AvatarSection'
|
||||||
$updateProfileError,
|
import { ProfileSection } from './ProfileSection'
|
||||||
$user,
|
|
||||||
fetchAuthMethodsFx,
|
|
||||||
setPasswordFx,
|
|
||||||
unlinkOAuthFx,
|
|
||||||
updateProfileFx,
|
|
||||||
type AuthMethod,
|
|
||||||
} from '@/shared/model/auth'
|
|
||||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
|
||||||
import { apiClient } from '@/shared/api/client'
|
|
||||||
import type { AxiosError } from 'axios'
|
|
||||||
|
|
||||||
function getApiErrorMessage(error: unknown): string | null {
|
|
||||||
const e = error as AxiosError<{ error?: string }>
|
|
||||||
const msg = e?.response?.data?.error
|
|
||||||
return msg ? String(msg) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const user = useUnit($user)
|
const user = useUnit($user)
|
||||||
const pendingProfile = useUnit(updateProfileFx.pending)
|
|
||||||
const errorProfile = useUnit($updateProfileError)
|
|
||||||
|
|
||||||
const profileForm = useForm<{ displayName: string }>({
|
|
||||||
defaultValues: {
|
|
||||||
displayName: user?.displayName ? String(user.displayName) : '',
|
|
||||||
},
|
|
||||||
mode: 'onChange',
|
|
||||||
})
|
|
||||||
|
|
||||||
const profileErrorMsg = getApiErrorMessage(errorProfile)
|
|
||||||
|
|
||||||
const [selectedStyle, setSelectedStyle] = useState(user?.avatarStyle || DEFAULT_STYLE_ID)
|
|
||||||
const [previewSrc, setPreviewSrc] = useState<string | null>(null)
|
|
||||||
const [previewStyle, setPreviewStyle] = useState<string>(DEFAULT_STYLE_ID)
|
|
||||||
|
|
||||||
const hasUnsavedPreview = previewSrc !== null
|
|
||||||
|
|
||||||
const [authMethods, setAuthMethods] = useState<AuthMethod[]>([])
|
|
||||||
const [showSetPassword, setShowSetPassword] = useState(false)
|
|
||||||
const passwordForm = useForm<{ password: string; passwordConfirm: string }>({
|
|
||||||
defaultValues: { password: '', passwordConfirm: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAuthMethodsFx()
|
|
||||||
.then(setAuthMethods)
|
|
||||||
.catch(() => {
|
|
||||||
setAuthMethods([])
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const setPasswordMutation = useMutation({
|
|
||||||
mutationFn: async (pw: string) => {
|
|
||||||
await setPasswordFx(pw)
|
|
||||||
const methods = await fetchAuthMethodsFx()
|
|
||||||
setAuthMethods(methods)
|
|
||||||
setShowSetPassword(false)
|
|
||||||
},
|
|
||||||
onError: () => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const unlinkMutation = useMutation({
|
|
||||||
mutationFn: async (provider: 'vk' | 'yandex') => {
|
|
||||||
await unlinkOAuthFx(provider)
|
|
||||||
const methods = await fetchAuthMethodsFx()
|
|
||||||
setAuthMethods(methods)
|
|
||||||
},
|
|
||||||
onError: () => {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false)
|
|
||||||
const changePasswordForm = useForm<{ oldPassword: string; newPassword: string; confirmPassword: string }>({
|
|
||||||
defaultValues: { oldPassword: '', newPassword: '', confirmPassword: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const changePasswordMutation = useMutation({
|
|
||||||
mutationFn: async (params: { oldPassword: string; newPassword: string }) => {
|
|
||||||
await apiClient.post('me/change-password', params)
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
setShowChangePassword(false)
|
|
||||||
changePasswordForm.reset()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const linkedCount = useCallback(() => {
|
|
||||||
return authMethods.filter((m) => m.active).length
|
|
||||||
}, [authMethods])
|
|
||||||
|
|
||||||
const METHOD_LABELS: Record<string, string> = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' }
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||||||
@@ -122,271 +25,14 @@ export function SettingsPage() {
|
|||||||
Текущая почта: <b>{user.email}</b>
|
Текущая почта: <b>{user.email}</b>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{profileErrorMsg && (
|
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
|
||||||
{profileErrorMsg}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||||||
<Box>
|
<ProfileSection />
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Профиль
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Имя или ник"
|
|
||||||
helperText="До 40 символов"
|
|
||||||
slotProps={{ htmlInput: { maxLength: 40 } }}
|
|
||||||
{...profileForm.register('displayName')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={pendingProfile}
|
|
||||||
onClick={() => {
|
|
||||||
const raw = profileForm.getValues('displayName')
|
|
||||||
const name = raw.trim()
|
|
||||||
updateProfileFx({ displayName: name.length ? name : null })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
<AvatarSection />
|
||||||
<Box>
|
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Аватар
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={3} sx={{ alignItems: 'flex-start', mb: 2 }}>
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<UserAvatar
|
|
||||||
userId={user.id}
|
|
||||||
avatarUrl={hasUnsavedPreview ? previewSrc : user.avatar}
|
|
||||||
avatarStyle={hasUnsavedPreview ? previewStyle : user.avatarStyle}
|
|
||||||
size={80}
|
|
||||||
sx={{
|
|
||||||
border: 2,
|
|
||||||
borderColor: hasUnsavedPreview ? 'warning.main' : 'primary.main',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
{hasUnsavedPreview && (
|
|
||||||
<Box sx={{ textAlign: 'center' }}>
|
|
||||||
<UserAvatar
|
|
||||||
userId={user.id}
|
|
||||||
avatarUrl={user.avatar}
|
|
||||||
avatarStyle={user.avatarStyle}
|
|
||||||
size={80}
|
|
||||||
sx={{ border: 2, borderColor: 'divider', opacity: 0.6 }}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
|
||||||
Текущий
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
|
||||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
|
||||||
<InputLabel>Стиль</InputLabel>
|
|
||||||
<Select value={selectedStyle} label="Стиль" onChange={(e) => setSelectedStyle(e.target.value)}>
|
|
||||||
{AVATAR_STYLES.map((s) => (
|
|
||||||
<MenuItem key={s.id} value={s.id}>
|
|
||||||
{s.label}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => {
|
|
||||||
const seed = `${user.id}_${Date.now()}`
|
|
||||||
const styleDef = getStyleById(selectedStyle)
|
|
||||||
const avatar = createAvatar(styleDef.style, { seed })
|
|
||||||
setPreviewSrc(avatar.toDataUri())
|
|
||||||
setPreviewStyle(selectedStyle)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сгенерировать
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{hasUnsavedPreview && (
|
|
||||||
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, mb: 1 }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={pendingProfile}
|
|
||||||
onClick={() => {
|
|
||||||
updateProfileFx({
|
|
||||||
displayName: user.displayName?.trim() || null,
|
|
||||||
avatar: previewSrc,
|
|
||||||
avatarStyle: previewStyle,
|
|
||||||
})
|
|
||||||
setPreviewSrc(null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
<Button variant="text" onClick={() => setPreviewSrc(null)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{!user.isAdmin && (
|
{!user.isAdmin && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Box>
|
<AuthMethodsSection />
|
||||||
<Typography variant="h6" gutterBottom>
|
|
||||||
Методы входа
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{authMethods.map((m) => (
|
|
||||||
<Stack key={m.type} direction="row" spacing={2} sx={{ alignItems: 'center' }}>
|
|
||||||
<Typography sx={{ minWidth: 120 }}>{METHOD_LABELS[m.type] || m.type}</Typography>
|
|
||||||
<Chip
|
|
||||||
label={m.active ? 'Привязан' : 'Не привязан'}
|
|
||||||
color={m.active ? 'success' : 'default'}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
{m.active && m.type !== 'password' && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
disabled={linkedCount() <= 1}
|
|
||||||
onClick={() => unlinkMutation.mutate(m.type as 'vk' | 'yandex')}
|
|
||||||
>
|
|
||||||
Отвязать
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{m.active && m.type === 'password' && (
|
|
||||||
<Button size="small" variant="outlined" onClick={() => setShowChangePassword(true)}>
|
|
||||||
Сменить пароль
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!m.active && m.type === 'password' && (
|
|
||||||
<Button size="small" variant="outlined" onClick={() => setShowSetPassword(true)}>
|
|
||||||
Установить пароль
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!m.active && m.type !== 'password' && (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
variant="outlined"
|
|
||||||
component="a"
|
|
||||||
href={`/api/auth/oauth/${m.type}/link?token=${localStorage.getItem('craftshop_auth_token') || ''}`}
|
|
||||||
>
|
|
||||||
Привязать
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{showSetPassword && (
|
|
||||||
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
||||||
<TextField label="Пароль" type="password" {...passwordForm.register('password')} fullWidth />
|
|
||||||
<TextField
|
|
||||||
label="Подтверждение пароля"
|
|
||||||
type="password"
|
|
||||||
{...passwordForm.register('passwordConfirm')}
|
|
||||||
fullWidth
|
|
||||||
error={
|
|
||||||
Boolean(passwordForm.watch('passwordConfirm')) &&
|
|
||||||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
|
||||||
}
|
|
||||||
helperText={
|
|
||||||
passwordForm.watch('passwordConfirm') &&
|
|
||||||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm')
|
|
||||||
? 'Пароли не совпадают'
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={
|
|
||||||
!passwordForm.watch('password') ||
|
|
||||||
passwordForm.watch('password').length < 8 ||
|
|
||||||
passwordForm.watch('password') !== passwordForm.watch('passwordConfirm') ||
|
|
||||||
setPasswordMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={() => setPasswordMutation.mutate(passwordForm.getValues('password'))}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
<Button variant="text" onClick={() => setShowSetPassword(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showChangePassword && (
|
|
||||||
<Stack spacing={2} sx={{ mt: 2, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 1 }}>
|
|
||||||
<TextField
|
|
||||||
label="Текущий пароль"
|
|
||||||
type="password"
|
|
||||||
{...changePasswordForm.register('oldPassword')}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Новый пароль"
|
|
||||||
type="password"
|
|
||||||
{...changePasswordForm.register('newPassword')}
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Подтверждение пароля"
|
|
||||||
type="password"
|
|
||||||
{...changePasswordForm.register('confirmPassword')}
|
|
||||||
fullWidth
|
|
||||||
error={
|
|
||||||
Boolean(changePasswordForm.watch('confirmPassword')) &&
|
|
||||||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
|
||||||
}
|
|
||||||
helperText={
|
|
||||||
changePasswordForm.watch('confirmPassword') &&
|
|
||||||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword')
|
|
||||||
? 'Пароли не совпадают'
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Stack direction="row" spacing={1}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
disabled={
|
|
||||||
!changePasswordForm.watch('oldPassword') ||
|
|
||||||
!changePasswordForm.watch('newPassword') ||
|
|
||||||
changePasswordForm.watch('newPassword').length < 8 ||
|
|
||||||
changePasswordForm.watch('newPassword') !== changePasswordForm.watch('confirmPassword') ||
|
|
||||||
changePasswordMutation.isPending
|
|
||||||
}
|
|
||||||
onClick={() =>
|
|
||||||
changePasswordMutation.mutate({
|
|
||||||
oldPassword: changePasswordForm.getValues('oldPassword'),
|
|
||||||
newPassword: changePasswordForm.getValues('newPassword'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</Button>
|
|
||||||
<Button variant="text" onClick={() => setShowChangePassword(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AuthMethodsSection } from '../AuthMethodsSection'
|
||||||
|
|
||||||
|
vi.mock('@/shared/model/auth', () => ({
|
||||||
|
$user: {
|
||||||
|
defaultState: { id: '1', email: 'test@test.com' },
|
||||||
|
subscribe: () => () => {},
|
||||||
|
getState: () => ({ id: '1', email: 'test@test.com' }),
|
||||||
|
watch: () => () => {},
|
||||||
|
on: () => {},
|
||||||
|
reset: () => {},
|
||||||
|
},
|
||||||
|
fetchAuthMethodsFx: vi.fn().mockResolvedValue([]),
|
||||||
|
setPasswordFx: vi.fn(),
|
||||||
|
unlinkOAuthFx: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/api/client', () => ({ apiClient: { post: vi.fn() } }))
|
||||||
|
|
||||||
|
vi.mock('effector-react', async () => {
|
||||||
|
return {
|
||||||
|
useUnit: () => ({ id: '1', email: 'test@test.com' }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderSection() {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<AuthMethodsSection />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AuthMethodsSection', () => {
|
||||||
|
it('renders auth methods section', async () => {
|
||||||
|
renderSection()
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Методы входа')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AvatarSection } from '../AvatarSection'
|
||||||
|
|
||||||
|
vi.mock('@/shared/model/auth', () => ({
|
||||||
|
$user: {
|
||||||
|
defaultState: { id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' },
|
||||||
|
subscribe: () => () => {},
|
||||||
|
getState: () => ({ id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }),
|
||||||
|
watch: () => () => {},
|
||||||
|
on: () => {},
|
||||||
|
reset: () => {},
|
||||||
|
},
|
||||||
|
updateProfileFx: { pending: false },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('effector-react', async () => {
|
||||||
|
return {
|
||||||
|
useUnit: () => ({ id: '1', avatar: null, avatarStyle: 'initials', displayName: 'Test' }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@dicebear/core', () => ({
|
||||||
|
createAvatar: vi.fn(() => ({
|
||||||
|
toDataUri: () => 'data:image/svg+xml,<svg></svg>',
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AvatarSection', () => {
|
||||||
|
it('renders avatar section', async () => {
|
||||||
|
render(<AvatarSection />)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Аватар')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ProfileSection } from '../ProfileSection'
|
||||||
|
|
||||||
|
vi.mock('@/shared/model/auth', () => ({
|
||||||
|
$user: null,
|
||||||
|
$updateProfileError: null,
|
||||||
|
updateProfileFx: { pending: false },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('effector-react', async () => {
|
||||||
|
const actual = await vi.importActual('effector-react')
|
||||||
|
return { ...actual, useUnit: () => null }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ProfileSection', () => {
|
||||||
|
it('renders profile section', () => {
|
||||||
|
render(<ProfileSection />)
|
||||||
|
expect(screen.getByText('Профиль')).toBeTruthy()
|
||||||
|
expect(screen.getByLabelText('Имя или ник')).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Сохранить' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user