diff --git a/package.json b/package.json index 289d1149..35efc4a1 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "deploy": "graphql-codegen && npm run typecheck && vite build && vercel", "dev": "vite", "e2e": "npx playwright test --project=chromium", - "fix": "npm run lint:code:fix && stylelint **/*.{scss,css} --fix", + "fix": "npm run check:code:fix && stylelint **/*.{scss,css} --fix", "format": "npx @biomejs/biome format src/. --write", "hygen": "HYGEN_TMPLS=gen hygen", "postinstall": "npm run codegen && npx patch-package", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c6d80595..9a0b007a 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -532,5 +532,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" } diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index ee135233..08927e17 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -559,5 +559,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": "Неверное подтверждение нового пароля" } diff --git a/src/components/Editor/SimplifiedEditor.tsx b/src/components/Editor/SimplifiedEditor.tsx index 187fe25f..18e8792c 100644 --- a/src/components/Editor/SimplifiedEditor.tsx +++ b/src/components/Editor/SimplifiedEditor.tsx @@ -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) => { diff --git a/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx b/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx index a478225d..a2ec9185 100644 --- a/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx +++ b/src/components/Nav/AuthModal/PasswordField/PasswordField.tsx @@ -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() - const validatePassword = (passwordToCheck) => { + const validatePassword = (passwordToCheck: string) => { if (passwordToCheck.length < minLength) { return t('Password should be at least 8 characters') } @@ -50,11 +53,13 @@ export const PasswordField = (props: Props) => { } props.onInput(value) - const errorValue = validatePassword(value) - if (errorValue) { - setError(errorValue) - } else { - setError() + if (!props.noValidate) { + const errorValue = validatePassword(value) + if (errorValue) { + setError(errorValue) + } else { + setError() + } } } @@ -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')} diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx index 15532ddb..a74597a5 100644 --- a/src/components/ProfileSettings/ProfileSettings.tsx +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -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({}) 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), () => { - setIsFloatingPanelVisible(!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 = () => {

{t('About')}

{ setFollowing([...(authors || []), ...(topics || [])]) setFollowers(followersResult || []) - console.debug('[components.Author] following data loaded', subscriptionsResult) + console.info('[components.Author] data loaded') } catch (error) { console.error('[components.Author] fetch error', error) } @@ -243,7 +243,7 @@ export const AuthorView = (props: Props) => { class={styles.longBio} classList={{ [styles.longBioExpanded]: isBioExpanded() }} > -
(bioContainerRef.current = el)} innerHTML={author().about} /> +
(bioContainerRef.current = el)} innerHTML={author()?.about || ''} />
diff --git a/src/components/Views/PublishSettings/PublishSettings.tsx b/src/components/Views/PublishSettings/PublishSettings.tsx index de86a02b..44289502 100644 --- a/src/components/Views/PublishSettings/PublishSettings.tsx +++ b/src/components/Views/PublishSettings/PublishSettings.tsx @@ -59,7 +59,7 @@ export const PublishSettings = (props: Props) => { const composeDescription = () => { if (!props.form.description) { - const cleanFootnotes = props.form.body.replaceAll(/.*?<\/footnote>/g, '') + const cleanFootnotes = props.form.body.replaceAll(/(.*?)<\/footnote>/g, '') const leadText = cleanFootnotes.replaceAll(/<\/?[^>]+(>|$)/gi, ' ') return shorten(leadText, DESCRIPTION_MAX_LENGTH).trim() } diff --git a/src/components/_shared/SolidSwiper/Swiper.module.scss b/src/components/_shared/SolidSwiper/Swiper.module.scss index 78d205e2..f66de2fc 100644 --- a/src/components/_shared/SolidSwiper/Swiper.module.scss +++ b/src/components/_shared/SolidSwiper/Swiper.module.scss @@ -8,18 +8,6 @@ } } -.unswiped { - width: 100%; - margin: 2rem 0; - margin-bottom: 6rem; - padding-bottom: 2rem; - display: block; - - h2 { - text-align: center; - } -} - .Swiper { display: block; margin: 2rem 0; diff --git a/src/context/session.tsx b/src/context/session.tsx index 8ebb803a..45b79f79 100644 --- a/src/context/session.tsx +++ b/src/context/session.tsx @@ -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 oauth: (provider: string) => Promise 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 = await authorizer().logout() @@ -381,6 +385,7 @@ export const SessionProvider = (props: { signIn, signOut, confirmEmail, + updateProfile, setIsSessionLoaded, setSession, setAuthor, diff --git a/src/graphql/client/core.ts b/src/graphql/client/core.ts index 9c083e86..bbfa00f4 100644 --- a/src/graphql/client/core.ts +++ b/src/graphql/client/core.ts @@ -1,6 +1,5 @@ import type { Author, - AuthorFollowsResult, CommonResult, FollowingEntity, LoadShoutsOptions, @@ -134,7 +133,7 @@ export const apiClient = { slug?: string author_id?: number user?: string - }): Promise => { + }): Promise => { const response = await publicGraphQLClient.query(authorFollows, params).toPromise() return response.data.get_author_follows }, diff --git a/src/graphql/query/core/author-follows.ts b/src/graphql/query/core/author-follows.ts index 9d46ea2c..31924246 100644 --- a/src/graphql/query/core/author-follows.ts +++ b/src/graphql/query/core/author-follows.ts @@ -3,6 +3,7 @@ import { gql } from '@urql/core' export default gql` query GetAuthorFollows($slug: String, $user: String, $author_id: Int) { get_author_follows(slug: $slug, user: $user, author_id: $author_id) { + error authors { id slug diff --git a/src/pages/profile/Settings.module.scss b/src/pages/profile/Settings.module.scss index 9bd4906c..38babb4d 100644 --- a/src/pages/profile/Settings.module.scss +++ b/src/pages/profile/Settings.module.scss @@ -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); +} diff --git a/src/pages/profile/profileSecurity.page.tsx b/src/pages/profile/profileSecurity.page.tsx index 2e572047..b19f139e 100644 --- a/src/pages/profile/profileSecurity.page.tsx +++ b/src/pages/profile/profileSecurity.page.tsx @@ -6,135 +6,321 @@ 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() + const [oldPasswordError, setOldPasswordError] = createSignal() + const [emailError, setEmailError] = createSignal() + const [isSubmitting, setIsSubmitting] = createSignal() + 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 ( -
-
-
-
- + }> +
+
+
+
+ +
-
-
-
-
-

Вход и безопасность

-

Настройки аккаунта, почты, пароля и способов входа.

- -
-

Почта

-
- - -
- -

Изменить пароль

-
Текущий пароль
-
- - -
- -
Новый пароль
-
- - -
- -
Подтвердите новый пароль
-
- - -
- -

Социальные сети

-
Google
-
-

- -

-
- -
VK
-
-

- -

-
- -
Facebook
-
-

- -

-
- -
Apple
-
-

- -

-
- -
-

- +

+
+
+

{t('Login and security')}

+

+ {t('Settings for account, email, password and login methods.')}

- + +
+

{t('Email')}

+
+ setEmailError()} + onInput={(event) => handleChangeEmail(event.target.value)} + /> + + +
+ {emailError()} +
+
+
+ +

{t('Change password')}

+
{t('Current password')}
+ +
(oldPasswordRef.current = el)}> + setOldPasswordError()} + setError={oldPasswordError()} + onInput={(value) => handleInputChange('oldPassword', value)} + value={formData()['oldPassword'] ?? null} + disabled={isSubmitting()} + /> +
+ +
{t('New password')}
+ { + handleInputChange('newPassword', value) + handleInputChange('newPasswordConfirm', '') + }} + value={formData()['newPassword'] ?? ''} + disabled={isSubmitting()} + disableAutocomplete={true} + /> + +
{t('Confirm your new password')}
+
(newPasswordRepeatRef.current = el)}> + 0 + ? formData()['newPasswordConfirm'] + : null + } + onFocus={() => setNewPasswordError()} + setError={newPasswordError()} + onInput={(value) => handleCheckNewPassword(value)} + disabled={isSubmitting()} + disableAutocomplete={true} + /> +
+

{t('Social networks')}

+
Google
+
+

+ +

+
+ +
VK
+
+

+ +

+
+ +
Facebook
+
+

+ +

+
+ +
Apple
+
+

+ +

+
+ +
-
+ + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)