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:
Erik Michelson 2020-10-10 11:38:32 +02:00 committed by GitHub
parent f72380edd1
commit 053edb9ace
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 302 additions and 4 deletions

View file

@ -57,6 +57,7 @@
- The <i class="fa fa-picture-o"/> (add image) and <i class="fa fa-link"/> (add link) toolbar buttons, put selected links directly in the `()` instead of the `[]` part of the generated markdown
- The help dialog has multiple tabs, and is a bit more organized.
- Use KaTeX instead of MathJax. ([Why?](https://community.codimd.org/t/frequently-asked-questions/190))
- The access tokens for the CLI and 3rd-party-clients can be managed in the user profile.
---

View file

@ -0,0 +1,61 @@
describe('profile page', () => {
beforeEach(() => {
cy.route({
url: '/api/v2/tokens',
method: 'GET',
response: [
{
label: "cypress-App",
created: 1601991518
}
]
})
cy.route({
url: '/api/v2/tokens',
method: 'POST',
response: {
label: 'cypress',
secret: 'c-y-p-r-e-s-s',
created: Date.now()
}
})
cy.route({
url: '/api/v2/tokens/1601991518',
method: 'DELETE',
response: []
})
cy.visit('/profile')
})
describe('access tokens', () => {
it('list existing tokens', () => {
cy.get('.card.access-tokens .list-group-item .text-start.col')
.contains('cypress-App')
})
it('delete token', () => {
cy.get('.card.access-tokens .list-group-item .btn-danger')
.click()
cy.get('.modal-dialog')
.should('be.visible')
.get('.modal-footer .btn-danger')
.click()
cy.get('.modal-dialog')
.should('not.be.visible')
})
it('add token', () => {
cy.get('.card.access-tokens .btn-primary')
.should('be.disabled')
cy.get('.card.access-tokens input[type=text]')
.type('cypress')
cy.get('.card.access-tokens .btn-primary')
.should('not.be.disabled')
.click()
cy.get('.modal-dialog')
.should('be.visible')
.get('.modal-dialog input[readonly]')
.should('have.value', 'c-y-p-r-e-s-s')
})
})
})

10
public/api/v2/tokens Normal file
View file

@ -0,0 +1,10 @@
[
{
"label": "Demo-App",
"created": 1601991518
},
{
"label": "CLI @ Test-PC",
"created": 1601912159
}
]

View file

@ -152,11 +152,28 @@
"accountManagement": "Account management",
"deleteUser": "Delete user",
"exportUserData": "Export user data",
"accessTokens": {
"title": "Access tokens",
"info": "Access tokens are an password-less authentication method for the CLI or third-party applications.",
"infoDev": "Developers can use these to access the public API.",
"noTokens": "You don't have any tokens generated yet.",
"createToken": "Create token",
"label": "Token label",
"created": "created {{time}}"
},
"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."
},
"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."
},
"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."
}
}
},
@ -384,12 +401,14 @@
"ok": "OK",
"close": "Close",
"save": "Save",
"delete": "Delete",
"or": "or",
"and": "and",
"avatarOf": "avatar of '{{name}}'",
"why": "Why?",
"successfullyCopied": "Copied!",
"copyError": "Error while copying!"
"copyError": "Error while copying!",
"errorOccurred": "An error occurred"
},
"login": {
"chooseMethod": "Choose method",

28
src/api/tokens/index.ts Normal file
View 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
View file

@ -0,0 +1,8 @@
export interface AccessToken {
label: string
created: number
}
export interface AccessTokenSecret {
secret: string
}

View file

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

View file

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

View file

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