Skip to content

Commit

Permalink
[NEW] Personal Access Tokens rewritten (#18206)
Browse files Browse the repository at this point in the history
* Finished refactor accountTokens

* Use useSetModal hook

* Prefer children over renderRow on GenericTable

* Fix AddToken markup

* Move InfoModal to its own module

* Move hook calls into inner components

* Block access to AccountTokensPage rendering

Co-authored-by: Tasso Evangelista <[email protected]>
  • Loading branch information
gabriellsh and tassoevan authored Jul 17, 2020
1 parent 13c1d88 commit 19001a4
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 10 deletions.
3 changes: 1 addition & 2 deletions client/account/AccountProfilePage.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ButtonGroup, Button, Box, Icon, PasswordInput, TextInput, Modal } from '@rocket.chat/fuselage';
import { SHA256 } from 'meteor/sha';
import React, { useMemo, useState, useCallback } from 'react';
import { ButtonGroup, Button, Box, Icon, PasswordInput, TextInput } from '@rocket.chat/fuselage';

import Page from '../components/basic/Page';
import AccountProfileForm from './AccountProfileForm';
Expand All @@ -12,7 +12,6 @@ import { useUser } from '../contexts/UserContext';
import { useToastMessageDispatch } from '../contexts/ToastMessagesContext';
import { useMethod } from '../contexts/ServerContext';
import { useSetModal } from '../contexts/ModalContext';
import { Modal } from '../components/basic/Modal';
import { useUpdateAvatar } from '../hooks/useUpdateAvatar';
import { getUserEmailAddress } from '../helpers/getUserEmailAddress';

Expand Down
18 changes: 17 additions & 1 deletion client/account/AccountRoute.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import React, { useEffect } from 'react';

import { useRouteParameter, useRoute } from '../contexts/RouterContext';
import { SideNav } from '../../app/ui-utils';
import NotAuthorizedPage from '../components/NotAuthorizedPage';
import { usePermission } from '../contexts/AuthorizationContext';
import { useRouteParameter, useRoute } from '../contexts/RouterContext';
import AccountProfilePage from './AccountProfilePage';
import AccountPreferencesPage from './preferences/AccountPreferencesPage';
import AccountSecurityPage from './security/AccountSecurityPage';
import AccountTokensPage from './tokens/AccountTokensPage';
import './sidebarItems';

const AccountRoute = () => {
Expand All @@ -18,15 +21,28 @@ const AccountRoute = () => {
SideNav.openFlex();
});

const canCreateTokens = usePermission('create-personal-access-tokens');

if (page === 'profile') {
return <AccountProfilePage />;
}

if (page === 'preferences') {
return <AccountPreferencesPage />;
}

if (page === 'security') {
return <AccountSecurityPage />;
}

if (page === 'tokens') {
if (!canCreateTokens) {
return <NotAuthorizedPage />;
}

return <AccountTokensPage />;
}

return null;
};

Expand Down
3 changes: 1 addition & 2 deletions client/account/preferences/PreferencesMyDataSection.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { useCallback } from 'react';
import { Accordion, Field, FieldGroup, ButtonGroup, Button, Icon, Box } from '@rocket.chat/fuselage';
import { Accordion, Field, FieldGroup, ButtonGroup, Button, Icon, Box, Modal } from '@rocket.chat/fuselage';

import { useTranslation } from '../../contexts/TranslationContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { Modal } from '../../components/basic/Modal';
import { useMethod } from '../../contexts/ServerContext';
import { useSetModal } from '../../contexts/ModalContext';

Expand Down
22 changes: 22 additions & 0 deletions client/account/tokens/AccountTokensPage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';

import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointDataExperimental } from '../../hooks/useEndpointDataExperimental';
import Page from '../../components/basic/Page';
import AccountTokensTable from './AccountTokensTable';
import AddToken from './AddToken';

const AccountTokensPage = () => {
const t = useTranslation();
const { data, reload } = useEndpointDataExperimental('users.getPersonalAccessTokens');

return <Page>
<Page.Header title={t('Personal_Access_Tokens')}/>
<Page.Content>
<AddToken onDidAddToken={reload}/>
<AccountTokensTable data={data} reload={reload} />
</Page.Content>
</Page>;
};

export default AccountTokensPage;
131 changes: 131 additions & 0 deletions client/account/tokens/AccountTokensTable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { Table, Button, ButtonGroup, Icon, Box } from '@rocket.chat/fuselage';
import React, { useMemo, useCallback, useState } from 'react';

import { GenericTable, Th } from '../../components/GenericTable';
import { useSetModal } from '../../contexts/ModalContext';
import { useMethod } from '../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useResizeInlineBreakpoint } from '../../hooks/useResizeInlineBreakpoint';
import { useFormatDateAndTime } from '../../hooks/useFormatDateAndTime';
import InfoModal from './InfoModal';
import { useUserId } from '../../contexts/UserContext';

