From f1117dbad3c19f08a7704b992e79acbaf1f738c9 Mon Sep 17 00:00:00 2001 From: Erik Michelson <github@erik.michelson.eu> Date: Thu, 2 Dec 2021 23:03:03 +0100 Subject: [PATCH] Refactor profile page (#1636) --- cypress/integration/profile.spec.ts | 14 +- locales/en.json | 22 +- public/mock-backend/api/private/tokens | 11 +- src/api/tokens/index.ts | 15 +- src/api/tokens/types.d.ts | 7 +- .../countdown-button/countdown-button.tsx | 30 +++ .../access-token-created-modal.tsx | 54 +++++ ...ccess-token-creation-form-expiry-field.tsx | 48 ++++ .../access-token-creation-form-field.d.ts | 12 + ...access-token-creation-form-label-field.tsx | 50 ++++ ...cess-token-creation-form-submit-button.tsx | 30 +++ .../access-token-creation-form.tsx | 83 +++++++ .../hooks/use-expiry-dates.ts | 37 +++ .../hooks/use-on-create-token.ts | 36 +++ .../access-token-deletion-modal.tsx | 63 +++++ .../access-tokens/access-token-list-entry.tsx | 64 +++++ .../access-tokens/profile-access-tokens.tsx | 218 +++--------------- .../account-deletion-modal.tsx | 58 +++++ .../profile-account-management.tsx | 49 ++++ src/components/profile-page/profile-page.tsx | 6 +- .../settings/profile-account-management.tsx | 97 -------- .../settings/profile-change-password.tsx | 51 ++-- .../settings/profile-display-name.tsx | 49 ++-- 23 files changed, 765 insertions(+), 339 deletions(-) create mode 100644 src/components/common/countdown-button/countdown-button.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-created-modal.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-expiry-field.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-field.d.ts create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-label-field.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-submit-button.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-expiry-dates.ts create mode 100644 src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts create mode 100644 src/components/profile-page/access-tokens/access-token-deletion-modal.tsx create mode 100644 src/components/profile-page/access-tokens/access-token-list-entry.tsx create mode 100644 src/components/profile-page/account-management/account-deletion-modal.tsx create mode 100644 src/components/profile-page/account-management/profile-account-management.tsx delete mode 100644 src/components/profile-page/settings/profile-account-management.tsx diff --git a/cypress/integration/profile.spec.ts b/cypress/integration/profile.spec.ts index 07d922d0e..04be09934 100644 --- a/cypress/integration/profile.spec.ts +++ b/cypress/integration/profile.spec.ts @@ -15,7 +15,10 @@ describe('profile page', () => { body: [ { label: 'cypress-App', - created: 1601991518 + keyId: 'cypress', + createdAt: '2021-11-21T01:11:12+01:00', + lastUsed: '2021-11-21T01:11:12+01:00', + validUntil: '2023-11-21' } ] } @@ -28,14 +31,17 @@ describe('profile page', () => { { body: { label: 'cypress', + keyId: 'cypress2', secret: 'c-y-p-r-e-s-s', - created: Date.now() + createdAt: '2021-11-21T01:11:12+01:00', + lastUsed: '2021-11-21T01:11:12+01:00', + validUntil: '2023-11-21' } } ) cy.intercept( { - url: '/mock-backend/api/private/tokens/1601991518', + url: '/mock-backend/api/private/tokens/cypress', method: 'DELETE' }, { @@ -59,7 +65,7 @@ describe('profile page', () => { it('add token', () => { cy.getById('access-token-add-button').should('be.disabled') - cy.getById('access-token-add-input').type('cypress') + cy.getById('access-token-add-input-label').type('cypress') cy.getById('access-token-modal-add').should('not.exist') cy.getById('access-token-add-button').should('not.be.disabled').click() cy.getById('access-token-modal-add') diff --git a/locales/en.json b/locales/en.json index 1cf08daa7..b17e954de 100644 --- a/locales/en.json +++ b/locales/en.json @@ -158,8 +158,10 @@ "old": "Old password", "new": "New password", "newAgain": "New password again", - "info": "Your new password should contain at least 6 characters." + "info": "Your new password should contain at least 6 characters.", + "failed": "Changing your password failed. Check your old password and try again." }, + "changeDisplayNameFailed": "There was an error changing your display name.", "accountManagement": "Account management", "deleteUser": "Delete user", "exportUserData": "Export user data", @@ -170,21 +172,31 @@ "noTokens": "You don't have any tokens generated yet.", "createToken": "Create token", "label": "Token label", - "created": "created {{time}}" + "created": "created {{time}}", + "lastUsed": "last used {{time}}", + "loadingFailed": "Fetching your access tokens has failed. Try reloading this page.", + "creationFailed": "Creating the access token failed.", + "expiry": "Expiry date" }, "modal": { "deleteUser": { "title": "Delete user", "message": "Do you really want to delete your user account?", - "subMessage": "This will delete your account, all notes that are owned by you and remove all references to your account from other notes." + "subMessage": "This will delete your account, all notes that are owned by you and remove all references to your account from other notes.", + "failed": "There was an error deleting your account. Please try it again or contact your instance's administrator.", + "notificationTitle": "Account deleted", + "notificationText": "Your account has been successfully deleted." }, "addedAccessToken": { "title": "Token added", - "message": "An access token was created. Copy it from the field below now, as you won't be able to view it again." + "message": "The access token '{{label}}' was created. Copy it from the field below now, as you won't be able to view it again." }, "deleteAccessToken": { "title": "Really delete token?", - "message": "When deleting an access token, the applications that used it won't have access to your account anymore. You eventually need a new token to continue using those applications." + "message": "When deleting an access token, the applications that used it won't have access to your account anymore. You eventually need a new token to continue using those applications.", + "notificationTitle": "Access token deleted", + "notificationText": "The access token '{{label}}' has been deleted.", + "failed": "There was an error deleting the access token. Please try it again or contact your instance's administrator." } } }, diff --git a/public/mock-backend/api/private/tokens b/public/mock-backend/api/private/tokens index ca4cb3bf0..0183bc195 100644 --- a/public/mock-backend/api/private/tokens +++ b/public/mock-backend/api/private/tokens @@ -1,10 +1,17 @@ [ { "label": "Demo-App", - "created": 1601991518 + "keyId": "demo", + "createdAt": "2021-11-20T23:54:13+01:00", + "lastUsed": "2021-11-20T23:54:13+01:00", + "validUntil": "2022-11-20" + }, { "label": "CLI @ Test-PC", - "created": 1601912159 + "keyId": "cli", + "createdAt": "2021-11-20T23:54:13+01:00", + "lastUsed": "2021-11-20T23:54:13+01:00", + "validUntil": "2021-11-20" } ] diff --git a/src/api/tokens/index.ts b/src/api/tokens/index.ts index da097cb83..59772bd1e 100644 --- a/src/api/tokens/index.ts +++ b/src/api/tokens/index.ts @@ -5,7 +5,7 @@ */ import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' -import type { AccessToken, AccessTokenSecret } from './types' +import type { AccessToken, AccessTokenWithSecret } from './types' export const getAccessTokenList = async (): Promise<AccessToken[]> => { const response = await fetch(`${getApiUrl()}tokens`, { @@ -15,18 +15,21 @@ export const getAccessTokenList = async (): Promise<AccessToken[]> => { return (await response.json()) as AccessToken[] } -export const postNewAccessToken = async (label: string): Promise<AccessToken & AccessTokenSecret> => { +export const postNewAccessToken = async (label: string, expiryDate: string): Promise<AccessTokenWithSecret> => { const response = await fetch(`${getApiUrl()}tokens`, { ...defaultFetchConfig, method: 'POST', - body: label + body: JSON.stringify({ + label: label, + validUntil: expiryDate + }) }) expectResponseCode(response) - return (await response.json()) as AccessToken & AccessTokenSecret + return (await response.json()) as AccessTokenWithSecret } -export const deleteAccessToken = async (timestamp: number): Promise<void> => { - const response = await fetch(`${getApiUrl()}tokens/${timestamp}`, { +export const deleteAccessToken = async (keyId: string): Promise<void> => { + const response = await fetch(`${getApiUrl()}tokens/${keyId}`, { ...defaultFetchConfig, method: 'DELETE' }) diff --git a/src/api/tokens/types.d.ts b/src/api/tokens/types.d.ts index b4ea98896..6944eac7b 100644 --- a/src/api/tokens/types.d.ts +++ b/src/api/tokens/types.d.ts @@ -6,9 +6,12 @@ export interface AccessToken { label: string - created: number + validUntil: string + keyId: string + createdAt: string + lastUsed: string } -export interface AccessTokenSecret { +export interface AccessTokenWithSecret extends AccessToken { secret: string } diff --git a/src/components/common/countdown-button/countdown-button.tsx b/src/components/common/countdown-button/countdown-button.tsx new file mode 100644 index 000000000..711d2d983 --- /dev/null +++ b/src/components/common/countdown-button/countdown-button.tsx @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useState } from 'react' +import type { ButtonProps } from 'react-bootstrap' +import { Button } from 'react-bootstrap' +import { useInterval } from 'react-use' + +export interface CountdownButtonProps extends ButtonProps { + countdownStartSeconds: number +} + +/** + * Button that starts a countdown on render and is only clickable after the countdown has finished. + * @param countdownStartSeconds The initial amount of seconds for the countdown. + */ +export const CountdownButton: React.FC<CountdownButtonProps> = ({ countdownStartSeconds, children, ...props }) => { + const [secondsRemaining, setSecondsRemaining] = useState(countdownStartSeconds) + + useInterval(() => setSecondsRemaining((previous) => previous - 1), secondsRemaining <= 0 ? null : 1000) + + return ( + <Button disabled={secondsRemaining > 0} {...props}> + {secondsRemaining > 0 ? secondsRemaining : children} + </Button> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-created-modal.tsx b/src/components/profile-page/access-tokens/access-token-created-modal.tsx new file mode 100644 index 000000000..6e32960b5 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-created-modal.tsx @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import { cypressId } from '../../../utils/cypress-attribute' +import { Button, Modal } from 'react-bootstrap' +import { Trans } from 'react-i18next' +import { CopyableField } from '../../common/copyable/copyable-field/copyable-field' +import type { ModalVisibilityProps } from '../../common/modals/common-modal' +import { CommonModal } from '../../common/modals/common-modal' +import type { AccessTokenWithSecret } from '../../../api/tokens/types' + +export interface AccessTokenCreatedModalProps extends ModalVisibilityProps { + tokenWithSecret?: AccessTokenWithSecret +} + +/** + * Modal that shows the secret of a freshly created access token. + * @param show True when the modal should be shown, false otherwise. + * @param onHide Callback that gets called when the modal should be dismissed. + * @param tokenWithSecret The token altogether with its secret. + */ +export const AccessTokenCreatedModal: React.FC<AccessTokenCreatedModalProps> = ({ show, onHide, tokenWithSecret }) => { + if (!tokenWithSecret) { + return null + } + + return ( + <CommonModal + show={show} + onHide={onHide} + title='profile.modal.addedAccessToken.title' + {...cypressId('access-token-modal-add')}> + <Modal.Body> + <Trans + i18nKey='profile.modal.addedAccessToken.message' + values={{ + label: tokenWithSecret.label + }} + /> + <br /> + <CopyableField content={tokenWithSecret.secret} /> + </Modal.Body> + <Modal.Footer> + <Button variant='primary' onClick={onHide}> + <Trans i18nKey='common.close' /> + </Button> + </Modal.Footer> + </CommonModal> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-expiry-field.tsx b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-expiry-field.tsx new file mode 100644 index 000000000..92e401d97 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-expiry-field.tsx @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ChangeEvent } from 'react' +import React from 'react' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { cypressId } from '../../../../utils/cypress-attribute' +import { useExpiryDates } from './hooks/use-expiry-dates' + +interface AccessTokenCreationFormExpiryFieldProps extends AccessTokenCreationFormFieldProps { + onChangeExpiry: (event: ChangeEvent<HTMLInputElement>) => void +} + +/** + * Input field for expiry of a new token. + * @param formValues The values of the stored form values. + * @param onChangeExpiry Callback that updates the stored expiry form value. + */ +export const AccessTokenCreationFormExpiryField: React.FC<AccessTokenCreationFormExpiryFieldProps> = ({ + onChangeExpiry, + formValues +}) => { + useTranslation() + const minMaxDefaultDates = useExpiryDates() + + return ( + <Form.Group> + <Form.Label> + <Trans i18nKey={'profile.accessTokens.expiry'} /> + </Form.Label> + <Form.Control + type='date' + size='sm' + value={formValues.expiryDate} + className='bg-dark text-light' + onChange={onChangeExpiry} + min={minMaxDefaultDates.min} + max={minMaxDefaultDates.max} + required + {...cypressId('access-token-add-input-expiry')} + /> + </Form.Group> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-field.d.ts b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-field.d.ts new file mode 100644 index 000000000..a558435f7 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-field.d.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +interface AccessTokenCreationFormFieldProps { + formValues: { + expiryDate: string + label: string + } +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-label-field.tsx b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-label-field.tsx new file mode 100644 index 000000000..1050570b4 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-label-field.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ChangeEvent } from 'react' +import React, { useMemo } from 'react' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { cypressId } from '../../../../utils/cypress-attribute' + +interface AccessTokenCreationFormLabelFieldProps extends AccessTokenCreationFormFieldProps { + onChangeLabel: (event: ChangeEvent<HTMLInputElement>) => void +} + +/** + * Input field for the label of a new token. + * @param onChangeLabel Callback for updating the stored label form value. + * @param formValues The stored form values. + */ +export const AccessTokenCreationFormLabelField: React.FC<AccessTokenCreationFormLabelFieldProps> = ({ + onChangeLabel, + formValues +}) => { + const { t } = useTranslation() + + const labelValid = useMemo(() => { + return formValues.label.trim() !== '' + }, [formValues]) + + return ( + <Form.Group> + <Form.Label> + <Trans i18nKey={'profile.accessTokens.label'} /> + </Form.Label> + <Form.Control + type='text' + size='sm' + placeholder={t('profile.accessTokens.label')} + value={formValues.label} + className='bg-dark text-light' + onChange={onChangeLabel} + isValid={labelValid} + required + {...cypressId('access-token-add-input-label')} + /> + </Form.Group> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-submit-button.tsx b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-submit-button.tsx new file mode 100644 index 000000000..3b72e6608 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form-submit-button.tsx @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import { cypressId } from '../../../../utils/cypress-attribute' +import { Trans } from 'react-i18next' +import { Button } from 'react-bootstrap' + +/** + * Submit button for creating a new access token. + */ +export const AccessTokenCreationFormSubmitButton: React.FC<AccessTokenCreationFormFieldProps> = ({ formValues }) => { + const validFormValues = useMemo(() => { + return formValues.label.trim() !== '' && formValues.expiryDate.trim() !== '' + }, [formValues]) + + return ( + <Button + type='submit' + variant='primary' + size='sm' + disabled={!validFormValues} + {...cypressId('access-token-add-button')}> + <Trans i18nKey='profile.accessTokens.createToken' /> + </Button> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form.tsx b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form.tsx new file mode 100644 index 000000000..73c83a5ae --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/access-token-creation-form.tsx @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ChangeEvent } from 'react' +import React, { Fragment, useCallback, useMemo, useState } from 'react' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { AccessTokenCreatedModal } from '../access-token-created-modal' +import type { AccessTokenWithSecret } from '../../../../api/tokens/types' +import { AccessTokenCreationFormLabelField } from './access-token-creation-form-label-field' +import { AccessTokenCreationFormExpiryField } from './access-token-creation-form-expiry-field' +import { AccessTokenCreationFormSubmitButton } from './access-token-creation-form-submit-button' +import { useExpiryDates } from './hooks/use-expiry-dates' +import { useOnCreateToken } from './hooks/use-on-create-token' + +interface NewTokenFormValues { + label: string + expiryDate: string +} + +/** + * Form for creating a new access token. + */ +export const AccessTokenCreationForm: React.FC = () => { + useTranslation() + const expiryDates = useExpiryDates() + + const formValuesInitialState = useMemo(() => { + return { + expiryDate: expiryDates.default, + label: '' + } + }, [expiryDates]) + + const [formValues, setFormValues] = useState<NewTokenFormValues>(() => formValuesInitialState) + const [newTokenWithSecret, setNewTokenWithSecret] = useState<AccessTokenWithSecret>() + + const onHideCreatedModal = useCallback(() => { + setFormValues(formValuesInitialState) + setNewTokenWithSecret(undefined) + }, [formValuesInitialState]) + + const onCreateToken = useOnCreateToken(formValues.label, formValues.expiryDate, setNewTokenWithSecret) + + const onChangeExpiry = useCallback((event: ChangeEvent<HTMLInputElement>) => { + setFormValues((previousValues) => { + return { + ...previousValues, + expiryDate: event.target.value + } + }) + }, []) + + const onChangeLabel = useCallback((event: ChangeEvent<HTMLInputElement>) => { + setFormValues((previousValues) => { + return { + ...previousValues, + label: event.target.value + } + }) + }, []) + + return ( + <Fragment> + <h5> + <Trans i18nKey={'profile.accessTokens.createToken'} /> + </h5> + <Form onSubmit={onCreateToken} className='text-start'> + <AccessTokenCreationFormLabelField onChangeLabel={onChangeLabel} formValues={formValues} /> + <AccessTokenCreationFormExpiryField onChangeExpiry={onChangeExpiry} formValues={formValues} /> + <AccessTokenCreationFormSubmitButton formValues={formValues} /> + </Form> + <AccessTokenCreatedModal + tokenWithSecret={newTokenWithSecret} + show={!!newTokenWithSecret} + onHide={onHideCreatedModal} + /> + </Fragment> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-expiry-dates.ts b/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-expiry-dates.ts new file mode 100644 index 000000000..72387dd6e --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-expiry-dates.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useMemo } from 'react' +import { DateTime } from 'luxon' + +interface ExpiryDates { + default: string + min: string + max: string +} + +/** + * Returns the minimal, maximal and default expiry date for new access tokens. + * @return Memoized expiry dates. + */ +export const useExpiryDates = (): ExpiryDates => { + return useMemo(() => { + const today = DateTime.now() + return { + min: today.toISODate(), + max: today + .plus({ + year: 2 + }) + .toISODate(), + default: today + .plus({ + year: 1 + }) + .toISODate() + } + }, []) +} diff --git a/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts b/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts new file mode 100644 index 000000000..8a1d60318 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-creation-form/hooks/use-on-create-token.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { FormEvent } from 'react' +import { useCallback } from 'react' +import { postNewAccessToken } from '../../../../../api/tokens' +import { showErrorNotification } from '../../../../../redux/ui-notifications/methods' +import type { AccessTokenWithSecret } from '../../../../../api/tokens/types' + +/** + * Callback for requesting a new access token from the API and returning the response token and secret. + * @param label The label for the new access token. + * @param expiryDate The expiry date of the new access token. + * @param setNewTokenWithSecret Callback to set the new access token with the secret from the API. + * @return Callback that can be called when the new access token should be requested. + */ +export const useOnCreateToken = ( + label: string, + expiryDate: string, + setNewTokenWithSecret: (token: AccessTokenWithSecret) => void +): ((event: FormEvent) => void) => { + return useCallback( + (event: FormEvent) => { + event.preventDefault() + postNewAccessToken(label, expiryDate) + .then((tokenWithSecret) => { + setNewTokenWithSecret(tokenWithSecret) + }) + .catch(showErrorNotification('profile.accessTokens.creationFailed')) + }, + [expiryDate, label, setNewTokenWithSecret] + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx b/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx new file mode 100644 index 000000000..b317bc4ec --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-deletion-modal.tsx @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import type { ModalVisibilityProps } from '../../common/modals/common-modal' +import { CommonModal } from '../../common/modals/common-modal' +import { cypressId } from '../../../utils/cypress-attribute' +import { Button, Modal } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import type { AccessToken } from '../../../api/tokens/types' +import { deleteAccessToken } from '../../../api/tokens' +import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods' + +export interface AccessTokenDeletionModalProps extends ModalVisibilityProps { + token: AccessToken +} + +/** + * Modal that asks for confirmation when deleting an access token. + * @param show True when the deletion modal should be shown, false otherwise. + * @param token The access token to delete. + * @param onHide Callback that is fired when the modal is closed. + */ +export const AccessTokenDeletionModal: React.FC<AccessTokenDeletionModalProps> = ({ show, token, onHide }) => { + useTranslation() + + const onConfirmDelete = useCallback(() => { + deleteAccessToken(token.keyId) + .then(() => { + return dispatchUiNotification( + 'profile.modal.deleteAccessToken.notificationTitle', + 'profile.modal.deleteAccessToken.notificationText', + {} + ) + }) + .catch(showErrorNotification('profile.modal.deleteAccessToken.failed')) + .finally(() => { + if (onHide) { + onHide() + } + }) + }, [token, onHide]) + + return ( + <CommonModal + show={show} + onHide={onHide} + title={'profile.modal.deleteAccessToken.title'} + {...cypressId('access-token-modal-delete')}> + <Modal.Body> + <Trans i18nKey='profile.modal.deleteAccessToken.message' /> + </Modal.Body> + <Modal.Footer> + <Button variant='danger' onClick={onConfirmDelete}> + <Trans i18nKey={'common.delete'} /> + </Button> + </Modal.Footer> + </CommonModal> + ) +} diff --git a/src/components/profile-page/access-tokens/access-token-list-entry.tsx b/src/components/profile-page/access-tokens/access-token-list-entry.tsx new file mode 100644 index 000000000..c0dd62629 --- /dev/null +++ b/src/components/profile-page/access-tokens/access-token-list-entry.tsx @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback, useState } from 'react' +import { Col, ListGroup, Row } from 'react-bootstrap' +import { cypressId } from '../../../utils/cypress-attribute' +import { Trans, useTranslation } from 'react-i18next' +import { DateTime } from 'luxon' +import { IconButton } from '../../common/icon-button/icon-button' +import type { AccessToken } from '../../../api/tokens/types' +import { AccessTokenDeletionModal } from './access-token-deletion-modal' + +export interface AccessTokenListEntryProps { + token: AccessToken +} + +/** + * List entry that represents an access token with the possibility to delete it. + * @param token The access token. + */ +export const AccessTokenListEntry: React.FC<AccessTokenListEntryProps> = ({ token }) => { + useTranslation() + const [showDeletionModal, setShowDeletionModal] = useState(false) + + const onShowDeletionModal = useCallback(() => { + setShowDeletionModal(true) + }, []) + + const onHideDeletionModal = useCallback(() => { + setShowDeletionModal(false) + }, []) + + return ( + <ListGroup.Item className='bg-dark'> + <Row> + <Col className='text-start' {...cypressId('access-token-label')}> + {token.label} + </Col> + <Col className='text-start text-white-50'> + <Trans + i18nKey='profile.accessTokens.lastUsed' + values={{ + time: DateTime.fromISO(token.lastUsed).toRelative({ + style: 'short' + }) + }} + /> + </Col> + <Col xs='auto'> + <IconButton + icon='trash-o' + variant='danger' + onClick={onShowDeletionModal} + {...cypressId('access-token-delete-button')} + /> + </Col> + </Row> + <AccessTokenDeletionModal token={token} show={showDeletionModal} onHide={onHideDeletionModal} /> + </ListGroup.Item> + ) +} diff --git a/src/components/profile-page/access-tokens/profile-access-tokens.tsx b/src/components/profile-page/access-tokens/profile-access-tokens.tsx index 4f17fb016..41677a263 100644 --- a/src/components/profile-page/access-tokens/profile-access-tokens.tsx +++ b/src/components/profile-page/access-tokens/profile-access-tokens.tsx @@ -3,199 +3,57 @@ SPDX-License-Identifier: AGPL-3.0-only */ - -import { DateTime } from 'luxon' -import type { ChangeEvent, FormEvent } from 'react' -import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react' -import { Button, Card, Col, Form, ListGroup, Modal, Row } from 'react-bootstrap' +import React, { useEffect, useState } from 'react' +import { Card, ListGroup } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' -import { deleteAccessToken, getAccessTokenList, postNewAccessToken } from '../../../api/tokens' +import { getAccessTokenList } from '../../../api/tokens' import type { AccessToken } from '../../../api/tokens/types' -import { CopyableField } from '../../common/copyable/copyable-field/copyable-field' -import { IconButton } from '../../common/icon-button/icon-button' -import { CommonModal } from '../../common/modals/common-modal' import { ShowIf } from '../../common/show-if/show-if' -import { Logger } from '../../../utils/logger' -import { cypressId } from '../../../utils/cypress-attribute' - -const log = new Logger('ProfileAccessTokens') +import { AccessTokenListEntry } from './access-token-list-entry' +import { AccessTokenCreationForm } from './access-token-creation-form/access-token-creation-form' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' +/** + * Profile page section that shows the user's access tokens and allows to manage them. + */ export const ProfileAccessTokens: React.FC = () => { - const { t } = useTranslation() - - const [error, setError] = useState(false) - const [showAddedModal, setShowAddedModal] = useState(false) - const [showDeleteModal, setShowDeleteModal] = useState(false) + useTranslation() const [accessTokens, setAccessTokens] = useState<AccessToken[]>([]) - const [newTokenLabel, setNewTokenLabel] = useState('') - const [newTokenSecret, setNewTokenSecret] = useState('') - const [selectedForDeletion, setSelectedForDeletion] = useState(0) - - const addToken = useCallback( - (event: FormEvent) => { - event.preventDefault() - postNewAccessToken(newTokenLabel) - .then((token) => { - setNewTokenSecret(token.secret) - setShowAddedModal(true) - setNewTokenLabel('') - }) - .catch((error: Error) => { - log.error(error) - setError(true) - }) - }, - [newTokenLabel] - ) - - const deleteToken = useCallback(() => { - deleteAccessToken(selectedForDeletion) - .then(() => { - setSelectedForDeletion(0) - }) - .catch((error: Error) => { - log.error(error) - setError(true) - }) - .finally(() => { - setShowDeleteModal(false) - }) - }, [selectedForDeletion, setError]) - - const selectForDeletion = useCallback((timestamp: number) => { - setSelectedForDeletion(timestamp) - setShowDeleteModal(true) - }, []) - - const newTokenSubmittable = useMemo(() => { - return newTokenLabel.trim().length > 0 - }, [newTokenLabel]) useEffect(() => { getAccessTokenList() .then((tokens) => { - setError(false) setAccessTokens(tokens) }) - .catch((err) => { - log.error(err) - setError(true) - }) - }, [showAddedModal]) + .catch(showErrorNotification('profile.accessTokens.loadingFailed')) + }, []) return ( - <Fragment> - <Card className='bg-dark mb-4 access-tokens'> - <Card.Body> - <Card.Title> - <Trans i18nKey='profile.accessTokens.title' /> - </Card.Title> - <p className='text-start'> - <Trans i18nKey='profile.accessTokens.info' /> - </p> - <p className='text-start small'> - <Trans i18nKey='profile.accessTokens.infoDev' /> - </p> - <hr /> - <ShowIf condition={accessTokens.length === 0 && !error}> - <Trans i18nKey='profile.accessTokens.noTokens' /> - </ShowIf> - <ShowIf condition={error}> - <Trans i18nKey='common.errorOccurred' /> - </ShowIf> - <ListGroup> - {accessTokens.map((token) => { - return ( - <ListGroup.Item className='bg-dark' key={token.created}> - <Row> - <Col className='text-start' {...cypressId('access-token-label')}> - {token.label} - </Col> - <Col className='text-start text-white-50'> - <Trans - i18nKey='profile.accessTokens.created' - values={{ - time: DateTime.fromSeconds(token.created).toRelative({ - style: 'short' - }) - }} - /> - </Col> - <Col xs='auto'> - <IconButton - icon='trash-o' - variant='danger' - onClick={() => selectForDeletion(token.created)} - {...cypressId('access-token-delete-button')} - /> - </Col> - </Row> - </ListGroup.Item> - ) - })} - </ListGroup> - <hr /> - <Form onSubmit={addToken} className='text-left'> - <Form.Row> - <Col> - <Form.Control - type='text' - size='sm' - placeholder={t('profile.accessTokens.label')} - value={newTokenLabel} - className='bg-dark text-light' - onChange={(event: ChangeEvent<HTMLInputElement>) => setNewTokenLabel(event.target.value)} - isValid={newTokenSubmittable} - required - {...cypressId('access-token-add-input')} - /> - </Col> - <Col xs={'auto'}> - <Button - type='submit' - variant='primary' - size='sm' - disabled={!newTokenSubmittable} - {...cypressId('access-token-add-button')}> - <Trans i18nKey='profile.accessTokens.createToken' /> - </Button> - </Col> - </Form.Row> - </Form> - </Card.Body> - </Card> - - <CommonModal - show={showAddedModal} - onHide={() => setShowAddedModal(false)} - title='profile.modal.addedAccessToken.title' - {...cypressId('access-token-modal-add')}> - <Modal.Body> - <Trans i18nKey='profile.modal.addedAccessToken.message' /> - <br /> - <CopyableField content={newTokenSecret} /> - </Modal.Body> - <Modal.Footer> - <Button variant='primary' onClick={() => setShowAddedModal(false)}> - <Trans i18nKey='common.close' /> - </Button> - </Modal.Footer> - </CommonModal> - - <CommonModal - show={showDeleteModal} - onHide={() => setShowDeleteModal(false)} - title={'profile.modal.deleteAccessToken.title'} - {...cypressId('access-token-modal-delete')}> - <Modal.Body> - <Trans i18nKey='profile.modal.deleteAccessToken.message' /> - </Modal.Body> - <Modal.Footer> - <Button variant='danger' onClick={deleteToken}> - <Trans i18nKey={'common.delete'} /> - </Button> - </Modal.Footer> - </CommonModal> - </Fragment> + <Card className='bg-dark mb-4 access-tokens'> + <Card.Body> + <Card.Title> + <Trans i18nKey='profile.accessTokens.title' /> + </Card.Title> + <p className='text-start'> + <Trans i18nKey='profile.accessTokens.info' /> + </p> + <p className='text-start small'> + <Trans i18nKey='profile.accessTokens.infoDev' /> + </p> + <hr /> + <ShowIf condition={accessTokens.length === 0}> + <Trans i18nKey='profile.accessTokens.noTokens' /> + </ShowIf> + <ListGroup> + {accessTokens.map((token) => ( + <AccessTokenListEntry token={token} key={token.keyId} /> + ))} + </ListGroup> + <hr /> + <ShowIf condition={accessTokens.length < 200}> + <AccessTokenCreationForm /> + </ShowIf> + </Card.Body> + </Card> ) } diff --git a/src/components/profile-page/account-management/account-deletion-modal.tsx b/src/components/profile-page/account-management/account-deletion-modal.tsx new file mode 100644 index 000000000..c99f760d9 --- /dev/null +++ b/src/components/profile-page/account-management/account-deletion-modal.tsx @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import type { ModalVisibilityProps } from '../../common/modals/common-modal' +import { CommonModal } from '../../common/modals/common-modal' +import { Trans, useTranslation } from 'react-i18next' +import { Button, Modal } from 'react-bootstrap' +import { CountdownButton } from '../../common/countdown-button/countdown-button' +import { deleteUser } from '../../../api/me' +import { clearUser } from '../../../redux/user/methods' +import { dispatchUiNotification, showErrorNotification } from '../../../redux/ui-notifications/methods' + +/** + * Confirmation modal for deleting your account. + * @param show True if the modal should be shown, false otherwise. + * @param onHide Callback that is fired when the modal is closed. + */ +export const AccountDeletionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => { + useTranslation() + + const deleteUserAccount = useCallback(() => { + deleteUser() + .then(() => { + clearUser() + return dispatchUiNotification( + 'profile.modal.deleteUser.notificationTitle', + 'profile.modal.deleteUser.notificationText', + {} + ) + }) + .catch(showErrorNotification('profile.modal.deleteUser.failed')) + .finally(() => { + if (onHide) { + onHide() + } + }) + }, [onHide]) + + return ( + <CommonModal show={show} title={'profile.modal.deleteUser.message'} onHide={onHide} showCloseButton={true}> + <Modal.Body> + <Trans i18nKey='profile.modal.deleteUser.subMessage' /> + </Modal.Body> + <Modal.Footer> + <Button variant='secondary' onClick={onHide}> + <Trans i18nKey='common.close' /> + </Button> + <CountdownButton variant='danger' onClick={deleteUserAccount} countdownStartSeconds={10}> + <Trans i18nKey={'profile.modal.deleteUser.title'} /> + </CountdownButton> + </Modal.Footer> + </CommonModal> + ) +} diff --git a/src/components/profile-page/account-management/profile-account-management.tsx b/src/components/profile-page/account-management/profile-account-management.tsx new file mode 100644 index 000000000..44a5149ad --- /dev/null +++ b/src/components/profile-page/account-management/profile-account-management.tsx @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { Fragment, useCallback, useState } from 'react' +import { Button, Card } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' +import { getApiUrl } from '../../../api/utils' +import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' +import { AccountDeletionModal } from './account-deletion-modal' + +/** + * Profile page section that allows to export all data from the account or to delete the account. + */ +export const ProfileAccountManagement: React.FC = () => { + useTranslation() + const [showDeleteModal, setShowDeleteModal] = useState(false) + + const onShowDeletionModal = useCallback(() => { + setShowDeleteModal(true) + }, []) + + const onHideDeletionModal = useCallback(() => { + setShowDeleteModal(false) + }, []) + + return ( + <Fragment> + <Card className='bg-dark mb-4'> + <Card.Body> + <Card.Title> + <Trans i18nKey='profile.accountManagement' /> + </Card.Title> + <Button variant='secondary' block href={getApiUrl() + 'me/export'} className='mb-2'> + <ForkAwesomeIcon icon='cloud-download' fixedWidth={true} className='mx-2' /> + <Trans i18nKey='profile.exportUserData' /> + </Button> + <Button variant='danger' block onClick={onShowDeletionModal}> + <ForkAwesomeIcon icon='trash' fixedWidth={true} className='mx-2' /> + <Trans i18nKey='profile.deleteUser' /> + </Button> + </Card.Body> + </Card> + <AccountDeletionModal show={showDeleteModal} onHide={onHideDeletionModal} /> + </Fragment> + ) +} diff --git a/src/components/profile-page/profile-page.tsx b/src/components/profile-page/profile-page.tsx index 96da2c85d..2d13bfadc 100644 --- a/src/components/profile-page/profile-page.tsx +++ b/src/components/profile-page/profile-page.tsx @@ -11,10 +11,14 @@ import { useApplicationState } from '../../hooks/common/use-application-state' import { LoginProvider } from '../../redux/user/types' import { ShowIf } from '../common/show-if/show-if' import { ProfileAccessTokens } from './access-tokens/profile-access-tokens' -import { ProfileAccountManagement } from './settings/profile-account-management' +import { ProfileAccountManagement } from './account-management/profile-account-management' import { ProfileChangePassword } from './settings/profile-change-password' import { ProfileDisplayName } from './settings/profile-display-name' +/** + * Profile page that includes forms for changing display name, password (if internal login is used), + * managing access tokens and deleting the account. + */ export const ProfilePage: React.FC = () => { const userProvider = useApplicationState((state) => state.user?.provider) diff --git a/src/components/profile-page/settings/profile-account-management.tsx b/src/components/profile-page/settings/profile-account-management.tsx deleted file mode 100644 index 9fdf7c18b..000000000 --- a/src/components/profile-page/settings/profile-account-management.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* - SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) - - SPDX-License-Identifier: AGPL-3.0-only - */ - -import React, { Fragment, useEffect, useRef, useState } from 'react' -import { Button, Card, Modal } from 'react-bootstrap' -import { Trans, useTranslation } from 'react-i18next' -import { deleteUser } from '../../../api/me' -import { getApiUrl } from '../../../api/utils' -import { clearUser } from '../../../redux/user/methods' -import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' - -export const ProfileAccountManagement: React.FC = () => { - useTranslation() - const [showDeleteModal, setShowDeleteModal] = useState(false) - const [deletionButtonActive, setDeletionButtonActive] = useState(false) - const [countdown, setCountdown] = useState(0) - const interval = useRef<NodeJS.Timeout>() - - const stopCountdown = (): void => { - if (interval.current) { - clearTimeout(interval.current) - } - } - - const startCountdown = (): void => { - interval.current = setInterval(() => { - setCountdown((oldValue) => oldValue - 1) - }, 1000) - } - - const handleModalClose = () => { - setShowDeleteModal(false) - stopCountdown() - } - - useEffect(() => { - if (!showDeleteModal) { - return - } - if (countdown === 0) { - setDeletionButtonActive(true) - stopCountdown() - } - }, [countdown, showDeleteModal]) - - const handleModalOpen = () => { - setShowDeleteModal(true) - setDeletionButtonActive(false) - setCountdown(10) - startCountdown() - } - - const deleteUserAccount = async () => { - await deleteUser() - clearUser() - } - - return ( - <Fragment> - <Card className='bg-dark mb-4'> - <Card.Body> - <Card.Title> - <Trans i18nKey='profile.accountManagement' /> - </Card.Title> - <Button variant='secondary' block href={getApiUrl() + 'me/export'} className='mb-2'> - <ForkAwesomeIcon icon='cloud-download' fixedWidth={true} className='mx-2' /> - <Trans i18nKey='profile.exportUserData' /> - </Button> - <Button variant='danger' block onClick={handleModalOpen}> - <ForkAwesomeIcon icon='trash' fixedWidth={true} className='mx-2' /> - <Trans i18nKey='profile.deleteUser' /> - </Button> - </Card.Body> - </Card> - - <Modal show={showDeleteModal} onHide={handleModalClose} animation={true}> - <Modal.Body className='text-dark'> - <h3 dir='auto'> - <Trans i18nKey='profile.modal.deleteUser.message' /> - </h3> - <Trans i18nKey='profile.modal.deleteUser.subMessage' /> - </Modal.Body> - <Modal.Footer> - <Button variant='secondary' onClick={handleModalClose}> - <Trans i18nKey='common.close' /> - </Button> - <Button variant='danger' onClick={deleteUserAccount} disabled={!deletionButtonActive}> - {deletionButtonActive ? <Trans i18nKey={'profile.modal.deleteUser.title'} /> : countdown} - </Button> - </Modal.Footer> - </Modal> - </Fragment> - ) -} diff --git a/src/components/profile-page/settings/profile-change-password.tsx b/src/components/profile-page/settings/profile-change-password.tsx index b661a2bed..837abc94e 100644 --- a/src/components/profile-page/settings/profile-change-password.tsx +++ b/src/components/profile-page/settings/profile-change-password.tsx @@ -5,36 +5,50 @@ */ import type { ChangeEvent, FormEvent } from 'react' -import React, { useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { Button, Card, Form } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { changePassword } from '../../../api/me' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' +const REGEX_VALID_PASSWORD = /^[^\s].{5,}$/ + +/** + * Profile page section for changing the password when using internal login. + */ export const ProfileChangePassword: React.FC = () => { useTranslation() const [oldPassword, setOldPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [newPasswordAgain, setNewPasswordAgain] = useState('') - const [newPasswordValid, setNewPasswordValid] = useState(false) - const [newPasswordAgainValid, setNewPasswordAgainValid] = useState(false) - const regexPassword = /^[^\s].{5,}$/ + const newPasswordValid = useMemo(() => { + return REGEX_VALID_PASSWORD.test(newPassword) + }, [newPassword]) - const onChangeNewPassword = (event: ChangeEvent<HTMLInputElement>) => { + const newPasswordAgainValid = useMemo(() => { + return newPassword === newPasswordAgain + }, [newPassword, newPasswordAgain]) + + const onChangeOldPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => { + setOldPassword(event.target.value) + }, []) + + const onChangeNewPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => { setNewPassword(event.target.value) - setNewPasswordValid(regexPassword.test(event.target.value)) - setNewPasswordAgainValid(event.target.value === newPasswordAgain) - } + }, []) - const onChangeNewPasswordAgain = (event: ChangeEvent<HTMLInputElement>) => { + const onChangeNewPasswordAgain = useCallback((event: ChangeEvent<HTMLInputElement>) => { setNewPasswordAgain(event.target.value) - setNewPasswordAgainValid(event.target.value === newPassword) - } + }, []) - const updatePasswordSubmit = async (event: FormEvent) => { - await changePassword(oldPassword, newPassword) - event.preventDefault() - } + const onSubmitPasswordChange = useCallback( + (event: FormEvent) => { + event.preventDefault() + changePassword(oldPassword, newPassword).catch(showErrorNotification('profile.changePassword.failed')) + }, + [oldPassword, newPassword] + ) return ( <Card className='bg-dark mb-4'> @@ -42,7 +56,7 @@ export const ProfileChangePassword: React.FC = () => { <Card.Title> <Trans i18nKey='profile.changePassword.title' /> </Card.Title> - <Form onSubmit={updatePasswordSubmit} className='text-left'> + <Form onSubmit={onSubmitPasswordChange} className='text-left'> <Form.Group controlId='oldPassword'> <Form.Label> <Trans i18nKey='profile.changePassword.old' /> @@ -51,8 +65,9 @@ export const ProfileChangePassword: React.FC = () => { type='password' size='sm' className='bg-dark text-light' + autoComplete='current-password' required - onChange={(event) => setOldPassword(event.target.value)} + onChange={onChangeOldPassword} /> </Form.Group> <Form.Group controlId='newPassword'> @@ -63,6 +78,7 @@ export const ProfileChangePassword: React.FC = () => { type='password' size='sm' className='bg-dark text-light' + autoComplete='new-password' required onChange={onChangeNewPassword} isValid={newPasswordValid} @@ -80,6 +96,7 @@ export const ProfileChangePassword: React.FC = () => { size='sm' className='bg-dark text-light' required + autoComplete='new-password' onChange={onChangeNewPasswordAgain} isValid={newPasswordAgainValid} isInvalid={newPasswordAgain !== '' && !newPasswordAgainValid} diff --git a/src/components/profile-page/settings/profile-display-name.tsx b/src/components/profile-page/settings/profile-display-name.tsx index 10f4abaf3..fffc98500 100644 --- a/src/components/profile-page/settings/profile-display-name.tsx +++ b/src/components/profile-page/settings/profile-display-name.tsx @@ -5,19 +5,20 @@ */ import type { ChangeEvent, FormEvent } from 'react' -import React, { useEffect, useState } from 'react' -import { Alert, Button, Card, Form } from 'react-bootstrap' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { Button, Card, Form } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { updateDisplayName } from '../../../api/me' import { fetchAndSetUser } from '../../login-page/auth/utils' import { useApplicationState } from '../../../hooks/common/use-application-state' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' +/** + * Profile page section for changing the current display name. + */ export const ProfileDisplayName: React.FC = () => { - const regexInvalidDisplayName = /^\s*$/ const { t } = useTranslation() const userName = useApplicationState((state) => state.user?.name) - const [submittable, setSubmittable] = useState(false) - const [error, setError] = useState(false) const [displayName, setDisplayName] = useState('') useEffect(() => { @@ -26,24 +27,23 @@ export const ProfileDisplayName: React.FC = () => { } }, [userName]) - if (!userName) { - return <Alert variant={'danger'}>User not logged in</Alert> - } - - const changeNameField = (event: ChangeEvent<HTMLInputElement>) => { - setSubmittable(!regexInvalidDisplayName.test(event.target.value)) + const onChangeDisplayName = useCallback((event: ChangeEvent<HTMLInputElement>) => { setDisplayName(event.target.value) - } + }, []) - const doAsyncChange = async () => { - await updateDisplayName(displayName) - await fetchAndSetUser() - } + const onSubmitNameChange = useCallback( + (event: FormEvent) => { + event.preventDefault() + updateDisplayName(displayName) + .then(fetchAndSetUser) + .catch(showErrorNotification('profile.changeDisplayNameFailed')) + }, + [displayName] + ) - const changeNameSubmit = (event: FormEvent) => { - doAsyncChange().catch(() => setError(true)) - event.preventDefault() - } + const formSubmittable = useMemo(() => { + return displayName.trim() !== '' + }, [displayName]) return ( <Card className='bg-dark mb-4'> @@ -51,7 +51,7 @@ export const ProfileDisplayName: React.FC = () => { <Card.Title> <Trans i18nKey='profile.userProfile' /> </Card.Title> - <Form onSubmit={changeNameSubmit} className='text-left'> + <Form onSubmit={onSubmitNameChange} className='text-left'> <Form.Group controlId='displayName'> <Form.Label> <Trans i18nKey='profile.displayName' /> @@ -62,9 +62,8 @@ export const ProfileDisplayName: React.FC = () => { placeholder={t('profile.displayName')} value={displayName} className='bg-dark text-light' - onChange={changeNameField} - isValid={submittable} - isInvalid={error} + onChange={onChangeDisplayName} + isValid={formSubmittable} required /> <Form.Text> @@ -72,7 +71,7 @@ export const ProfileDisplayName: React.FC = () => { </Form.Text> </Form.Group> - <Button type='submit' variant='primary' disabled={!submittable}> + <Button type='submit' variant='primary' disabled={!formSubmittable}> <Trans i18nKey='common.save' /> </Button> </Form>