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 => { const response = await fetch(`${getApiUrl()}tokens`, { @@ -15,18 +15,21 @@ export const getAccessTokenList = async (): Promise => { return (await response.json()) as AccessToken[] } -export const postNewAccessToken = async (label: string): Promise => { +export const postNewAccessToken = async (label: string, expiryDate: string): Promise => { 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 => { - const response = await fetch(`${getApiUrl()}tokens/${timestamp}`, { +export const deleteAccessToken = async (keyId: string): Promise => { + 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 = ({ countdownStartSeconds, children, ...props }) => { + const [secondsRemaining, setSecondsRemaining] = useState(countdownStartSeconds) + + useInterval(() => setSecondsRemaining((previous) => previous - 1), secondsRemaining <= 0 ? null : 1000) + + return ( + + ) +} 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 = ({ show, onHide, tokenWithSecret }) => { + if (!tokenWithSecret) { + return null + } + + return ( + + + +
+ +
+ + + +
+ ) +} 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) => 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 = ({ + onChangeExpiry, + formValues +}) => { + useTranslation() + const minMaxDefaultDates = useExpiryDates() + + return ( + + + + + + + ) +} 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) => 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 = ({ + onChangeLabel, + formValues +}) => { + const { t } = useTranslation() + + const labelValid = useMemo(() => { + return formValues.label.trim() !== '' + }, [formValues]) + + return ( + + + + + + + ) +} 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 = ({ formValues }) => { + const validFormValues = useMemo(() => { + return formValues.label.trim() !== '' && formValues.expiryDate.trim() !== '' + }, [formValues]) + + return ( + + ) +} 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(() => formValuesInitialState) + const [newTokenWithSecret, setNewTokenWithSecret] = useState() + + const onHideCreatedModal = useCallback(() => { + setFormValues(formValuesInitialState) + setNewTokenWithSecret(undefined) + }, [formValuesInitialState]) + + const onCreateToken = useOnCreateToken(formValues.label, formValues.expiryDate, setNewTokenWithSecret) + + const onChangeExpiry = useCallback((event: ChangeEvent) => { + setFormValues((previousValues) => { + return { + ...previousValues, + expiryDate: event.target.value + } + }) + }, []) + + const onChangeLabel = useCallback((event: ChangeEvent) => { + setFormValues((previousValues) => { + return { + ...previousValues, + label: event.target.value + } + }) + }, []) + + return ( + +
+ +
+
+ + + + + +
+ ) +} 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 = ({ 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 ( + + + + + + + + + ) +} 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 = ({ token }) => { + useTranslation() + const [showDeletionModal, setShowDeletionModal] = useState(false) + + const onShowDeletionModal = useCallback(() => { + setShowDeletionModal(true) + }, []) + + const onHideDeletionModal = useCallback(() => { + setShowDeletionModal(false) + }, []) + + return ( + + + + {token.label} + + + + + + + + + + + ) +} 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([]) - 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 ( - - - - - - -

- -

-

- -

-
- - - - - - - - {accessTokens.map((token) => { - return ( - - - - {token.label} - - - - - - selectForDeletion(token.created)} - {...cypressId('access-token-delete-button')} - /> - - - - ) - })} - -
-
- - - ) => setNewTokenLabel(event.target.value)} - isValid={newTokenSubmittable} - required - {...cypressId('access-token-add-input')} - /> - - - - - -
-
-
- - setShowAddedModal(false)} - title='profile.modal.addedAccessToken.title' - {...cypressId('access-token-modal-add')}> - - -
- -
- - - -
- - setShowDeleteModal(false)} - title={'profile.modal.deleteAccessToken.title'} - {...cypressId('access-token-modal-delete')}> - - - - - - - -
+ + + + + +

+ +

+

+ +

+
+ + + + + {accessTokens.map((token) => ( + + ))} + +
+ + + +
+
) } 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 = ({ 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 ( + + + + + + + + + + + + ) +} 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 ( + + + + + + + + + + + + + ) +} 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() - - 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 ( - - - - - - - - - - - - - -

- -

- -
- - - - -
-
- ) -} 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) => { + const newPasswordAgainValid = useMemo(() => { + return newPassword === newPasswordAgain + }, [newPassword, newPasswordAgain]) + + const onChangeOldPassword = useCallback((event: ChangeEvent) => { + setOldPassword(event.target.value) + }, []) + + const onChangeNewPassword = useCallback((event: ChangeEvent) => { setNewPassword(event.target.value) - setNewPasswordValid(regexPassword.test(event.target.value)) - setNewPasswordAgainValid(event.target.value === newPasswordAgain) - } + }, []) - const onChangeNewPasswordAgain = (event: ChangeEvent) => { + const onChangeNewPasswordAgain = useCallback((event: ChangeEvent) => { 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 ( @@ -42,7 +56,7 @@ export const ProfileChangePassword: React.FC = () => { -
+ @@ -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} /> @@ -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 User not logged in - } - - const changeNameField = (event: ChangeEvent) => { - setSubmittable(!regexInvalidDisplayName.test(event.target.value)) + const onChangeDisplayName = useCallback((event: ChangeEvent) => { 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 ( @@ -51,7 +51,7 @@ export const ProfileDisplayName: React.FC = () => { - + @@ -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 /> @@ -72,7 +71,7 @@ export const ProfileDisplayName: React.FC = () => { -