mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-17 00:24:43 -04:00
Add access token management to profile (#653)
* Add mock-files, API calls and overall tokens-UI * Added ability to add tokens * Added token deletion feature (based on timestamp) * Replace mock-method by real API code * Add cypress tests * Added CHANGELOG information * Un-access-ify i18n * Set unique react-element key to timestamp of token-creation * Remove 'now' from changelog * Use @mrdrogdrog's suggestion for the info label
This commit is contained in:
parent
f72380edd1
commit
053edb9ace
9 changed files with 302 additions and 4 deletions
28
src/api/tokens/index.ts
Normal file
28
src/api/tokens/index.ts
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
|
||||
import { AccessToken, AccessTokenSecret } from './types'
|
||||
|
||||
export const getAccessTokenList = async (): Promise<AccessToken[]> => {
|
||||
const response = await fetch(`${getApiUrl()}/tokens`, {
|
||||
...defaultFetchConfig
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return await response.json() as AccessToken[]
|
||||
}
|
||||
|
||||
export const postNewAccessToken = async (label: string): Promise<AccessToken & AccessTokenSecret> => {
|
||||
const response = await fetch(`${getApiUrl()}/tokens`, {
|
||||
...defaultFetchConfig,
|
||||
method: 'POST',
|
||||
body: label
|
||||
})
|
||||
expectResponseCode(response)
|
||||
return await response.json() as (AccessToken & AccessTokenSecret)
|
||||
}
|
||||
|
||||
export const deleteAccessToken = async (timestamp: number): Promise<void> => {
|
||||
const response = await fetch(`${getApiUrl()}/tokens/${timestamp}`, {
|
||||
...defaultFetchConfig,
|
||||
method: 'DELETE'
|
||||
})
|
||||
expectResponseCode(response)
|
||||
}
|
8
src/api/tokens/types.d.ts
vendored
Normal file
8
src/api/tokens/types.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
export interface AccessToken {
|
||||
label: string
|
||||
created: number
|
||||
}
|
||||
|
||||
export interface AccessTokenSecret {
|
||||
secret: string
|
||||
}
|
|
@ -3,6 +3,7 @@ import { Button, ButtonProps } from 'react-bootstrap'
|
|||
import { ForkAwesomeIcon } from '../fork-awesome/fork-awesome-icon'
|
||||
import { IconName } from '../fork-awesome/types'
|
||||
import './icon-button.scss'
|
||||
import { ShowIf } from '../show-if/show-if'
|
||||
|
||||
export interface IconButtonProps extends ButtonProps {
|
||||
icon: IconName
|
||||
|
@ -16,9 +17,11 @@ export const IconButton: React.FC<IconButtonProps> = ({ icon, children, border =
|
|||
<span className="icon-part d-flex align-items-center">
|
||||
<ForkAwesomeIcon icon={icon} className={'icon'}/>
|
||||
</span>
|
||||
<span className="text-part d-flex align-items-center">
|
||||
{children}
|
||||
</span>
|
||||
<ShowIf condition={!!children}>
|
||||
<span className="text-part d-flex align-items-center">
|
||||
{children}
|
||||
</span>
|
||||
</ShowIf>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import React, { ChangeEvent, FormEvent, Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Card, Col, Form, ListGroup, Modal, Row } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { deleteAccessToken, getAccessTokenList, postNewAccessToken } from '../../../api/tokens'
|
||||
import { 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 { DeletionModal } from '../../common/modals/deletion-modal'
|
||||
import { ShowIf } from '../../common/show-if/show-if'
|
||||
|
||||
export const ProfileAccessTokens: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [error, setError] = useState(false)
|
||||
const [showAddedModal, setShowAddedModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
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 => {
|
||||
console.error(error)
|
||||
setError(true)
|
||||
})
|
||||
}, [newTokenLabel])
|
||||
|
||||
const deleteToken = useCallback(() => {
|
||||
deleteAccessToken(selectedForDeletion)
|
||||
.then(() => {
|
||||
setSelectedForDeletion(0)
|
||||
})
|
||||
.catch(error => {
|
||||
console.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 => {
|
||||
console.error(err)
|
||||
setError(true)
|
||||
})
|
||||
}, [showAddedModal])
|
||||
|
||||
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'>
|
||||
{ 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)}/>
|
||||
</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
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={'auto'}>
|
||||
<Button
|
||||
type='submit'
|
||||
variant='primary'
|
||||
size='sm'
|
||||
disabled={!newTokenSubmittable}>
|
||||
<Trans i18nKey='profile.accessTokens.createToken'/>
|
||||
</Button>
|
||||
</Col>
|
||||
</Form.Row>
|
||||
</Form>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<CommonModal show={showAddedModal} onHide={() => setShowAddedModal(false)} titleI18nKey='profile.modal.addedAccessToken.title'>
|
||||
<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>
|
||||
|
||||
<DeletionModal
|
||||
onConfirm={deleteToken}
|
||||
deletionButtonI18nKey={'common.delete'}
|
||||
show={showDeleteModal}
|
||||
onHide={() => setShowDeleteModal(false)}
|
||||
titleI18nKey={'profile.modal.deleteAccessToken.title'}>
|
||||
<Trans i18nKey='profile.modal.deleteAccessToken.message'/>
|
||||
</DeletionModal>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -5,6 +5,7 @@ import { Redirect } from 'react-router'
|
|||
import { ApplicationState } from '../../redux'
|
||||
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 { ProfileChangePassword } from './settings/profile-change-password'
|
||||
import { ProfileDisplayName } from './settings/profile-display-name'
|
||||
|
@ -26,6 +27,7 @@ export const ProfilePage: React.FC = () => {
|
|||
<ShowIf condition={userProvider === LoginProvider.INTERNAL}>
|
||||
<ProfileChangePassword/>
|
||||
</ShowIf>
|
||||
<ProfileAccessTokens/>
|
||||
<ProfileAccountManagement/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue