Feature/profile settings page (#452)

* Init change password form
This commit is contained in:
Ilya Y 2024-05-13 02:36:46 +03:00 committed by GitHub
parent 79749bd95e
commit a9f732d1a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 364 additions and 139 deletions

View File

@ -531,5 +531,13 @@
"It's OK. Just enter your email to receive a link to change your password": "It's OK. Just enter your email to receive a link to change your password",
"Restore password": "Restore password",
"Subscribing...": "Subscribing...",
"Unsubscribing...": "Unsubscribing..."
"Unsubscribing...": "Unsubscribing...",
"Login and security": "Login and security",
"Settings for account, email, password and login methods.": "Settings for account, email, password and login methods.",
"Current password": "Current password",
"Confirm your new password": "Confirm your new password",
"Connect": "Connect",
"Incorrect old password": "Incorrect old password",
"Repeat new password": "Repeat new password",
"Incorrect new password confirm": "Incorrect new password confirm"
}

View File

@ -558,5 +558,13 @@
"It's OK. Just enter your email to receive a link to change your password": "Ничего страшного. Просто укажите свою почту, чтобы получить ссылку для смены пароля",
"Restore password": "Восстановить пароль",
"Subscribing...": "Подписываем...",
"Unsubscribing...": "Отписываем..."
"Unsubscribing...": "Отписываем...",
"Login and security": "Вход и безопасность",
"Settings for account, email, password and login methods.": "Настройки аккаунта, почты, пароля и способов входа.",
"Current password": "Текущий пароль",
"Confirm your new password": "Подтвердите новый пароль",
"Connect": "Привязать",
"Incorrect old password": "Старый пароль не верен",
"Repeat new password": "Повторите новый пароль",
"Incorrect new password confirm": "Неверное подтверждение нового пароля"
}

View File

