mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 03:06:31 -05: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
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
61
cypress/integration/profile.spec.ts
Normal file
61
cypress/integration/profile.spec.ts
Normal 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
10
public/api/v2/tokens
Normal file
|
@ -0,0 +1,10 @@
|
|||
[
|
||||
{
|
||||
"label": "Demo-App",
|
||||
"created": 1601991518
|
||||
},
|
||||
{
|
||||
"label": "CLI @ Test-PC",
|
||||
"created": 1601912159
|
||||
}
|
||||
]
|
|
@ -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
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…
Reference in a new issue