mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 20:35:25 +02:00
fix(api-key): add password requirement to generate api key [EE-6140] (#10617)
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Some checks are pending
ci / build_images (map[arch:amd64 platform:linux]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
This commit is contained in:
parent
236e669332
commit
dbd2e609d7
20 changed files with 305 additions and 251 deletions
|
@ -1,54 +0,0 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { render } from '@/react-tools/test-utils';
|
||||
|
||||
import { CreateAccessToken } from './CreateAccessToken';
|
||||
|
||||
test('the button is disabled when description is missing and enabled when description is filled', async () => {
|
||||
const queries = renderComponent();
|
||||
|
||||
const button = queries.getByRole('button', { name: 'Add access token' });
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const descriptionField = queries.getByLabelText('Description');
|
||||
|
||||
userEvent.type(descriptionField, 'description');
|
||||
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
userEvent.clear(descriptionField);
|
||||
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
test('once the button is clicked, the access token is generated and displayed', async () => {
|
||||
const token = 'a very long access token that should be displayed';
|
||||
const onSubmit = jest.fn(() => Promise.resolve({ rawAPIKey: token }));
|
||||
|
||||
const queries = renderComponent(onSubmit);
|
||||
|
||||
const descriptionField = queries.getByLabelText('Description');
|
||||
|
||||
userEvent.type(descriptionField, 'description');
|
||||
|
||||
const button = queries.getByRole('button', { name: 'Add access token' });
|
||||
|
||||
userEvent.click(button);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith('description');
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(queries.findByText('New access token')).resolves.toBeVisible();
|
||||
expect(queries.getByText(token)).toHaveTextContent(token);
|
||||
});
|
||||
|
||||
function renderComponent(onSubmit = jest.fn()) {
|
||||
const queries = render(
|
||||
<CreateAccessToken onSubmit={onSubmit} onError={jest.fn()} />
|
||||
);
|
||||
|
||||
expect(queries.getByLabelText('Description')).toBeVisible();
|
||||
|
||||
return queries;
|
||||
}
|
|
@ -1,114 +0,0 @@
|
|||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Code } from '@@/Code';
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
|
||||
interface AccessTokenResponse {
|
||||
rawAPIKey: string;
|
||||
}
|
||||
|
||||
export interface Props {
|
||||
// onSubmit dispatches a successful matomo analytics event
|
||||
onSubmit: (description: string) => Promise<AccessTokenResponse>;
|
||||
|
||||
// onError is called when an error occurs; this is a callback to Notifications.error
|
||||
onError: (heading: string, err: unknown, message: string) => void;
|
||||
}
|
||||
|
||||
export function CreateAccessToken({
|
||||
onSubmit,
|
||||
onError,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const translationNS = 'account.accessTokens.create';
|
||||
const { t } = useTranslation(translationNS);
|
||||
|
||||
const router = useRouter();
|
||||
const [description, setDescription] = useState('');
|
||||
const [errorText, setErrorText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (description.length === 0) {
|
||||
setErrorText(t('this field is required'));
|
||||
} else setErrorText('');
|
||||
}, [description, t]);
|
||||
|
||||
async function generateAccessToken() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await onSubmit(description);
|
||||
setAccessToken(response.rawAPIKey);
|
||||
} catch (err) {
|
||||
onError('Failure', err, 'Failed to generate access token');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<div className="form-horizontal">
|
||||
<FormControl
|
||||
inputId="input"
|
||||
label={t('Description')}
|
||||
errors={errorText}
|
||||
>
|
||||
<Input
|
||||
id="input"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
value={description}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="row mt-5">
|
||||
<Button
|
||||
disabled={!!errorText || !!accessToken}
|
||||
onClick={() => generateAccessToken()}
|
||||
>
|
||||
{t('Add access token')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{accessToken && (
|
||||
<div className="mt-5">
|
||||
<FormSectionTitle>
|
||||
<Trans ns={translationNS}>New access token</Trans>
|
||||
</FormSectionTitle>
|
||||
<TextTip>
|
||||
<Trans ns={translationNS}>
|
||||
Please copy the new access token. You won't be able to view
|
||||
the token again.
|
||||
</Trans>
|
||||
</TextTip>
|
||||
<Code>{accessToken}</Code>
|
||||
<div className="mt-2">
|
||||
<CopyButton copyText={accessToken}>
|
||||
<Trans ns={translationNS}>Copy access token</Trans>
|
||||
</CopyButton>
|
||||
</div>
|
||||
<hr />
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.stateService.go('portainer.account')}
|
||||
>
|
||||
<Trans ns={translationNS}>Done</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { SchemaOf, object, string } from 'yup';
|
||||
|
||||
import { ApiKeyFormValues } from './types';
|
||||
|
||||
export function getAPITokenValidationSchema(): SchemaOf<ApiKeyFormValues> {
|
||||
return object({
|
||||
password: string().required('Password is required.'),
|
||||
description: string()
|
||||
.max(128, 'Description must be at most 128 characters')
|
||||
.required('Description is required'),
|
||||
});
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { renderWithQueryClient, waitFor } from '@/react-tools/test-utils';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { UserContext } from '@/react/hooks/useUser';
|
||||
|
||||
import { CreateUserAccessToken } from './CreateUserAccessToken';
|
||||
|
||||
test('the button is disabled when all fields are blank and enabled when all fields are filled', async () => {
|
||||
const { getByRole, getByLabelText } = renderComponent();
|
||||
|
||||
const button = getByRole('button', { name: 'Add access token' });
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
const descriptionField = getByLabelText(/Description/);
|
||||
const passwordField = getByLabelText(/Current password/);
|
||||
|
||||
userEvent.type(passwordField, 'password');
|
||||
userEvent.type(descriptionField, 'description');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
userEvent.clear(descriptionField);
|
||||
await waitFor(() => {
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
return renderWithQueryClient(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<CreateUserAccessToken />
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { ApiKeyFormValues } from './types';
|
||||
import { getAPITokenValidationSchema } from './CreateUserAcccessToken.validation';
|
||||
import { useCreateUserAccessTokenMutation } from './useCreateUserAccessTokenMutation';
|
||||
import { CreateUserAccessTokenInnerForm } from './CreateUserAccessTokenInnerForm';
|
||||
import { DisplayUserAccessToken } from './DisplayUserAccessToken';
|
||||
|
||||
const initialValues: ApiKeyFormValues = {
|
||||
password: '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export function CreateUserAccessToken() {
|
||||
const mutation = useCreateUserAccessTokenMutation();
|
||||
const { user } = useCurrentUser();
|
||||
const [newAPIToken, setNewAPIToken] = useState('');
|
||||
const { trackEvent } = useAnalytics();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Create access token"
|
||||
breadcrumbs={[
|
||||
{ label: 'My account', link: 'portainer.account' },
|
||||
'Add access token',
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
{newAPIToken === '' ? (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
validationSchema={getAPITokenValidationSchema}
|
||||
>
|
||||
<CreateUserAccessTokenInnerForm />
|
||||
</Formik>
|
||||
) : (
|
||||
DisplayUserAccessToken(newAPIToken)
|
||||
)}
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
async function onSubmit(values: ApiKeyFormValues) {
|
||||
mutation.mutate(
|
||||
{ values, userid: user.Id },
|
||||
{
|
||||
onSuccess(response) {
|
||||
setNewAPIToken(response);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
trackEvent('portainer-account-access-token-create', {
|
||||
category: 'portainer',
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { Field, Form, useFormikContext } from 'formik';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
|
||||
import { ApiKeyFormValues } from './types';
|
||||
|
||||
export function CreateUserAccessTokenInnerForm() {
|
||||
const { errors, values, handleSubmit, isValid, dirty } =
|
||||
useFormikContext<ApiKeyFormValues>();
|
||||
|
||||
return (
|
||||
<Form
|
||||
className="form-horizontal"
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
<FormControl
|
||||
inputId="password"
|
||||
label="Current password"
|
||||
required
|
||||
errors={errors.password}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={values.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
inputId="description"
|
||||
label="Description"
|
||||
required
|
||||
errors={errors.description}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="description"
|
||||
name="description"
|
||||
value={values.description}
|
||||
/>
|
||||
</FormControl>
|
||||
<LoadingButton
|
||||
disabled={!isValid || !dirty}
|
||||
isLoading={false}
|
||||
loadingText="Adding access token..."
|
||||
>
|
||||
Add access token
|
||||
</LoadingButton>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { Button, CopyButton } from '@@/buttons';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
export function DisplayUserAccessToken(apikey: string) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>New access token</FormSectionTitle>
|
||||
<TextTip>
|
||||
Please copy the new access token. You won't be able to view the
|
||||
token again.
|
||||
</TextTip>
|
||||
<div className="pt-5">
|
||||
<div className="inline-flex">
|
||||
<div className="">{apikey}</div>
|
||||
<div>
|
||||
<CopyButton copyText={apikey} color="link" />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => router.stateService.go('portainer.account')}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1 +1 @@
|
|||
export { CreateAccessToken } from './CreateAccessToken';
|
||||
export { CreateUserAccessToken } from './CreateUserAccessToken';
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
export interface ApiKeyFormValues {
|
||||
password: string;
|
||||
description: string;
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
|
||||
import { ApiKeyFormValues } from './types';
|
||||
|
||||
export interface ApiKeyResponse {
|
||||
rawAPIKey: string;
|
||||
}
|
||||
|
||||
export function useCreateUserAccessTokenMutation() {
|
||||
return useMutation(createUserAccessToken, {
|
||||
...withError('Unable to create access token'),
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Access token successfully created');
|
||||
// TODO: invalidate query when user page migrated to react.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createUserAccessToken({
|
||||
values,
|
||||
userid,
|
||||
}: {
|
||||
values: ApiKeyFormValues;
|
||||
userid: number;
|
||||
}) {
|
||||
try {
|
||||
const response = await axios.post<ApiKeyResponse>(
|
||||
`/users/${userid}/tokens`,
|
||||
values
|
||||
);
|
||||
return response.data.rawAPIKey;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e);
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue