Feature/change password modal (#344)

* Change Password Modal
This commit is contained in:
Ilya Y 2023-12-21 13:02:28 +03:00 committed by GitHub
parent d113d9ca8a
commit 37c27cc09c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 285 additions and 122 deletions

View File

@ -65,6 +65,7 @@
"Can write and edit text directly, and accept or reject suggestions from others": "Can write and edit text directly, and accept or reject suggestions from others",
"Cancel": "Cancel",
"Cancel changes": "Cancel changes",
"Change password": "Change password",
"Characters": "Знаков",
"Chat Title": "Chat Title",
"Choose a post type": "Choose a post type",
@ -131,6 +132,7 @@
"Email": "Mail",
"Enter": "Enter",
"Enter URL address": "Enter URL address",
"Enter a new password": "Enter a new password",
"Enter footnote text": "Enter footnote text",
"Enter image description": "Enter image description",
"Enter image title": "Enter image title",
@ -253,6 +255,7 @@
"NotificationNewReplyText2": "from",
"NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { and one more user} other { and more {restUsersCount} users}}",
"Notifications": "Notifications",
"Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password": "Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password",
"Or paste a link to an image": "Or paste a link to an image",
"Ordered list": "Ordered list",
"Our regular contributor": "Our regular contributor",
@ -266,6 +269,7 @@
"Password should be at least 8 characters": "Password should be at least 8 characters",
"Password should contain at least one number": "Password should contain at least one number",
"Password should contain at least one special character: !@#$%^&*": "Password should contain at least one special character: !@#$%^&*",
"Password updated!": "Password updated!",
"Passwords are not equal": "Passwords are not equal",
"Paste Embed code": "Paste Embed code",
"Personal": "Personal",
@ -426,6 +430,7 @@
"Write to us": "Write to us",
"Write your colleagues name or email": "Write your colleague's name or email",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "You can download multiple tracks at once in .mp3, .wav or .flac formats",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You were successfully authorized": "You were successfully authorized",
"You ll be able to participate in discussions, rate others' comments and learn about new responses": "You ll be able to participate in discussions, rate others' comments and learn about new responses",
"You've confirmed email": "You've confirmed email",

View File

@ -68,6 +68,7 @@
"Can write and edit text directly, and accept or reject suggestions from others": "Может писать и редактировать текст напрямую, а также принимать или отклонять предложения других",
"Cancel": "Отмена",
"Cancel changes": "Отменить изменения",
"Change password": "Сменить пароль",
"Characters": "Знаков",
"Chat Title": "Тема дискурса",
"Choose a post type": "Выберите тип публикации",
@ -137,6 +138,7 @@
"Email": "Почта",
"Enter": "Войти",
"Enter URL address": "Введите адрес ссылки",
"Enter a new password": "Введите новый пароль",
"Enter footnote text": "Введите текст сноски",
"Enter image description": "Введите описание изображения",
"Enter image title": "Введите название изображения",
@ -265,6 +267,7 @@
"NotificationNewReplyText2": "от",
"NotificationNewReplyText3": "{restUsersCount, plural, =0 {} one { и ещё 1 пользователя} few { и ещё {restUsersCount} пользователей} other { и ещё {restUsersCount} пользователей}}",
"Notifications": "Уведомления",
"Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password": "Теперь можете ввести новый пароль, он должен содержать минимум 8 символов и не совпадать с предыдущим паролем",
"Or paste a link to an image": "Или вставьте ссылку на изображение",
"Ordered list": "Нумерованный список",
"Our regular contributor": "Наш постоянный автор",
@ -278,6 +281,7 @@
"Password should be at least 8 characters": "Пароль должен быть не менее 8 символов",
"Password should contain at least one number": "Пароль должен содержать хотя бы одну цифру",
"Password should contain at least one special character: !@#$%^&*": "Пароль должен содержать хотя бы один спецсимвол: !@#$%^&*",
"Password updated!": "Пароль обновлен!",
"Passwords are not equal": "Пароли не совпадают",
"Paste Embed code": "Вставьте embed код",
"Personal": "Личные",
@ -447,6 +451,7 @@
"Write to us": "Напишите нам",
"Write your colleagues name or email": "Напишите имя или e-mail коллеги",
"You can download multiple tracks at once in .mp3, .wav or .flac formats": "Можно загрузить сразу несколько треков в форматах .mp3, .wav или .flac",
"You can now login using your new password": "Теперь вы можете входить с помощью нового пароля",
"You was successfully authorized": "Вы были успешно авторизованы",
"You ll be able to participate in discussions, rate others' comments and learn about new responses": "Вы сможете участвовать в обсуждениях, оценивать комментарии других и узнавать о новых ответах",
"You've confirmed email": "Вы подтвердили почту",

View File

@ -2,6 +2,7 @@
background: #fff;
min-height: 550px;
position: relative;
justify-content: center;
@include media-breakpoint-up(md) {
min-height: 600px;
@ -106,6 +107,10 @@
margin-top: 1.6rem;
text-align: center;
display: flex;
flex-direction: row;
justify-content: center;
gap: 1rem;
a {
color: #9fa1a7;
@ -125,7 +130,7 @@
.submitButton {
display: block;
font-weight: 700;
margin-top: 32px;
margin-top: 36px;
padding: 1.6rem !important;
width: 100%;
}
@ -183,10 +188,6 @@
line-height: 16px;
margin-top: 0.3em;
&.registerPassword {
margin-bottom: -32px;
}
/* Red/500 */
color: #d00820;
@ -201,26 +202,6 @@
}
}
.passwordToggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
}
.passwordToggleIcon {
height: 1.6em;
display: inline-block;
margin-right: 0.2em;
max-width: 1.4em;
max-height: 1.4em;
transition: filter 0.2s;
vertical-align: middle;
}
.title {
font-size: 26px;
line-height: 32px;

View File

@ -0,0 +1,103 @@
import type { AuthModalSearchParams } from './types'
import { clsx } from 'clsx'
import { createSignal, JSX, Show } from 'solid-js'
import { useLocalize } from '../../../context/localize'
import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { PasswordField } from './PasswordField'
import styles from './AuthModal.module.scss'
type FormFields = {
password: string
}
type ValidationErrors = Partial<Record<keyof FormFields, string | JSX.Element>>
export const ChangePasswordForm = () => {
const { changeSearchParams } = useRouter<AuthModalSearchParams>()
const { t } = useLocalize()
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [newPassword, setNewPassword] = createSignal<string>()
const [passwordError, setPasswordError] = createSignal<string>()
const [isSuccess, setIsSuccess] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
const handleSubmit = async (event: Event) => {
event.preventDefault()
setIsSubmitting(true)
// Fake change password logic
console.log('!!! sent new password:', newPassword)
setTimeout(() => {
setIsSubmitting(false)
setIsSuccess(true)
}, 1000)
}
const handlePasswordInput = (value) => {
setNewPassword(value)
if (passwordError()) {
setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
} else {
setValidationErrors(({ password: _notNeeded, ...rest }) => rest)
}
}
return (
<>
<Show when={!isSuccess()}>
<form
onSubmit={handleSubmit}
class={clsx(styles.authForm, styles.authFormForgetPassword)}
ref={(el) => (authFormRef.current = el)}
>
<div>
<h4>{t('Enter a new password')}</h4>
<div class={styles.authSubtitle}>
{t(
'Now you can enter a new password, it must contain at least 8 characters and not be the same as the previous password',
)}
</div>
<PasswordField
errorMessage={(err) => setPasswordError(err)}
onInput={(value) => handlePasswordInput(value)}
/>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
{isSubmitting() ? '...' : t('Change password')}
</button>
</div>
<div class={styles.authControl}>
<span
class={styles.authLink}
onClick={() =>
changeSearchParams({
mode: 'login',
})
}
>
{t('Cancel')}
</span>
</div>
</div>
</form>
</Show>
<Show when={isSuccess()}>
<div class={styles.title}>{t('Password updated!')}</div>
<div class={styles.text}>{t('You can now login using your new password')}</div>
<div>
<button class={clsx('button', styles.submitButton)} onClick={() => hideModal()}>
{t('Back to main page')}
</button>
</div>
</Show>
</>
)
}

View File

@ -11,9 +11,9 @@ import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { ApiError } from '../../../utils/apiClient'
import { validateEmail } from '../../../utils/validateEmail'
import { Icon } from '../../_shared/Icon'
import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField'
import { email, setEmail } from './sharedLogic'
import { SocialProviders } from './SocialProviders'
@ -35,7 +35,6 @@ export const LoginForm = () => {
// TODO: better solution for interactive error messages
const [isEmailNotConfirmed, setIsEmailNotConfirmed] = createSignal(false)
const [isLinkSent, setIsLinkSent] = createSignal(false)
const [showPassword, setShowPassword] = createSignal(false)
const authFormRef: { current: HTMLFormElement } = { current: null }
@ -166,31 +165,7 @@ export const LoginForm = () => {
</Show>
</div>
<div
class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().password,
})}
>
<input
id="password"
name="password"
autocomplete="password"
type={showPassword() ? 'text' : 'password'}
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
<button
type="button"
class={styles.passwordToggle}
onClick={() => setShowPassword(!showPassword())}
>
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button>
<Show when={validationErrors().password}>
<div class={styles.validationError}>{validationErrors().password}</div>
</Show>
</div>
<PasswordField onInput={(value) => handlePasswordInput(value)} />
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">
@ -208,6 +183,16 @@ export const LoginForm = () => {
>
{t('Forgot password?')}
</span>
<span
class="link"
onClick={() =>
changeSearchParams({
mode: 'change-password',
})
}
>
{t('Change password')}
</span>
</div>
</div>

View File

@ -0,0 +1,46 @@
.PassportField {
.passwordToggle {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
}
.passwordToggleIcon {
height: 1.6em;
display: inline-block;
margin-right: 0.2em;
max-width: 1.4em;
max-height: 1.4em;
transition: filter 0.2s;
vertical-align: middle;
}
.validationError {
position: absolute;
top: 100%;
font-size: 12px;
line-height: 16px;
margin-top: 0.3em;
&.registerPassword {
margin-bottom: -32px;
}
/* Red/500 */
color: #d00820;
a {
color: #d00820;
border-color: #d00820;
&:hover {
color: var(--default-color-invert);
border-color: var(--background-color-invert);
}
}
}
}

View File

@ -0,0 +1,88 @@
import { clsx } from 'clsx'
import { createEffect, createSignal, JSX, on, Show } from 'solid-js'
import { useLocalize } from '../../../../context/localize'
import { resetSortedArticles } from '../../../../stores/zine/articles'
import { Icon } from '../../../_shared/Icon'
import styles from './PasswordField.module.scss'
type Props = {
class?: string
errorMessage?: (error: string) => void
onInput: (value: string) => void
}
export const PasswordField = (props: Props) => {
const { t } = useLocalize()
const [showPassword, setShowPassword] = createSignal(false)
const [error, setError] = createSignal<string>()
const validatePassword = (passwordToCheck) => {
const minLength = 8
const hasNumber = /\d/
const hasSpecial = /[!#$%&*@^]/
if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters')
}
if (!hasNumber.test(passwordToCheck)) {
return t('Password should contain at least one number')
}
if (!hasSpecial.test(passwordToCheck)) {
return t('Password should contain at least one special character: !@#$%^&*')
}
return null
}
const handleInputChange = (value) => {
props.onInput(value)
const errorValue = validatePassword(value)
if (errorValue) {
setError(errorValue)
} else {
setError()
}
}
createEffect(
on(
() => error(),
() => {
props.errorMessage(error())
},
{ defer: true },
),
)
return (
<div class={clsx(styles.PassportField, props.class)}>
<div
class={clsx('pretty-form__item', {
'pretty-form__item--error': error(),
})}
>
<input
id="password"
name="password"
autocomplete="current-password"
type={showPassword() ? 'text' : 'password'}
placeholder={t('Password')}
onInput={(event) => handleInputChange(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
<button
type="button"
class={styles.passwordToggle}
onClick={() => setShowPassword(!showPassword())}
>
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button>
<Show when={error()}>
<div class={clsx(styles.registerPassword, styles.validationError)}>{error()}</div>
</Show>
</div>
</div>
)
}

View File

@ -0,0 +1 @@
export { PasswordField } from './PasswordField'

View File

@ -11,9 +11,9 @@ import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { ApiError } from '../../../utils/apiClient'
import { validateEmail } from '../../../utils/validateEmail'
import { Icon } from '../../_shared/Icon'
import { AuthModalHeader } from './AuthModalHeader'
import { PasswordField } from './PasswordField'
import { email, setEmail } from './sharedLogic'
import { SocialProviders } from './SocialProviders'
@ -40,9 +40,9 @@ export const RegisterForm = () => {
const [fullName, setFullName] = createSignal('')
const [password, setPassword] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const [showPassword, setShowPassword] = createSignal(false)
const [isSuccess, setIsSuccess] = createSignal(false)
const [validationErrors, setValidationErrors] = createSignal<ValidationErrors>({})
const [passwordError, setPasswordError] = createSignal<string>()
const authFormRef: { current: HTMLFormElement } = { current: null }
@ -52,37 +52,15 @@ export const RegisterForm = () => {
}
}
function isValidPassword(passwordToCheck) {
const minLength = 8
const hasNumber = /\d/
const hasSpecial = /[!#$%&*@^]/
if (passwordToCheck.length < minLength) {
return t('Password should be at least 8 characters')
}
if (!hasNumber.test(passwordToCheck)) {
return t('Password should contain at least one number')
}
if (!hasSpecial.test(passwordToCheck)) {
return t('Password should contain at least one special character: !@#$%^&*')
}
return null
}
const handlePasswordInput = (newPassword: string) => {
setPassword(newPassword)
}
const handleNameInput = (newPasswordCopy: string) => {
setFullName(newPasswordCopy)
const handleNameInput = (newName: string) => {
setFullName(newName)
}
const handleSubmit = async (event: Event) => {
event.preventDefault()
const passwordError = isValidPassword(password())
if (passwordError) {
setValidationErrors((errors) => ({ ...errors, password: passwordError }))
if (passwordError()) {
setValidationErrors((errors) => ({ ...errors, password: passwordError() }))
} else {
setValidationErrors(({ password: _notNeeded, ...rest }) => rest)
}
@ -198,48 +176,17 @@ export const RegisterForm = () => {
<Show when={emailChecks()[email()]}>
<div class={styles.validationError}>
{t("This email is already taken. If it's you")},{' '}
<a
href="#"
onClick={(event) => {
event.preventDefault()
changeSearchParams({
mode: 'login',
})
}}
>
<span class="link" onClick={() => changeSearchParams({ mode: 'login' })}>
{t('enter')}
</a>
</span>
</div>
</Show>
</div>
<div
class={clsx('pretty-form__item', {
'pretty-form__item--error': validationErrors().password,
})}
>
<input
id="password"
name="password"
autocomplete="current-password"
type={showPassword() ? 'text' : 'password'}
placeholder={t('Password')}
onInput={(event) => handlePasswordInput(event.currentTarget.value)}
/>
<label for="password">{t('Password')}</label>
<button
type="button"
class={styles.passwordToggle}
onClick={() => setShowPassword(!showPassword())}
>
<Icon class={styles.passwordToggleIcon} name={showPassword() ? 'eye-off' : 'eye'} />
</button>
<Show when={validationErrors().password}>
<div class={clsx(styles.registerPassword, styles.validationError)}>
{validationErrors().password}
</div>
</Show>
</div>
<PasswordField
errorMessage={(err) => setPasswordError(err)}
onInput={(value) => setPassword(value)}
/>
<div>
<button class={clsx('button', styles.submitButton)} disabled={isSubmitting()} type="submit">

View File

@ -9,6 +9,7 @@ import { useRouter } from '../../../stores/router'
import { hideModal } from '../../../stores/ui'
import { isMobile } from '../../../utils/media-query'
import { ChangePasswordForm } from './ChangePasswordForm'
import { EmailConfirm } from './EmailConfirm'
import { ForgotPasswordForm } from './ForgotPasswordForm'
import { LoginForm } from './LoginForm'
@ -21,10 +22,11 @@ const AUTH_MODAL_MODES: Record<AuthModalMode, Component> = {
register: RegisterForm,
'forgot-password': ForgotPasswordForm,
'confirm-email': EmailConfirm,
'change-password': ChangePasswordForm,
}
export const AuthModal = () => {
let rootRef: HTMLDivElement
const rootRef: { current: HTMLDivElement } = { current: null }
const { t } = useLocalize()
const { searchParams } = useRouter<AuthModalSearchParams>()
@ -36,17 +38,17 @@ export const AuthModal = () => {
createEffect((oldMode) => {
if (oldMode !== mode() && !isMobile()) {
rootRef?.querySelector('input')?.focus()
rootRef.current?.querySelector('input')?.focus()
}
}, null)
return (
<div
ref={rootRef}
ref={(el) => (rootRef.current = el)}
class={clsx(styles.view, {
row: !source,
[styles.signUp]: mode() === 'register' || mode() === 'confirm-email',
})}
classList={{ [styles.signUp]: mode() === 'register' || mode() === 'confirm-email' }}
>
<Show when={!source}>
<div class={clsx('col-md-12 d-none d-md-flex', styles.authImage)}>

View File

@ -1,4 +1,4 @@
export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password'
export type AuthModalMode = 'login' | 'register' | 'confirm-email' | 'forgot-password' | 'change-password'
export type AuthModalSource =
| 'discussions'
| 'vote'