448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import Alert from '@mui/material/Alert'
|
||
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 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 TextField from '@mui/material/TextField'
|
||
import Typography from '@mui/material/Typography'
|
||
import { createAvatar } from '@dicebear/core'
|
||
import { useMutation } from '@tanstack/react-query'
|
||
import { useUnit } from 'effector-react'
|
||
import { useForm } from 'react-hook-form'
|
||
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
|
||
import {
|
||
$requestEmailChangeCodeError,
|
||
$updateProfileError,
|
||
$user,
|
||
$verifyEmailChangeError,
|
||
fetchAuthMethodsFx,
|
||
requestEmailChangeCodeFx,
|
||
setPasswordFx,
|
||
unlinkOAuthFx,
|
||
updateProfileFx,
|
||
verifyEmailChangeFx,
|
||
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() {
|
||
const user = useUnit($user)
|
||
const pendingEmailReq = useUnit(requestEmailChangeCodeFx.pending)
|
||
const pendingEmailVerify = useUnit(verifyEmailChangeFx.pending)
|
||
const pendingProfile = useUnit(updateProfileFx.pending)
|
||
const errorEmailReq = useUnit($requestEmailChangeCodeError)
|
||
const errorProfile = useUnit($updateProfileError)
|
||
const errorEmailVerify = useUnit($verifyEmailChangeError)
|
||
|
||
const emailForm = useForm<{ newEmail: string; code: string }>({
|
||
defaultValues: { newEmail: '', code: '' },
|
||
mode: 'onChange',
|
||
})
|
||
|
||
const profileForm = useForm<{ displayName: string }>({
|
||
defaultValues: {
|
||
displayName: user?.displayName ? String(user.displayName) : '',
|
||
},
|
||
mode: 'onChange',
|
||
})
|
||
|
||
const emailErrorMsg = getApiErrorMessage(errorEmailReq) ?? getApiErrorMessage(errorEmailVerify)
|
||
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) {
|
||
return <Alert severity="info">Нужно войти. Перейдите на страницу «Вход».</Alert>
|
||
}
|
||
|
||
return (
|
||
<Box>
|
||
<Typography variant="h4" gutterBottom>
|
||
Настройки
|
||
</Typography>
|
||
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||
Текущая почта: <b>{user.email}</b>
|
||
</Typography>
|
||
|
||
{emailErrorMsg && (
|
||
<Alert severity="error" sx={{ mb: 2 }}>
|
||
{emailErrorMsg}
|
||
</Alert>
|
||
)}
|
||
{profileErrorMsg && (
|
||
<Alert severity="error" sx={{ mb: 2 }}>
|
||
{profileErrorMsg}
|
||
</Alert>
|
||
)}
|
||
|
||
<Stack spacing={3} sx={{ maxWidth: 560 }}>
|
||
<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>
|
||
|
||
<Divider />
|
||
|
||
<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 && (
|
||
<>
|
||
<Divider />
|
||
<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>
|
||
</>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
<Box>
|
||
<Typography variant="h6" gutterBottom>
|
||
Смена почты
|
||
</Typography>
|
||
<Stack spacing={2}>
|
||
<TextField label="Новая почта" {...emailForm.register('newEmail')} />
|
||
<Button
|
||
variant="outlined"
|
||
disabled={!emailForm.watch('newEmail') || pendingEmailReq}
|
||
onClick={() => requestEmailChangeCodeFx(emailForm.getValues('newEmail').trim())}
|
||
>
|
||
Отправить код на новую почту
|
||
</Button>
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
|
||
<TextField label="Код (6 цифр)" inputMode="numeric" {...emailForm.register('code')} />
|
||
<Button
|
||
variant="contained"
|
||
disabled={emailForm.watch('code').trim().length !== 6 || pendingEmailVerify}
|
||
onClick={() =>
|
||
verifyEmailChangeFx({
|
||
newEmail: emailForm.getValues('newEmail').trim(),
|
||
code: emailForm.getValues('code').trim(),
|
||
})
|
||
}
|
||
>
|
||
Подтвердить
|
||
</Button>
|
||
</Stack>
|
||
</Stack>
|
||
</Box>
|
||
</Stack>
|
||
</Box>
|
||
)
|
||
}
|