Refactor profile page (#1636)

This commit is contained in:
Erik Michelson 2021-12-02 23:03:03 +01:00 committed by GitHub
parent 394b8bd199
commit f1117dbad3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 765 additions and 339 deletions

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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
}
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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()
}
}, [])
}

View file

@ -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]
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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)

View file

@ -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>
)
}

View file

@ -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}

View file

@ -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>