From 053edb9acee36dd86fb8c91e5b1f0f452374443e Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Sat, 10 Oct 2020 11:38:32 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + cypress/integration/profile.spec.ts | 61 +++++++ public/api/v2/tokens | 10 ++ public/locales/en.json | 21 ++- src/api/tokens/index.ts | 28 +++ src/api/tokens/types.d.ts | 8 + .../common/icon-button/icon-button.tsx | 9 +- .../access-tokens/profile-access-tokens.tsx | 166 ++++++++++++++++++ src/components/profile-page/profile-page.tsx | 2 + 9 files changed, 302 insertions(+), 4 deletions(-) create mode 100644 cypress/integration/profile.spec.ts create mode 100644 public/api/v2/tokens create mode 100644 src/api/tokens/index.ts create mode 100644 src/api/tokens/types.d.ts create mode 100644 src/components/profile-page/access-tokens/profile-access-tokens.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c5901a51..7141eaa0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - The (add image) and (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. --- diff --git a/cypress/integration/profile.spec.ts b/cypress/integration/profile.spec.ts new file mode 100644 index 000000000..789f8393b --- /dev/null +++ b/cypress/integration/profile.spec.ts @@ -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') + }) + }) +}) diff --git a/public/api/v2/tokens b/public/api/v2/tokens new file mode 100644 index 000000000..ca4cb3bf0 --- /dev/null +++ b/public/api/v2/tokens @@ -0,0 +1,10 @@ +[ + { + "label": "Demo-App", + "created": 1601991518 + }, + { + "label": "CLI @ Test-PC", + "created": 1601912159 + } +] diff --git a/public/locales/en.json b/public/locales/en.json index 832e8b7d7..3dac48fda 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/src/api/tokens/index.ts b/src/api/tokens/index.ts new file mode 100644 index 000000000..683ff37d8 --- /dev/null +++ b/src/api/tokens/index.ts @@ -0,0 +1,28 @@ +import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' +import { AccessToken, AccessTokenSecret } from './types' + +export const getAccessTokenList = async (): Promise => { + const response = await fetch(`${getApiUrl()}/tokens`, { + ...defaultFetchConfig + }) + expectResponseCode(response) + return await response.json() as AccessToken[] +} + +export const postNewAccessToken = async (label: string): Promise => { + 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 => { + const response = await fetch(`${getApiUrl()}/tokens/${timestamp}`, { + ...defaultFetchConfig, + method: 'DELETE' + }) + expectResponseCode(response) +} diff --git a/src/api/tokens/types.d.ts b/src/api/tokens/types.d.ts new file mode 100644 index 000000000..f5de54d29 --- /dev/null +++ b/src/api/tokens/types.d.ts @@ -0,0 +1,8 @@ +export interface AccessToken { + label: string + created: number +} + +export interface AccessTokenSecret { + secret: string +} diff --git a/src/components/common/icon-button/icon-button.tsx b/src/components/common/icon-button/icon-button.tsx index ad8a421e7..52d577aca 100644 --- a/src/components/common/icon-button/icon-button.tsx +++ b/src/components/common/icon-button/icon-button.tsx @@ -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 = ({ icon, children, border = - - {children} - + + + {children} + + ) } diff --git a/src/components/profile-page/access-tokens/profile-access-tokens.tsx b/src/components/profile-page/access-tokens/profile-access-tokens.tsx new file mode 100644 index 000000000..ac21ddbfe --- /dev/null +++ b/src/components/profile-page/access-tokens/profile-access-tokens.tsx @@ -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([]) + 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 ( + + + + + + +

+

+
+ + + + + + + + { + accessTokens.map((token) => { + return ( + + + + { token.label } + + + + + + selectForDeletion(token.created)}/> + + + + ) + }) + } + +
+
+ + + ) => setNewTokenLabel(event.target.value)} + isValid={newTokenSubmittable} + required + /> + + + + + +
+
+
+ + setShowAddedModal(false)} titleI18nKey='profile.modal.addedAccessToken.title'> + + +
+ +
+ + + +
+ + setShowDeleteModal(false)} + titleI18nKey={'profile.modal.deleteAccessToken.title'}> + + +
+ ) +} diff --git a/src/components/profile-page/profile-page.tsx b/src/components/profile-page/profile-page.tsx index 50c57c259..1ad2f2b0a 100644 --- a/src/components/profile-page/profile-page.tsx +++ b/src/components/profile-page/profile-page.tsx @@ -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 = () => { +