const TokenRow = ({ lastTokenPart, name, createdAt, bypassTwoFactor, formatDateAndTime, onRegenerate, onRemove, t, isMedium }) => {
const handleRegenerate = useCallback(() => onRegenerate(name), [name, onRegenerate]);
const handleRemove = useCallback(() => onRemove(name), [name, onRemove]);

return <Table.Row key={name} tabIndex={0} role='link' action qa-token-name={name}>
<Table.Cell withTruncatedText color='default' fontScale='p2'>{name}</Table.Cell>
{isMedium && <Table.Cell withTruncatedText>{formatDateAndTime(createdAt)}</Table.Cell>}
<Table.Cell withTruncatedText>...{lastTokenPart}</Table.Cell>
<Table.Cell withTruncatedText>{bypassTwoFactor ? t('Ignore') : t('Require')}</Table.Cell>
<Table.Cell withTruncatedText>
<ButtonGroup>
<Button onClick={handleRegenerate} small><Icon name='refresh' size='x16'/></Button>
<Button onClick={handleRemove} small><Icon name='trash' size='x16'/></Button>
</ButtonGroup>
</Table.Cell>
</Table.Row>;
};

export function AccountTokensTable({ data, reload }) {
const t = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();

const userId = useUserId();

const regenerateToken = useMethod('personalAccessTokens:regenerateToken');
const removeToken = useMethod('personalAccessTokens:removeToken');

const [ref, isMedium] = useResizeInlineBreakpoint([600], 200);

const [params, setParams] = useState({ current: 0, itemsPerPage: 25 });

const tokensTotal = data && data.success ? data.tokens.length : 0;

const { current, itemsPerPage } = params;

const tokens = useMemo(() => {
if (!data) { return null; }
if (!data.success) { return []; }
const sliceStart = current > tokensTotal ? tokensTotal - itemsPerPage : current;
return data.tokens.slice(sliceStart, sliceStart + itemsPerPage);
}, [current, data, itemsPerPage, tokensTotal]);

const closeModal = useCallback(() => setModal(null), [setModal]);

const header = useMemo(() => [
<Th key={'name'}>{t('API_Personal_Access_Token_Name')}</Th>,
isMedium && <Th key={'createdAt'}>{t('Created_at')}</Th>,
<Th key={'lastTokenPart'}>{t('Last_token_part')}</Th>,
<Th key={'2fa'}>{t('Two Factor Authentication')}</Th>,
<Th key={'actions'} />,
].filter(Boolean), [isMedium, t]);

const onRegenerate = useCallback((name) => {
const onConfirm = async () => {
try {
const token = await regenerateToken({ tokenName: name });

setModal(<InfoModal
title={t('API_Personal_Access_Token_Generated')}
content={<Box dangerouslySetInnerHTML={{ __html: t('API_Personal_Access_Token_Generated_Text_Token_s_UserId_s', { token, userId }) }}/>}
confirmText={t('ok')}
onConfirm={closeModal}
/>);

reload();
} catch (e) {
dispatchToastMessage({ type: 'error', message: e });
}
};

setModal(<InfoModal
title={t('Are_you_sure')}
content={t('API_Personal_Access_Tokens_Regenerate_Modal')}
confirmText={t('API_Personal_Access_Tokens_Regenerate_It')}
onConfirm={onConfirm}
cancelText={t('Cancel')}
onClose={closeModal}
/>);
}, [closeModal, dispatchToastMessage, regenerateToken, reload, setModal, t, userId]);

const onRemove = useCallback((name) => {
const onConfirm = async () => {
try {
await removeToken({ tokenName: name });

dispatchToastMessage({ type: 'success', message: t('Removed') });
reload();
closeModal();
} catch (e) {
dispatchToastMessage({ type: 'error', message: e });
}
};

setModal(<InfoModal
title={t('Are_you_sure')}
content={t('API_Personal_Access_Tokens_Remove_Modal')}
confirmText={t('Yes')}
onConfirm={onConfirm}
cancelText={t('Cancel')}
onClose={closeModal}
/>);
}, [closeModal, dispatchToastMessage, reload, removeToken, setModal, t]);

return <GenericTable ref={ref} header={header} results={tokens} total={tokensTotal} setParams={setParams} params={params}>
{useCallback((props) => <TokenRow
onRegenerate={onRegenerate}
onRemove={onRemove}
t={t}
formatDateAndTime={formatDateAndTime}
isMedium={isMedium}
{...props}
/>, [formatDateAndTime, isMedium, onRegenerate, onRemove, t])}
</GenericTable>;
}

export default AccountTokensTable;
68 changes: 68 additions & 0 deletions client/account/tokens/AddToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Box, TextInput, Button, Field, FieldGroup, Margins, CheckBox } from '@rocket.chat/fuselage';
import { useUniqueId } from '@rocket.chat/fuselage-hooks';
import React, { useCallback } from 'react';

