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

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

View file

@ -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."
}
}
},

View file

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

View file

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

View file

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

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>