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>