import { useSetModal } from '../../contexts/ModalContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useMethod } from '../../contexts/ServerContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useUserId } from '../../contexts/UserContext';
import { useForm } from '../../hooks/useForm';
import InfoModal from './InfoModal';

const initialValues = {
name: '',
bypassTwoFactor: false,
};

const AddToken = ({ onDidAddToken, ...props }) => {
const t = useTranslation();
const createTokenFn = useMethod('personalAccessTokens:generateToken');
const dispatchToastMessage = useToastMessageDispatch();
const setModal = useSetModal();

const userId = useUserId();

const { values, handlers, reset } = useForm(initialValues);

const { name, bypassTwoFactor } = values;
const { handleName, handleBypassTwoFactor } = handlers;

const closeModal = useCallback(() => setModal(null), [setModal]);

const handleAdd = useCallback(async () => {
try {
const token = await createTokenFn({ tokenName: name, bypassTwoFactor });

setModal(<InfoModal
title={t('API_Personal_Access_Token_Generated')}
content={<Box dangerouslySetInnerHTML={{ __html: t('API_Personal_Access_Token_Generated_Text_Token_s_UserId_s', { token, userId }) }}/>}
confirmText={t('ok')}
onConfirm={closeModal}
/>);
reset();
onDidAddToken();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
}, [bypassTwoFactor, closeModal, createTokenFn, dispatchToastMessage, name, onDidAddToken, reset, setModal, t, userId]);

const bypassTwoFactorCheckboxId = useUniqueId();

return <FieldGroup is='form' marginBlock='x8' {...props}>
<Field>
<Field.Row>
<Margins inlineEnd='x4'>
<TextInput value={name} onChange={handleName} placeholder={t('API_Add_Personal_Access_Token')}/>
</Margins>
<Button primary onClick={handleAdd}>{t('Add')}</Button>
</Field.Row>
<Field.Row>
<CheckBox id={bypassTwoFactorCheckboxId} checked={bypassTwoFactor} onChange={handleBypassTwoFactor} />
<Field.Label htmlFor={bypassTwoFactorCheckboxId}>{t('Ignore')} {t('Two Factor Authentication')}</Field.Label>
</Field.Row>
</Field>
</FieldGroup>;
};

export default AddToken;
22 changes: 22 additions & 0 deletions client/account/tokens/InfoModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';
import { Button, ButtonGroup, Modal } from '@rocket.chat/fuselage';

const InfoModal = ({ title, content, icon, onConfirm, onClose, confirmText, cancelText, ...props }) =>
<Modal {...props}>
<Modal.Header>
{icon}
<Modal.Title>{title}</Modal.Title>
<Modal.Close onClick={onClose}/>
</Modal.Header>
<Modal.Content fontScale='p1'>
{content}
</Modal.Content>
<Modal.Footer>
<ButtonGroup align='end'>
{cancelText && <Button onClick={onClose}>{cancelText}</Button>}
{confirmText && onConfirm && <Button primary onClick={onConfirm}>{confirmText}</Button>}
</ButtonGroup>
</Modal.Footer>
</Modal>;

export default InfoModal;
16 changes: 11 additions & 5 deletions client/hooks/useEndpointDataExperimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ export const ENDPOINT_STATES = {
};

const defaultState = { data: null, state: ENDPOINT_STATES.LOADING };

export const useEndpointDataExperimental = (endpoint, params = {}, { delayTimeout = 1000 } = {}) => {
const defaultParams = {};
const defaultOptions = {};

export const useEndpointDataExperimental = (
endpoint,
params = defaultParams,
{ delayTimeout = 1000 } = defaultOptions,
) => {
const [data, setData] = useState(defaultState);

const getData = useEndpoint('GET', endpoint);
Expand All @@ -26,7 +32,7 @@ export const useEndpointDataExperimental = (endpoint, params = {}, { delayTimeou
return;
}

setData({ delaying: true, state: ENDPOINT_STATES.LOADING });
setData({ delaying: true, state: ENDPOINT_STATES.LOADING, reload: fetchData });
}, delayTimeout);

try {
Expand All @@ -42,13 +48,13 @@ export const useEndpointDataExperimental = (endpoint, params = {}, { delayTimeou
}


setData({ data, state: ENDPOINT_STATES.DONE });
setData({ data, state: ENDPOINT_STATES.DONE, reload: fetchData });
} catch (error) {
if (!mounted) {
return;
}

setData({ error, state: ENDPOINT_STATES.ERROR });
setData({ error, state: ENDPOINT_STATES.ERROR, reload: fetchData });
dispatchToastMessage({ type: 'error', message: error });
} finally {
clearTimeout(timer);
Expand Down

0 comments on commit 19001a4

Please sign in to comment.