mirror of https://github.com/portainer/portainer
fix(api-key): add password requirement to generate api key [EE-6140] (#10617)
parent
236e669332
commit
dbd2e609d7
|
@ -15,18 +15,22 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type userAccessTokenCreatePayload struct {
|
type userAccessTokenCreatePayload struct {
|
||||||
|
Password string `validate:"required" example:"password" json:"password"`
|
||||||
Description string `validate:"required" example:"github-api-key" json:"description"`
|
Description string `validate:"required" example:"github-api-key" json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
||||||
|
if govalidator.IsNull(payload.Password) {
|
||||||
|
return errors.New("invalid password: cannot be empty")
|
||||||
|
}
|
||||||
if govalidator.IsNull(payload.Description) {
|
if govalidator.IsNull(payload.Description) {
|
||||||
return errors.New("invalid description. cannot be empty")
|
return errors.New("invalid description: cannot be empty")
|
||||||
}
|
}
|
||||||
if govalidator.HasWhitespaceOnly(payload.Description) {
|
if govalidator.HasWhitespaceOnly(payload.Description) {
|
||||||
return errors.New("invalid description. cannot contain only whitespaces")
|
return errors.New("invalid description: cannot contain only whitespaces")
|
||||||
}
|
}
|
||||||
if govalidator.MinStringLength(payload.Description, "128") {
|
if govalidator.MinStringLength(payload.Description, "128") {
|
||||||
return errors.New("invalid description. cannot be longer than 128 characters")
|
return errors.New("invalid description: cannot be longer than 128 characters")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -82,7 +86,12 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
|
||||||
|
|
||||||
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
user, err := handler.DataStore.User().Read(portainer.UserID(userID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return httperror.BadRequest("Unable to find a user", err)
|
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||||
|
if err != nil {
|
||||||
|
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
|
||||||
}
|
}
|
||||||
|
|
||||||
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
||||||
|
|
|
@ -25,7 +25,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||||
_, store := datastore.MustNewTestStore(t, true, true)
|
_, store := datastore.MustNewTestStore(t, true, true)
|
||||||
|
|
||||||
// create admin and standard user(s)
|
// create admin and standard user(s)
|
||||||
adminUser := &portainer.User{ID: 1, Username: "admin", Role: portainer.AdministratorRole}
|
adminUser := &portainer.User{ID: 1, Password: "password", Username: "admin", Role: portainer.AdministratorRole}
|
||||||
err := store.User().Create(adminUser)
|
err := store.User().Create(adminUser)
|
||||||
is.NoError(err, "error creating admin user")
|
is.NoError(err, "error creating admin user")
|
||||||
|
|
||||||
|
@ -43,13 +43,14 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||||
|
|
||||||
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
h := NewHandler(requestBouncer, rateLimiter, apiKeyService, nil, passwordChecker)
|
||||||
h.DataStore = store
|
h.DataStore = store
|
||||||
|
h.CryptoService = testhelpers.NewCryptoService()
|
||||||
|
|
||||||
// generate standard and admin user tokens
|
// generate standard and admin user tokens
|
||||||
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
adminJWT, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: adminUser.ID, Username: adminUser.Username, Role: adminUser.Role})
|
||||||
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
jwt, _, _ := jwtService.GenerateToken(&portainer.TokenData{ID: user.ID, Username: user.Username, Role: user.Role})
|
||||||
|
|
||||||
t.Run("standard user successfully generates API key", func(t *testing.T) {
|
t.Run("standard user successfully generates API key", func(t *testing.T) {
|
||||||
data := userAccessTokenCreatePayload{Description: "test-token"}
|
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token"}
|
||||||
payload, err := json.Marshal(data)
|
payload, err := json.Marshal(data)
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
||||||
|
@ -72,7 +73,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("admin cannot generate API key for standard user", func(t *testing.T) {
|
t.Run("admin cannot generate API key for standard user", func(t *testing.T) {
|
||||||
data := userAccessTokenCreatePayload{Description: "test-token-admin"}
|
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"}
|
||||||
payload, err := json.Marshal(data)
|
payload, err := json.Marshal(data)
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
||||||
|
@ -92,7 +93,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||||
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
|
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key")
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
||||||
data := userAccessTokenCreatePayload{Description: "test-token-fails"}
|
data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"}
|
||||||
payload, err := json.Marshal(data)
|
payload, err := json.Marshal(data)
|
||||||
is.NoError(err)
|
is.NoError(err)
|
||||||
|
|
||||||
|
@ -118,23 +119,23 @@ func Test_userAccessTokenCreatePayload(t *testing.T) {
|
||||||
shouldFail bool
|
shouldFail bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
payload: userAccessTokenCreatePayload{Description: "test-token"},
|
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"},
|
||||||
shouldFail: false,
|
shouldFail: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
payload: userAccessTokenCreatePayload{Description: ""},
|
payload: userAccessTokenCreatePayload{Password: "password", Description: ""},
|
||||||
shouldFail: true,
|
shouldFail: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
payload: userAccessTokenCreatePayload{Description: "test token"},
|
payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"},
|
||||||
shouldFail: false,
|
shouldFail: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
payload: userAccessTokenCreatePayload{Description: "test-token "},
|
payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "},
|
||||||
shouldFail: false,
|
shouldFail: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
payload: userAccessTokenCreatePayload{Description: `
|
payload: userAccessTokenCreatePayload{Password: "password", Description: `
|
||||||
this string is longer than 128 characters and hence this will fail.
|
this string is longer than 128 characters and hence this will fail.
|
||||||
this string is longer than 128 characters and hence this will fail.
|
this string is longer than 128 characters and hence this will fail.
|
||||||
this string is longer than 128 characters and hence this will fail.
|
this string is longer than 128 characters and hence this will fail.
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
package testhelpers
|
||||||
|
|
||||||
|
// Service represents a service for encrypting/hashing data.
|
||||||
|
type cryptoService struct{}
|
||||||
|
|
||||||
|
func NewCryptoService() *cryptoService {
|
||||||
|
return &cryptoService{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*cryptoService) Hash(data string) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*cryptoService) CompareHashAndData(hash string, data string) error {
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -5,9 +5,8 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { r2a } from '@/react-tools/react2angular';
|
import { r2a } from '@/react-tools/react2angular';
|
||||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
import { CreateAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
|
import { CreateUserAccessToken } from '@/react/portainer/account/CreateAccessTokenView';
|
||||||
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
|
import { EdgeComputeSettingsView } from '@/react/portainer/settings/EdgeComputeView/EdgeComputeSettingsView';
|
||||||
import { withI18nSuspense } from '@/react-tools/withI18nSuspense';
|
|
||||||
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
|
import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView';
|
||||||
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
|
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
|
||||||
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
|
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
|
||||||
|
@ -36,11 +35,11 @@ export const viewsModule = angular
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
'createAccessToken',
|
'createUserAccessToken',
|
||||||
r2a(withI18nSuspense(withUIRouter(CreateAccessToken)), [
|
r2a(
|
||||||
'onSubmit',
|
withReactQuery(withCurrentUser(withUIRouter(CreateUserAccessToken))),
|
||||||
'onError',
|
[]
|
||||||
])
|
)
|
||||||
)
|
)
|
||||||
.component(
|
.component(
|
||||||
'settingsEdgeCompute',
|
'settingsEdgeCompute',
|
||||||
|
|
|
@ -17,7 +17,6 @@ angular.module('portainer.app').factory('Users', [
|
||||||
queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } },
|
queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } },
|
||||||
checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true, ignoreLoadingBar: true },
|
checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true, ignoreLoadingBar: true },
|
||||||
initAdminUser: { method: 'POST', params: { id: 'admin', entity: 'init' }, ignoreLoadingBar: true },
|
initAdminUser: { method: 'POST', params: { id: 'admin', entity: 'init' }, ignoreLoadingBar: true },
|
||||||
createAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens`, method: 'POST', params: { id: '@id' }, ignoreLoadingBar: true },
|
|
||||||
getAccessTokens: { method: 'GET', params: { id: '@id', entity: 'tokens' }, isArray: true },
|
getAccessTokens: { method: 'GET', params: { id: '@id', entity: 'tokens' }, isArray: true },
|
||||||
deleteAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens/:tokenId`, method: 'DELETE', params: { id: '@id', entityId: '@tokenId' } },
|
deleteAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens/:tokenId`, method: 'DELETE', params: { id: '@id', entityId: '@tokenId' } },
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,19 +112,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createAccessToken = function (id, description) {
|
|
||||||
const deferred = $q.defer();
|
|
||||||
const payload = { description };
|
|
||||||
Users.createAccessToken({ id }, payload)
|
|
||||||
.$promise.then((data) => {
|
|
||||||
deferred.resolve(data);
|
|
||||||
})
|
|
||||||
.catch(function error(err) {
|
|
||||||
deferred.reject({ msg: 'Unable to create user', err: err });
|
|
||||||
});
|
|
||||||
return deferred.promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
service.getAccessTokens = function (id) {
|
service.getAccessTokens = function (id) {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
|
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
export default class CreateUserAccessTokenController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async, $analytics, Authentication, UserService, Notifications) {
|
|
||||||
this.$async = $async;
|
|
||||||
this.$analytics = $analytics;
|
|
||||||
this.Authentication = Authentication;
|
|
||||||
this.UserService = UserService;
|
|
||||||
this.Notifications = Notifications;
|
|
||||||
|
|
||||||
this.onSubmit = this.onSubmit.bind(this);
|
|
||||||
this.onError = this.onError.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onSubmit(description) {
|
|
||||||
const accessToken = await this.UserService.createAccessToken(this.state.userId, description);
|
|
||||||
// Dispatch analytics event upon success accessToken generation
|
|
||||||
this.$analytics.eventTrack('portainer-account-access-token-create', { category: 'portainer' });
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
onError(heading, error, message) {
|
|
||||||
this.Notifications.error(heading, error, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
return this.$async(async () => {
|
|
||||||
const userId = this.Authentication.getUserDetails().ID;
|
|
||||||
this.state = {
|
|
||||||
userId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,8 +0,0 @@
|
||||||
<page-header title="'Create access token'" breadcrumbs="[{label:'User settings', link:'portainer.account'}, 'Add access token']" reload="true"> </page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<!-- mount react feature/view -->
|
|
||||||
<create-access-token user-id="$ctrl.state.userId" on-submit="($ctrl.onSubmit)" on-success="($ctrl.onSuccess)" on-error="($ctrl.onError)"></create-access-token>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,7 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
import controller from './create-user-access-token.controller';
|
|
||||||
|
|
||||||
angular.module('portainer.app').component('createUserAccessToken', {
|
|
||||||
templateUrl: './create-user-access-token.html',
|
|
||||||
controller,
|
|
||||||
});
|
|
|
@ -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…
Reference in New Issue