feat(client): add auth methods section to settings page
This commit is contained in:
@@ -53,12 +53,7 @@ export function OrderChat({ messages, isPending, onSend }: Props) {
|
|||||||
const isAdminMsg = m.authorType === 'admin'
|
const isAdminMsg = m.authorType === 'admin'
|
||||||
const adminAv = adminAvatarQuery.data
|
const adminAv = adminAvatarQuery.data
|
||||||
const avatarNode = isAdminMsg ? (
|
const avatarNode = isAdminMsg ? (
|
||||||
<UserAvatar
|
<UserAvatar userId="admin" avatarUrl={adminAv?.avatar} avatarStyle={adminAv?.avatarStyle} size={24} />
|
||||||
userId="admin"
|
|
||||||
avatarUrl={adminAv?.avatar}
|
|
||||||
avatarStyle={adminAv?.avatarStyle}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
) : currentUser ? (
|
) : currentUser ? (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={currentUser.id}
|
userId={currentUser.id}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
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 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 FormControl from '@mui/material/FormControl'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
@@ -13,15 +14,20 @@ import Typography from '@mui/material/Typography'
|
|||||||
import { createAvatar } from '@dicebear/core'
|
import { createAvatar } from '@dicebear/core'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
|
import { AVATAR_STYLES, DEFAULT_STYLE_ID, getStyleById } from '@/shared/lib/avatar-styles'
|
||||||
import {
|
import {
|
||||||
$requestEmailChangeCodeError,
|
$requestEmailChangeCodeError,
|
||||||
$updateProfileError,
|
$updateProfileError,
|
||||||
$user,
|
$user,
|
||||||
$verifyEmailChangeError,
|
$verifyEmailChangeError,
|
||||||
|
fetchAuthMethodsFx,
|
||||||
requestEmailChangeCodeFx,
|
requestEmailChangeCodeFx,
|
||||||
|
setPasswordFx,
|
||||||
|
unlinkOAuthFx,
|
||||||
updateProfileFx,
|
updateProfileFx,
|
||||||
verifyEmailChangeFx,
|
verifyEmailChangeFx,
|
||||||
|
type AuthMethod,
|
||||||
} from '@/shared/model/auth'
|
} from '@/shared/model/auth'
|
||||||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||||||
import type { AxiosError } from 'axios'
|
import type { AxiosError } from 'axios'
|
||||||
@@ -64,6 +70,43 @@ export function SettingsPage() {
|
|||||||
|
|
||||||
const hasUnsavedPreview = previewSrc !== null
|
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 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>
|
||||||
}
|
}
|
||||||
@@ -134,7 +177,7 @@ export function SettingsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
|
||||||
{hasUnsavedPreview ? 'Предпросмотр' : hasAvatar ? 'Сохранён' : 'Авто'}
|
{hasUnsavedPreview ? 'Предпросмотр' : user.avatar ? 'Сохранён' : 'Авто'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{hasUnsavedPreview && (
|
{hasUnsavedPreview && (
|
||||||
@@ -201,6 +244,94 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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={() => setShowSetPassword(true)}>
|
||||||
|
Установить пароль
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!m.active && m.type !== 'password' && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
component="a"
|
||||||
|
href={`/api/auth/oauth/${m.type}/link`}
|
||||||
|
>
|
||||||
|
Привязать
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user