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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user