expect(
await screen.findByText(`users.tokens.new_token_created.${newTokenName}`),
).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'copy_to_clipboard' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Copy to clipboard' })).toBeInTheDocument();
const lastTokenCreated = tokenMock.getLastToken();
// eslint-disable-next-line jest/no-conditional-in-test
scmAddButton: byRole('button', { name: 'add_verb' }),
createUserDialogButton: byRole('button', { name: 'create' }),
cancelButton: byRole('button', { name: 'cancel' }),
+ closeButton: byRole('button', { name: 'close' }),
reloadButton: byRole('button', { name: 'reload' }),
doneButton: byRole('button', { name: 'done' }),
changeButton: byRole('button', { name: 'change_verb' }),
unselectedFilter: byRole('radio', { name: 'unselected' }),
getGroups: () => within(ui.dialogGroups.get()).getAllByRole('checkbox'),
- dialogTokens: byRole('dialog', { name: 'users.tokens' }),
+ dialogTokens: byRole('dialog', { name: /users.user_X_tokens/ }),
dialogPasswords: byRole('dialog', { name: 'my_profile.password.title' }),
dialogUpdateUser: byRole('dialog', { name: 'users.update_user' }),
dialogCreateUser: byRole('dialog', { name: 'users.create_user' }),
githubProvisioningSuccess: byText(/synchronization_successful/),
githubProvisioningWarning: byText(/synchronization_successful.with_warning/),
githubProvisioningAlert: byText(/synchronization_failed_short/),
+ expiresInSelector: byRole('combobox', { name: 'users.tokens.expires_in' }),
};
beforeEach(() => {
const getTokensList = () => ui.dialogTokens.byRole('row').getAll();
- expect(getTokensList()).toHaveLength(3);
+ expect(getTokensList()).toHaveLength(3); // header + 2 mocked tokens
await user.type(ui.tokenNameInput.get(), 'test');
await user.click(ui.generateButton.get());
- // Not deleted because there is already token with name test
+ // Not created because there is already a token with the name "test"
expect(screen.queryByText('users.tokens.new_token_created.test')).not.toBeInTheDocument();
- expect(getTokensList()).toHaveLength(3);
+ expect(getTokensList()).toHaveLength(3); // header + 2 mocked tokens
expect(ui.sureButton.query()).not.toBeInTheDocument();
await user.click(ui.revokeButton('test').get());
expect(await ui.sureButton.find()).toBeInTheDocument();
await user.click(ui.sureButton.get());
- expect(getTokensList()).toHaveLength(2);
+ expect(getTokensList()).toHaveLength(2); // header + "local-scanner" token
+ expect(screen.queryByText('users.no_tokens')).not.toBeInTheDocument();
+ expect(ui.sureButton.query()).not.toBeInTheDocument();
+ await user.click(ui.revokeButton('local-scanner').get());
+ expect(await ui.sureButton.find()).toBeInTheDocument();
+ await user.click(ui.sureButton.get());
+
+ expect(getTokensList()).toHaveLength(2); // header + "No tokens"
+ expect(await screen.findByText('users.no_tokens')).toBeInTheDocument();
+
+ await selectEvent.select(ui.expiresInSelector.get(), 'users.tokens.expiration.0');
await user.click(ui.generateButton.get());
- expect(getTokensList()).toHaveLength(3);
+ expect(getTokensList()).toHaveLength(2); // header + "test" token
+ expect(screen.queryByText('users.no_tokens')).not.toBeInTheDocument();
expect(await screen.findByText('users.tokens.new_token_created.test')).toBeInTheDocument();
- await user.click(ui.doneButton.get());
+ await user.click(ui.closeButton.get());
expect(ui.dialogTokens.query()).not.toBeInTheDocument();
});
await user.click(ui.cancelButton.get());
// user tokens dialog should be accessible
- user.click(
+ await user.click(
await ui.aliceRow
.byRole('button', {
name: 'users.update_tokens_for_x.Alice Merveille',
})
.find(),
);
+
expect(await ui.dialogTokens.find()).toBeInTheDocument();
await expect(await ui.dialogTokens.find()).toHaveNoA11yViolations();
- await user.click(ui.doneButton.get());
+ await user.click(ui.closeButton.get());
// user password dialog should be accessible
await user.click(await ui.aliceUpdateButton.find());
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+
+import {
+ ButtonPrimary,
+ ContentCell,
+ GreySeparator,
+ InputField,
+ InputSelect,
+ Table,
+ TableRow,
+} from 'design-system';
import { isEmpty } from 'lodash';
import * as React from 'react';
import { getScannableProjects } from '../../../api/components';
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
-import Select, { LabelValueSelectOption } from '../../../components/controls/Select';
-import { SubmitButton } from '../../../components/controls/buttons';
+import { LabelValueSelectOption } from '../../../components/controls/Select';
import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import {
import TokensFormNewToken from './TokensFormNewToken';
interface Props {
+ currentUser: CurrentUser;
deleteConfirmation: TokenDeleteConfirmation;
- login: string;
displayTokenTypeInput: boolean;
- currentUser: CurrentUser;
+ login: string;
}
-export function TokensForm(props: Props) {
+const COLUMN_WIDTHS = ['auto', 'auto', 'auto', 'auto', 'auto', 'auto', '5%'];
+
+export function TokensForm(props: Readonly<Props>) {
const { currentUser, deleteConfirmation, displayTokenTypeInput, login } = props;
const { data: tokens, isLoading: loading } = useUserTokensQuery(login);
const [newToken, setNewToken] = React.useState<{ name: string; token: string }>();
const [newTokenType, setNewTokenType] = React.useState<TokenType>();
const [projects, setProjects] = React.useState<LabelValueSelectOption[]>([]);
const [selectedProject, setSelectedProject] = React.useState<LabelValueSelectOption>();
+
const [newTokenExpiration, setNewTokenExpiration] = React.useState<TokenExpiration>(
TokenExpiration.OneMonth,
);
+
const [tokenExpirationOptions, setTokenExpirationOptions] =
React.useState<{ value: TokenExpiration; label: string }[]>(EXPIRATION_OPTIONS);
value: TokenType.Global,
});
}
+
if (!isEmpty(projects)) {
value.unshift({
label: translate('users.tokens', TokenType.Project),
label: project.name,
value: project.key,
}));
+
setProjects(projects);
+
setSelectedProject(projects.length === 1 ? projects[0] : undefined);
})
.catch(() => {});
event.preventDefault();
generate({
- name: newTokenName,
login,
+ name: newTokenName,
type: newTokenType,
...(newTokenType === TokenType.Project &&
selectedProject !== undefined && {
if (generating || newTokenName.length <= 0) {
return true;
}
+
if (newTokenType === TokenType.Project) {
return !selectedProject?.value;
}
setNewTokenName(evt.currentTarget.value);
};
- const handleNewTokenTypeChange = ({ value }: { value: TokenType }) => {
- setNewTokenType(value);
+ const handleNewTokenTypeChange = (newTokenType: { value: unknown } | null) => {
+ setNewTokenType(newTokenType?.value as TokenType);
};
const handleProjectChange = (selectedProject: LabelValueSelectOption) => {
setSelectedProject(selectedProject);
};
- const handleNewTokenExpirationChange = ({ value }: { value: TokenExpiration }) => {
- setNewTokenExpiration(value);
+ const handleNewTokenExpirationChange = (newTokenExpiration: { value: unknown } | null) => {
+ setNewTokenExpiration(newTokenExpiration?.value as TokenExpiration);
};
const customSpinner = (
</tr>
);
+ const tableHeader = (
+ <TableRow>
+ <ContentCell>{translate('name')}</ContentCell>
+ <ContentCell>{translate('my_account.token_type')}</ContentCell>
+ <ContentCell>{translate('my_account.project_name')}</ContentCell>
+ <ContentCell>{translate('my_account.tokens_last_usage')}</ContentCell>
+ <ContentCell>{translate('created')}</ContentCell>
+ <ContentCell>{translate('my_account.tokens.expiration')}</ContentCell>
+ </TableRow>
+ );
+
return (
<>
- <h3 className="spacer-bottom">{translate('users.tokens.generate')}</h3>
- <form autoComplete="off" className="display-flex-center" onSubmit={handleGenerateToken}>
- <div className="display-flex-column input-large spacer-right ">
- <label htmlFor="token-name" className="text-bold">
+ <h3 className="sw-mb-2">{translate('users.tokens.generate')}</h3>
+
+ <form autoComplete="off" className="sw-flex sw-items-center" onSubmit={handleGenerateToken}>
+ <div className="sw-flex sw-flex-col sw-mr-2">
+ <label htmlFor="token-name" className="sw-font-bold">
{translate('users.tokens.name')}
</label>
- <input
+
+ <InputField
+ className="sw-mt-2 sw-w-auto it__token-name"
id="token-name"
- className="spacer-top it__token-name"
maxLength={100}
onChange={handleNewTokenChange}
placeholder={translate('users.tokens.enter_name')}
value={newTokenName}
/>
</div>
+
{displayTokenTypeInput && (
<>
- <div className="display-flex-column input-large spacer-right">
- <label htmlFor="token-select-type" className="text-bold">
+ <div className="sw-flex sw-flex-col sw-mr-2">
+ <label htmlFor="token-select-type" className="sw-font-bold">
{translate('users.tokens.type')}
</label>
- <Select
+
+ <InputSelect
+ className="sw-mt-2 it__token-type"
inputId="token-select-type"
- className="spacer-top it__token-type"
isSearchable={false}
onChange={handleNewTokenTypeChange}
options={tokenTypeOptions}
}
/>
</div>
+
{newTokenType === TokenType.Project && (
- <div className="input-large spacer-right display-flex-column">
- <label htmlFor="token-select-project" className="text-bold">
+ <div className="sw-flex sw-flex-col sw-mr-2">
+ <label htmlFor="token-select-project" className="sw-font-bold">
{translate('users.tokens.project')}
</label>
- <Select
+
+ <InputSelect
+ className="sw-mt-2 it__project"
inputId="token-select-project"
- className="spacer-top it__project"
onChange={handleProjectChange}
options={projects}
placeholder={translate('users.tokens.select_project')}
)}
</>
)}
- <div className="display-flex-column input-medium spacer-right ">
- <label htmlFor="token-select-expiration" className="text-bold">
+
+ <div className="sw-flex sw-flex-col sw-mr-2">
+ <label htmlFor="token-select-expiration" className="sw-font-bold">
{translate('users.tokens.expires_in')}
</label>
- <Select
+
+ <InputSelect
+ className="sw-mt-2"
inputId="token-select-expiration"
- className="spacer-top"
isSearchable={false}
onChange={handleNewTokenExpirationChange}
options={tokenExpirationOptions}
value={tokenExpirationOptions.find((option) => option.value === newTokenExpiration)}
/>
</div>
- <SubmitButton
+
+ <ButtonPrimary
className="it__generate-token"
- style={{ marginTop: 'auto' }}
disabled={isSubmitButtonDisabled()}
+ style={{ marginTop: 'auto' }}
+ type="submit"
>
{translate('users.generate')}
- </SubmitButton>
+ </ButtonPrimary>
</form>
+
{newToken && <TokensFormNewToken token={newToken} />}
- <table className="data zebra big-spacer-top fixed">
- <thead>
- <tr>
- <th>{translate('name')}</th>
- <th>{translate('my_account.token_type')}</th>
- <th>{translate('my_account.project_name')}</th>
- <th>{translate('my_account.tokens_last_usage')}</th>
- <th className="text-right">{translate('created')}</th>
- <th className="text-right">{translate('my_account.tokens.expiration')}</th>
- <th className="text-right">{translate('actions')}</th>
- </tr>
- </thead>
- <tbody>
- <Spinner customSpinner={customSpinner} loading={!!loading}>
- {tokens && tokens.length <= 0 ? (
- <tr>
- <td className="note" colSpan={7}>
- {translate('users.no_tokens')}
- </td>
- </tr>
- ) : (
- tokens?.map((token) => (
- <TokensFormItem
- deleteConfirmation={deleteConfirmation}
- key={token.name}
- login={login}
- token={token}
- />
- ))
- )}
- </Spinner>
- </tbody>
- </table>
+ <GreySeparator className="sw-mb-4 sw-mt-6" />
+
+ <Table
+ className="sw-min-h-40 sw-w-full"
+ columnCount={COLUMN_WIDTHS.length}
+ columnWidths={COLUMN_WIDTHS}
+ header={tableHeader}
+ noHeaderTopBorder
+ >
+ <Spinner customSpinner={customSpinner} loading={!!loading}>
+ {tokens && tokens.length <= 0 ? (
+ <TableRow>
+ <ContentCell className="sw-body-lg" colSpan={7}>
+ {translate('users.no_tokens')}
+ </ContentCell>
+ </TableRow>
+ ) : (
+ tokens?.map((token) => (
+ <TokensFormItem
+ deleteConfirmation={deleteConfirmation}
+ key={token.name}
+ login={login}
+ token={token}
+ />
+ ))
+ )}
+ </Spinner>
+ </Table>
</>
);
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+
+import styled from '@emotion/styled';
import classNames from 'classnames';
+import {
+ ContentCell,
+ DangerButtonSecondary,
+ FlagWarningIcon,
+ TableRow,
+ themeColor,
+} from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import ConfirmButton from '../../../components/controls/ConfirmButton';
-import { Button } from '../../../components/controls/buttons';
-import WarningIcon from '../../../components/icons/WarningIcon';
import DateFormatter from '../../../components/intl/DateFormatter';
import DateFromNow from '../../../components/intl/DateFromNow';
import Spinner from '../../../components/ui/Spinner';
token: UserToken;
}
-export default function TokensFormItem(props: Props) {
+export default function TokensFormItem(props: Readonly<Props>) {
const { token, deleteConfirmation, login } = props;
const [showConfirmation, setShowConfirmation] = React.useState(false);
const { mutateAsync, isLoading } = useRevokeTokenMutation();
}
};
+ const className = classNames('sw-mr-2', {
+ 'sw-text-gray-400': token.isExpired,
+ });
+
return (
- <tr className={classNames({ 'text-muted-2': token.isExpired })}>
- <td title={token.name} className="hide-overflow nowrap">
- {token.name}
- {token.isExpired && (
- <div className="spacer-top text-warning">
- <WarningIcon className="little-spacer-right" />
- {translate('my_account.tokens.expired')}
- </div>
- )}
- </td>
- <td title={translate('users.tokens', token.type)} className="hide-overflow thin">
+ <TableRow>
+ <ContentCell
+ className={classNames('sw-flex-col sw-items-center sw-w-64', className)}
+ title={token.name}
+ >
+ <div className="sw-w-full sw-truncate">
+ {token.name}
+
+ {token.isExpired && (
+ <StyledSpan tokenIsExpired>
+ <div className="sw-mt-1">
+ <FlagWarningIcon className="sw-mr-1" />
+
+ {translate('my_account.tokens.expired')}
+ </div>
+ </StyledSpan>
+ )}
+ </div>
+ </ContentCell>
+
+ <ContentCell className={className} title={translate('users.tokens', token.type)}>
{translate('users.tokens', token.type, 'short')}
- </td>
- <td title={token.project?.name} className="hide-overflow">
- {token.project?.name}
- </td>
- <td className="thin nowrap">
+ </ContentCell>
+
+ <ContentCell className={classNames('sw-w-32', className)} title={token.project?.name}>
+ <div className="sw-w-full sw-truncate">{token.project?.name}</div>
+ </ContentCell>
+
+ <ContentCell className={className}>
<DateFromNow date={token.lastConnectionDate} hourPrecision />
- </td>
- <td className="thin nowrap text-right">
+ </ContentCell>
+
+ <ContentCell className={className}>
<DateFormatter date={token.createdAt} long />
- </td>
- <td className={classNames('thin nowrap text-right', { 'text-warning': token.isExpired })}>
- {token.expirationDate ? <DateFormatter date={token.expirationDate} long /> : '–'}
- </td>
- <td className="thin nowrap text-right">
+ </ContentCell>
+
+ <ContentCell className={className}>
+ {token.expirationDate ? (
+ <StyledSpan tokenIsExpired={token.isExpired}>
+ <DateFormatter date={token.expirationDate} long />
+ </StyledSpan>
+ ) : (
+ '–'
+ )}
+ </ContentCell>
+
+ <ContentCell>
{token.isExpired && (
- <Button
- className="button-red input-small"
+ <DangerButtonSecondary
disabled={isLoading}
onClick={handleRevoke}
aria-label={translateWithParameters('users.tokens.remove_label', token.name)}
>
- <Spinner className="little-spacer-right" loading={isLoading}>
+ <Spinner className="sw-mr-1" loading={isLoading}>
{translate('remove')}
</Spinner>
- </Button>
+ </DangerButtonSecondary>
)}
+
{!token.isExpired && deleteConfirmation === 'modal' && (
<ConfirmButton
confirmButtonText={translate('yes')}
onConfirm={handleRevoke}
>
{({ onClick }) => (
- <Button
- className="button-red input-small"
+ <DangerButtonSecondary
disabled={isLoading}
onClick={onClick}
aria-label={translateWithParameters('users.tokens.revoke_label', token.name)}
>
{translate('users.tokens.revoke')}
- </Button>
+ </DangerButtonSecondary>
)}
</ConfirmButton>
)}
+
{!token.isExpired && deleteConfirmation === 'inline' && (
- <Button
- className="button-red input-small"
- disabled={isLoading}
+ <DangerButtonSecondary
aria-label={
showConfirmation
? translate('users.tokens.sure')
: translateWithParameters('users.tokens.revoke_label', token.name)
}
+ disabled={isLoading}
onClick={handleClick}
>
- <Spinner className="little-spacer-right" loading={isLoading} />
+ <Spinner className="sw-mr-1" loading={isLoading} />
+
{showConfirmation ? translate('users.tokens.sure') : translate('users.tokens.revoke')}
- </Button>
+ </DangerButtonSecondary>
)}
- </td>
- </tr>
+ </ContentCell>
+ </TableRow>
);
}
+
+const StyledSpan = styled.span<{
+ tokenIsExpired?: boolean;
+}>`
+ color: ${({ tokenIsExpired }) =>
+ tokenIsExpired ? themeColor('iconWarning') : themeColor('pageContent')};
+`;
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+
+import { Modal } from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
-import Modal from '../../../components/controls/Modal';
-import { ResetButtonLink } from '../../../components/controls/buttons';
import { translate } from '../../../helpers/l10n';
import { RestUserDetailed } from '../../../types/users';
import TokensForm from './TokensForm';
onClose: () => void;
}
-export default function TokensFormModal(props: Props) {
+export default function TokensFormModal(props: Readonly<Props>) {
const { user } = props;
return (
- <Modal size="large" contentLabel={translate('users.tokens')} onRequestClose={props.onClose}>
- <header className="modal-head">
- <h2>
- <FormattedMessage
- defaultMessage={translate('users.user_X_tokens')}
- id="users.user_X_tokens"
- values={{ user: <em>{user.name}</em> }}
- />
- </h2>
- </header>
- <div className="modal-body modal-container">
- <TokensForm deleteConfirmation="inline" login={user.login} displayTokenTypeInput={false} />
- </div>
- <footer className="modal-foot">
- <ResetButtonLink onClick={props.onClose}>{translate('done')}</ResetButtonLink>
- </footer>
- </Modal>
+ <Modal
+ body={
+ <TokensForm deleteConfirmation="inline" displayTokenTypeInput={false} login={user.login} />
+ }
+ headerTitle={
+ <FormattedMessage
+ defaultMessage={translate('users.user_X_tokens')}
+ id="users.user_X_tokens"
+ values={{ user: <em>{user.name}</em> }}
+ />
+ }
+ isLarge
+ onClose={props.onClose}
+ />
);
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+
+import { ClipboardIconButton, CodeSnippet, FlagMessage } from 'design-system';
import * as React from 'react';
-import { ClipboardButton } from '../../../components/controls/clipboard';
-import { Alert } from '../../../components/ui/Alert';
import { translate, translateWithParameters } from '../../../helpers/l10n';
interface Props {
token: { name: string; token: string };
}
-export default function TokensFormNewToken({ token }: Props) {
+export default function TokensFormNewToken({ token }: Readonly<Props>) {
return (
- <div className="panel panel-white big-spacer-top">
- <Alert variant="warning">
+ <div className="sw-mt-4">
+ <FlagMessage variant="success">
{translateWithParameters('users.tokens.new_token_created', token.name)}
- </Alert>
- <ClipboardButton copyValue={token.token} />
- <code aria-label={translate('users.new_token')} className="big-spacer-left text-success">
- {token.token}
- </code>
+ </FlagMessage>
+
+ <div aria-label={translate('users.new_token')} className="sw-flex sw-items-center sw-mt-3">
+ <CodeSnippet className="sw-p-1" isOneLine noCopy snippet={token.token} />
+
+ <ClipboardIconButton className="sw-ml-4" copyValue={token.token} />
+ </div>
</div>
);
}