@ -48,11 +48,13 @@ type Props = {
onChange?: (text: string) => void
variant?: 'minimal' | 'bordered'
maxLength?: number
noLimits?: boolean
maxHeight?: number
submitButtonText?: string
quoteEnabled?: boolean
imageEnabled?: boolean
setClear?: boolean
resetToInitial?: boolean
smallHeight?: boolean
submitByCtrlEnter?: boolean
onlyBubbleControls?: boolean
@ -124,7 +126,7 @@ const SimplifiedEditor = (props: Props) => {
openOnClick: false,
}),
CharacterCount.configure({
limit: maxLength,
limit: props.noLimits ? null : maxLength,
}),
Blockquote.configure({
HTMLAttributes: {
@ -216,6 +218,10 @@ const SimplifiedEditor = (props: Props) => {
if (props.setClear) {
editor().commands.clearContent(true)
}
if (props.resetToInitial) {
editor().commands.clearContent(true)
editor().commands.setContent(props.initialContent)
}
})
const handleKeyDown = (event) => {

View File

@ -16,6 +16,9 @@ type Props = {
onBlur?: (value: string) => void
variant?: 'login' | 'registration'
disableAutocomplete?: boolean
noValidate?: boolean
onFocus?: () => void
value?: string
}
const minLength = 8
@ -27,7 +30,7 @@ export const PasswordField = (props: Props) => {
const [showPassword, setShowPassword] = createSignal(false)
const [error, setError] = createSignal<string>()
const validatePassword = (passwordToCheck) => {
const validatePassword = (passwordToCheck: string) => {
if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters')
}
@ -50,6 +53,7 @@ export const PasswordField = (props: Props) => {
}
props.onInput(value)
if (!props.noValidate) {
const errorValue = validatePassword(value)
if (errorValue) {
setError(errorValue)
@ -57,6 +61,7 @@ export const PasswordField = (props: Props) => {
setError()
}
}
}
createEffect(
on(
@ -78,6 +83,8 @@ export const PasswordField = (props: Props) => {
id="password"
name="password"
disabled={props.disabled}
onFocus={props.onFocus}
value={props.value ? props.value : ''}
autocomplete={props.disableAutocomplete ? 'one-time-code' : 'current-password'}
type={showPassword() ? 'text' : 'password'}
placeholder={props.placeholder || t('Password')}

View File

@ -20,6 +20,8 @@ import { useLocalize } from '../../context/localize'
import { useProfileForm } from '../../context/profile'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { ProfileInput } from '../../graphql/schema/core.gen'
import styles from '../../pages/profile/Settings.module.scss'
import { hideModal, showModal } from '../../stores/ui'
import { clone } from '../../utils/clone'
import { getImageUrl } from '../../utils/getImageUrl'
@ -35,14 +37,12 @@ import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover'
import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
import styles from '../../pages/profile/Settings.module.scss'
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTextarea/GrowingTextarea'))
export const ProfileSettings = () => {
const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore({})
const [prevForm, setPrevForm] = createStore<ProfileInput>({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false)
const [isSaving, setIsSaving] = createSignal(false)
const [social, setSocial] = createSignal([])
@ -59,6 +59,7 @@ export const ProfileSettings = () => {
const { showSnackbar } = useSnackbar()
const { loadAuthor, session } = useSession()
const { showConfirm } = useConfirm()
const [clearAbout, setClearAbout] = createSignal(false)
createEffect(() => {
if (Object.keys(form).length > 0 && !isFormInitialized()) {
@ -121,7 +122,9 @@ export const ProfileSettings = () => {
declineButtonVariant: 'secondary',
})
if (isConfirmed) {
setClearAbout(true)
setForm(clone(prevForm))
setClearAbout(false)
}
}
@ -171,11 +174,13 @@ export const ProfileSettings = () => {
on(
() => deepEqual(form, prevForm),
() => {
if (Object.keys(prevForm).length > 0) {
setIsFloatingPanelVisible(!deepEqual(form, prevForm))
}
},
{ defer: true },
),
)
const handleDeleteSocialLink = (link) => {
updateFormField('links', link, true)
}
@ -317,6 +322,8 @@ export const ProfileSettings = () => {
<h4>{t('About')}</h4>
<SimplifiedEditor
resetToInitial={clearAbout()}
noLimits={true}
variant="bordered"
onlyBubbleControls={true}
smallHeight={true}

View File

@ -13,6 +13,7 @@ import {
LoginInput,
ResendVerifyEmailInput,
SignupInput,
UpdateProfileInput,
VerifyEmailInput,
} from '@authorizerdev/authorizer-js'
import {
@ -58,6 +59,7 @@ export type SessionContextType = {
) => void
signUp: (params: SignupInput) => Promise<{ data: AuthToken; errors: Error[] }>
signIn: (params: LoginInput) => Promise<{ data: AuthToken; errors: Error[] }>
updateProfile: (params: UpdateProfileInput) => Promise<{ data: AuthToken; errors: Error[] }>
signOut: () => Promise<void>
oauth: (provider: string) => Promise<void>
forgotPassword: (
@ -305,6 +307,8 @@ export const SessionProvider = (props: {
}
const signUp = async (params: SignupInput) => await authenticate(authorizer().signup, params)
const signIn = async (params: LoginInput) => await authenticate(authorizer().login, params)
const updateProfile = async (params: UpdateProfileInput) =>
await authenticate(authorizer().updateProfile, params)
const signOut = async () => {
const authResult: ApiResponse<GenericResponse> = await authorizer().logout()
@ -381,6 +385,7 @@ export const SessionProvider = (props: {
signIn,
signOut,
confirmEmail,
updateProfile,
setIsSessionLoaded,
setSession,
setAuthor,

View File

@ -100,17 +100,6 @@ h5 {
}
}
.passwordToggleControl {
position: absolute;
right: 1em;
transform: translateY(-50%);
top: 50%;
}
.passwordInput {
padding-right: 3em !important;
}
.searchContainer {
margin-top: 2.4rem;
}
@ -331,3 +320,12 @@ div[data-lastpass-infield="true"] {
opacity: 0 !important;
}
.emailValidationError {
position: absolute;
top: 100%;
font-size: 12px;
line-height: 16px;
margin-top: 0.3em;
color: var(--danger-color);
}

View File

@ -6,14 +6,143 @@ import { Icon } from '../../components/_shared/Icon'
import { PageLayout } from '../../components/_shared/PageLayout'
import { useLocalize } from '../../context/localize'
import { UpdateProfileInput } from '@authorizerdev/authorizer-js'
import { Show, createEffect, createSignal, on } from 'solid-js'
import { PasswordField } from '../../components/Nav/AuthModal/PasswordField'
import { Button } from '../../components/_shared/Button'
import { Loading } from '../../components/_shared/Loading'
import { useConfirm } from '../../context/confirm'
import { useSession } from '../../context/session'
import { useSnackbar } from '../../context/snackbar'
import { DEFAULT_HEADER_OFFSET } from '../../stores/router'
import { validateEmail } from '../../utils/validateEmail'
import styles from './Settings.module.scss'
type FormField = 'oldPassword' | 'newPassword' | 'newPasswordConfirm' | 'email'
export const ProfileSecurityPage = () => {
const { t } = useLocalize()
const { updateProfile, session, isSessionLoaded } = useSession()
const { showSnackbar } = useSnackbar()
const { showConfirm } = useConfirm()
const [newPasswordError, setNewPasswordError] = createSignal<string | undefined>()
const [oldPasswordError, setOldPasswordError] = createSignal<string | undefined>()
const [emailError, setEmailError] = createSignal<string | undefined>()
const [isSubmitting, setIsSubmitting] = createSignal<boolean>()
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
const initialState = {
oldPassword: undefined,
newPassword: undefined,
newPasswordConfirm: undefined,
email: undefined,
}
const [formData, setFormData] = createSignal(initialState)
const oldPasswordRef: { current: HTMLDivElement } = { current: null }
const newPasswordRepeatRef: { current: HTMLDivElement } = { current: null }
createEffect(
on(
() => session()?.user?.email,
() => {
setFormData((prevData) => ({
...prevData,
['email']: session()?.user?.email,
}))
},
),
)
const handleInputChange = (name: FormField, value: string) => {
if (
name === 'email' ||
(name === 'newPasswordConfirm' && value && value?.length > 0 && !emailError() && !newPasswordError())
) {
setIsFloatingPanelVisible(true)
} else {
setIsFloatingPanelVisible(false)
}
setFormData((prevData) => ({
...prevData,
[name]: value,
}))
}
const handleCancel = async () => {
const isConfirmed = await showConfirm({
confirmBody: t('Do you really want to reset all changes?'),
confirmButtonVariant: 'primary',
declineButtonVariant: 'secondary',
})
if (isConfirmed) {
setEmailError()
setFormData({
...initialState,
['email']: session()?.user?.email,
})
setIsFloatingPanelVisible(false)
}
}
const handleChangeEmail = (_value: string) => {
if (!validateEmail(formData()['email'])) {
setEmailError(t('Invalid email'))
return
}
}
const handleCheckNewPassword = (value: string) => {
handleInputChange('newPasswordConfirm', value)
if (value !== formData()['newPassword']) {
const rect = newPasswordRepeatRef.current.getBoundingClientRect()
const topPosition = window.scrollY + rect.top - DEFAULT_HEADER_OFFSET * 2
window.scrollTo({
top: topPosition,
left: 0,
behavior: 'smooth',
})
showSnackbar({ type: 'error', body: t('Incorrect new password confirm') })
setNewPasswordError(t('Passwords are not equal'))
}
}
const handleSubmit = async () => {
setIsSubmitting(true)
const options: UpdateProfileInput = {
old_password: formData()['oldPassword'],
new_password: formData()['newPassword'] || formData()['oldPassword'],
confirm_new_password: formData()['newPassword'] || formData()['oldPassword'],
email: formData()['email'],
}
try {
const { errors } = await updateProfile(options)
if (errors.length > 0) {
console.error(errors)
if (errors.some((obj) => obj.message === 'incorrect old password')) {
setOldPasswordError(t('Incorrect old password'))
showSnackbar({ type: 'error', body: t('Incorrect old password') })
const rect = oldPasswordRef.current.getBoundingClientRect()
const topPosition = window.scrollY + rect.top - DEFAULT_HEADER_OFFSET * 2
window.scrollTo({
top: topPosition,
left: 0,
behavior: 'smooth',
})
setIsFloatingPanelVisible(false)
}
return
}
showSnackbar({ type: 'success', body: t('Profile successfully saved') })
} catch (error) {
console.error(error)
} finally {
setIsSubmitting(false)
}
}
return (
<PageLayout title={t('Profile')}>
<AuthGuard>
<Show when={isSessionLoaded()} fallback={<Loading />}>
<div class="wide-container">
<div class="row">
<div class="col-md-5">
@ -25,63 +154,86 @@ export const ProfileSecurityPage = () => {
<div class="col-md-19">
<div class="row">
<div class="col-md-20 col-lg-18 col-xl-16">
<h1>Вход и&nbsp;безопасность</h1>
<p class="description">Настройки аккаунта, почты, пароля и&nbsp;способов входа.</p>
<h1>{t('Login and security')}</h1>
<p class="description">
{t('Settings for account, email, password and login methods.')}
</p>
<form>
<h4>Почта</h4>
<div class="pretty-form__item">
<input type="text" name="email" id="email" placeholder="Почта" />
<label for="email">Почта</label>
</div>
<h4>Изменить пароль</h4>
<h5>Текущий пароль</h5>
<h4>{t('Email')}</h4>
<div class="pretty-form__item">
<input
type="text"
name="password-current"
id="password-current"
class={clsx(styles.passwordInput, 'nolabel')}
name="email"
id="email"
disabled={isSubmitting()}
value={formData()['email'] || ''}
placeholder={t('Email')}
onFocus={() => setEmailError()}
onInput={(event) => handleChangeEmail(event.target.value)}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-hide" />
</button>
<label for="email">{t('Email')}</label>
<Show when={emailError()}>
<div
class={clsx(styles.emailValidationError, {
'form-message--error': emailError(),
})}
>
{emailError()}
</div>
</Show>
</div>
<h5>Новый пароль</h5>
<div class="pretty-form__item">
<input
type="password"
name="password-new"
id="password-new"
class={clsx(styles.passwordInput, 'nolabel')}
<h4>{t('Change password')}</h4>
<h5>{t('Current password')}</h5>
<div ref={(el) => (oldPasswordRef.current = el)}>
<PasswordField
onFocus={() => setOldPasswordError()}
setError={oldPasswordError()}
onInput={(value) => handleInputChange('oldPassword', value)}
value={formData()['oldPassword'] ?? null}
disabled={isSubmitting()}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-open" />
</button>
</div>
<h5>Подтвердите новый пароль</h5>
<div class="pretty-form__item">
<input
type="password"
name="password-new-confirm"
id="password-new-confirm"
class={clsx(styles.passwordInput, 'nolabel')}
<h5>{t('New password')}</h5>
<PasswordField
onInput={(value) => {
handleInputChange('newPassword', value)
handleInputChange('newPasswordConfirm', '')
}}
value={formData()['newPassword'] ?? ''}
disabled={isSubmitting()}
disableAutocomplete={true}
/>
<button type="button" class={styles.passwordToggleControl}>
<Icon name="password-open" />
</button>
</div>
<h4>Социальные сети</h4>
<h5>{t('Confirm your new password')}</h5>
<div ref={(el) => (newPasswordRepeatRef.current = el)}>
<PasswordField
noValidate={true}
value={
formData()['newPasswordConfirm']?.length > 0
? formData()['newPasswordConfirm']
: null
}
onFocus={() => setNewPasswordError()}
setError={newPasswordError()}
onInput={(value) => handleCheckNewPassword(value)}
disabled={isSubmitting()}
disableAutocomplete={true}
/>
</div>
<h4>{t('Social networks')}</h4>
<h5>Google</h5>
<div class="pretty-form__item">
<p>
<button class={clsx('button', 'button--light', styles.socialButton)} type="button">
<button
class={clsx('button', 'button--light', styles.socialButton)}
type="button"
>
<Icon name="google" class={styles.icon} />
Привязать
{t('Connect')}
</button>
</p>
</div>
@ -89,9 +241,12 @@ export const ProfileSecurityPage = () => {
<h5>VK</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button', 'button--light')} type="button">
<button
class={clsx(styles.socialButton, 'button', 'button--light')}
type="button"
>
<Icon name="vk" class={styles.icon} />
Привязать
{t('Connect')}
</button>
</p>
</div>
@ -99,9 +254,12 @@ export const ProfileSecurityPage = () => {
<h5>Facebook</h5>
<div class="pretty-form__item">
<p>
<button class={clsx(styles.socialButton, 'button', 'button--light')} type="button">
<button
class={clsx(styles.socialButton, 'button', 'button--light')}
type="button"
>
<Icon name="facebook" class={styles.icon} />
Привязать
{t('Connect')}
</button>
</p>
</div>
@ -118,23 +276,51 @@ export const ProfileSecurityPage = () => {
type="button"
>
<Icon name="apple" class={styles.icon} />
Привязать
{t('Connect')}
</button>
</p>
</div>
<br />
<p>
<button class="button button--submit" type="submit">
Сохранить настройки
</button>
</p>
</form>
</div>
</div>
</div>
</div>
</div>
</Show>
<Show when={isFloatingPanelVisible() && !emailError() && !newPasswordError()}>
<div class={styles.formActions}>
<div class="wide-container">
<div class="row">
<div class="col-md-19 offset-md-5">
<div class="row">
<div class="col-md-20 col-lg-18 col-xl-16">
<div class={styles.content}>
<Button
class={styles.cancel}
variant="light"
value={
<>
<span class={styles.cancelLabel}>{t('Cancel changes')}</span>
<span class={styles.cancelLabelMobile}>{t('Cancel')}</span>
</>
}
onClick={handleCancel}
/>
<Button
onClick={handleSubmit}
variant="primary"
disabled={isSubmitting()}
value={isSubmitting() ? t('Saving...') : t('Save settings')}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</Show>
</AuthGuard>
</PageLayout>
)