From dbd2e609d715c78686fc498951d5b8e3b888e0e3 Mon Sep 17 00:00:00 2001 From: Matt Hook Date: Tue, 9 Jan 2024 11:14:24 +1300 Subject: [PATCH] fix(api-key): add password requirement to generate api key [EE-6140] (#10617) --- .../handler/users/user_create_access_token.go | 17 ++- .../users/user_create_access_token_test.go | 19 +-- api/internal/testhelpers/crypto_service.go | 16 +++ app/portainer/react/views/index.ts | 13 +- app/portainer/rest/user.js | 1 - app/portainer/services/api/userService.js | 13 -- .../create-user-access-token.controller.js | 33 ----- .../create-user-access-token.html | 8 -- .../account/create-user-access-token/index.js | 7 -- .../account/CreateAccessTokenView/.keep | 0 .../CreateAccessToken.test.tsx | 54 --------- .../CreateAccessToken.tsx | 114 ------------------ .../CreateUserAcccessToken.validation.tsx | 12 ++ .../CreateUserAccessToken.test.tsx | 41 +++++++ .../CreateUserAccessToken.tsx | 74 ++++++++++++ .../CreateUserAccessTokenInnerForm.tsx | 56 +++++++++ .../DisplayUserAccessToken.tsx | 33 +++++ .../account/CreateAccessTokenView/index.ts | 2 +- .../account/CreateAccessTokenView/types.ts | 4 + .../useCreateUserAccessTokenMutation.ts | 39 ++++++ 20 files changed, 305 insertions(+), 251 deletions(-) create mode 100644 api/internal/testhelpers/crypto_service.go delete mode 100644 app/portainer/views/account/create-user-access-token/create-user-access-token.controller.js delete mode 100644 app/portainer/views/account/create-user-access-token/create-user-access-token.html delete mode 100644 app/portainer/views/account/create-user-access-token/index.js delete mode 100644 app/react/portainer/account/CreateAccessTokenView/.keep delete mode 100644 app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.test.tsx delete mode 100644 app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.tsx create mode 100644 app/react/portainer/account/CreateAccessTokenView/CreateUserAcccessToken.validation.tsx create mode 100644 app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.test.tsx create mode 100644 app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.tsx create mode 100644 app/react/portainer/account/CreateAccessTokenView/CreateUserAccessTokenInnerForm.tsx create mode 100644 app/react/portainer/account/CreateAccessTokenView/DisplayUserAccessToken.tsx create mode 100644 app/react/portainer/account/CreateAccessTokenView/types.ts create mode 100644 app/react/portainer/account/CreateAccessTokenView/useCreateUserAccessTokenMutation.ts diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go index 546afba62..d27242ca8 100644 --- a/api/http/handler/users/user_create_access_token.go +++ b/api/http/handler/users/user_create_access_token.go @@ -15,18 +15,22 @@ import ( ) type userAccessTokenCreatePayload struct { + Password string `validate:"required" example:"password" json:"password"` Description string `validate:"required" example:"github-api-key" json:"description"` } 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) { - return errors.New("invalid description. cannot be empty") + return errors.New("invalid description: cannot be empty") } 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") { - return errors.New("invalid description. cannot be longer than 128 characters") + return errors.New("invalid description: cannot be longer than 128 characters") } 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)) 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) diff --git a/api/http/handler/users/user_create_access_token_test.go b/api/http/handler/users/user_create_access_token_test.go index 170965786..335f69bd9 100644 --- a/api/http/handler/users/user_create_access_token_test.go +++ b/api/http/handler/users/user_create_access_token_test.go @@ -25,7 +25,7 @@ func Test_userCreateAccessToken(t *testing.T) { _, store := datastore.MustNewTestStore(t, true, true) // 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) 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.DataStore = store + h.CryptoService = testhelpers.NewCryptoService() // generate standard and admin user tokens 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}) 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) 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) { - data := userAccessTokenCreatePayload{Description: "test-token-admin"} + data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-admin"} payload, err := json.Marshal(data) is.NoError(err) @@ -92,7 +93,7 @@ func Test_userCreateAccessToken(t *testing.T) { rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test-api-key") is.NoError(err) - data := userAccessTokenCreatePayload{Description: "test-token-fails"} + data := userAccessTokenCreatePayload{Password: "password", Description: "test-token-fails"} payload, err := json.Marshal(data) is.NoError(err) @@ -118,23 +119,23 @@ func Test_userAccessTokenCreatePayload(t *testing.T) { shouldFail bool }{ { - payload: userAccessTokenCreatePayload{Description: "test-token"}, + payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token"}, shouldFail: false, }, { - payload: userAccessTokenCreatePayload{Description: ""}, + payload: userAccessTokenCreatePayload{Password: "password", Description: ""}, shouldFail: true, }, { - payload: userAccessTokenCreatePayload{Description: "test token"}, + payload: userAccessTokenCreatePayload{Password: "password", Description: "test token"}, shouldFail: false, }, { - payload: userAccessTokenCreatePayload{Description: "test-token "}, + payload: userAccessTokenCreatePayload{Password: "password", Description: "test-token "}, 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. diff --git a/api/internal/testhelpers/crypto_service.go b/api/internal/testhelpers/crypto_service.go new file mode 100644 index 000000000..9187f3fe5 --- /dev/null +++ b/api/internal/testhelpers/crypto_service.go @@ -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 +} diff --git a/app/portainer/react/views/index.ts b/app/portainer/react/views/index.ts index 56657ba48..1f134debf 100644 --- a/app/portainer/react/views/index.ts +++ b/app/portainer/react/views/index.ts @@ -5,9 +5,8 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { r2a } from '@/react-tools/react2angular'; import { withReactQuery } from '@/react-tools/withReactQuery'; 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 { withI18nSuspense } from '@/react-tools/withI18nSuspense'; import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAutoCreateScriptView'; import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView'; import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel'; @@ -36,11 +35,11 @@ export const viewsModule = angular ) ) .component( - 'createAccessToken', - r2a(withI18nSuspense(withUIRouter(CreateAccessToken)), [ - 'onSubmit', - 'onError', - ]) + 'createUserAccessToken', + r2a( + withReactQuery(withCurrentUser(withUIRouter(CreateUserAccessToken))), + [] + ) ) .component( 'settingsEdgeCompute', diff --git a/app/portainer/rest/user.js b/app/portainer/rest/user.js index 0fc6c21ea..9b749c30e 100644 --- a/app/portainer/rest/user.js +++ b/app/portainer/rest/user.js @@ -17,7 +17,6 @@ angular.module('portainer.app').factory('Users', [ queryMemberships: { method: 'GET', isArray: true, params: { id: '@id', entity: 'memberships' } }, checkAdminUser: { method: 'GET', params: { id: 'admin', entity: 'check' }, isArray: true, 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 }, deleteAccessToken: { url: `${API_ENDPOINT_USERS}/:id/tokens/:tokenId`, method: 'DELETE', params: { id: '@id', entityId: '@tokenId' } }, } diff --git a/app/portainer/services/api/userService.js b/app/portainer/services/api/userService.js index c5e945c5b..ae0bfd834 100644 --- a/app/portainer/services/api/userService.js +++ b/app/portainer/services/api/userService.js @@ -112,19 +112,6 @@ export function UserService($q, Users, TeamService, TeamMembershipService) { 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) { var deferred = $q.defer(); diff --git a/app/portainer/views/account/create-user-access-token/create-user-access-token.controller.js b/app/portainer/views/account/create-user-access-token/create-user-access-token.controller.js deleted file mode 100644 index a454e826f..000000000 --- a/app/portainer/views/account/create-user-access-token/create-user-access-token.controller.js +++ /dev/null @@ -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, - }; - }); - } -} diff --git a/app/portainer/views/account/create-user-access-token/create-user-access-token.html b/app/portainer/views/account/create-user-access-token/create-user-access-token.html deleted file mode 100644 index ab9551c46..000000000 --- a/app/portainer/views/account/create-user-access-token/create-user-access-token.html +++ /dev/null @@ -1,8 +0,0 @@ - - -
-
- - -
-
diff --git a/app/portainer/views/account/create-user-access-token/index.js b/app/portainer/views/account/create-user-access-token/index.js deleted file mode 100644 index 310f81090..000000000 --- a/app/portainer/views/account/create-user-access-token/index.js +++ /dev/null @@ -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, -}); diff --git a/app/react/portainer/account/CreateAccessTokenView/.keep b/app/react/portainer/account/CreateAccessTokenView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.test.tsx b/app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.test.tsx deleted file mode 100644 index 01fd7b6e2..000000000 --- a/app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.test.tsx +++ /dev/null @@ -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( - - ); - - expect(queries.getByLabelText('Description')).toBeVisible(); - - return queries; -} diff --git a/app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.tsx b/app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.tsx deleted file mode 100644 index 61a454242..000000000 --- a/app/react/portainer/account/CreateAccessTokenView/CreateAccessToken.tsx +++ /dev/null @@ -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; - - // 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) { - 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 ( - - -
- - setDescription(e.target.value)} - value={description} - /> - -
- -
-
- {accessToken && ( -
- - New access token - - - - Please copy the new access token. You won't be able to view - the token again. - - - {accessToken} -
- - Copy access token - -
-
- -
- )} -
-
- ); -} diff --git a/app/react/portainer/account/CreateAccessTokenView/CreateUserAcccessToken.validation.tsx b/app/react/portainer/account/CreateAccessTokenView/CreateUserAcccessToken.validation.tsx new file mode 100644 index 000000000..9a4c63073 --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/CreateUserAcccessToken.validation.tsx @@ -0,0 +1,12 @@ +import { SchemaOf, object, string } from 'yup'; + +import { ApiKeyFormValues } from './types'; + +export function getAPITokenValidationSchema(): SchemaOf { + return object({ + password: string().required('Password is required.'), + description: string() + .max(128, 'Description must be at most 128 characters') + .required('Description is required'), + }); +} diff --git a/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.test.tsx b/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.test.tsx new file mode 100644 index 000000000..f60090100 --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.test.tsx @@ -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( + + + + ); +} diff --git a/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.tsx b/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.tsx new file mode 100644 index 000000000..88eacfbf3 --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessToken.tsx @@ -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 ( + <> + + +
+
+ + + {newAPIToken === '' ? ( + + + + ) : ( + DisplayUserAccessToken(newAPIToken) + )} + + +
+
+ + ); + + async function onSubmit(values: ApiKeyFormValues) { + mutation.mutate( + { values, userid: user.Id }, + { + onSuccess(response) { + setNewAPIToken(response); + }, + } + ); + + trackEvent('portainer-account-access-token-create', { + category: 'portainer', + }); + } +} diff --git a/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessTokenInnerForm.tsx b/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessTokenInnerForm.tsx new file mode 100644 index 000000000..137189788 --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/CreateUserAccessTokenInnerForm.tsx @@ -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(); + + return ( +
+ + + + + + + + Add access token + +
+ ); +} diff --git a/app/react/portainer/account/CreateAccessTokenView/DisplayUserAccessToken.tsx b/app/react/portainer/account/CreateAccessTokenView/DisplayUserAccessToken.tsx new file mode 100644 index 000000000..bf4ffe23d --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/DisplayUserAccessToken.tsx @@ -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 ( + <> + New access token + + Please copy the new access token. You won't be able to view the + token again. + +
+
+
{apikey}
+
+ +
+
+
+
+ + + ); +} diff --git a/app/react/portainer/account/CreateAccessTokenView/index.ts b/app/react/portainer/account/CreateAccessTokenView/index.ts index 015b67a86..66872c3f3 100644 --- a/app/react/portainer/account/CreateAccessTokenView/index.ts +++ b/app/react/portainer/account/CreateAccessTokenView/index.ts @@ -1 +1 @@ -export { CreateAccessToken } from './CreateAccessToken'; +export { CreateUserAccessToken } from './CreateUserAccessToken'; diff --git a/app/react/portainer/account/CreateAccessTokenView/types.ts b/app/react/portainer/account/CreateAccessTokenView/types.ts new file mode 100644 index 000000000..ad482dbde --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/types.ts @@ -0,0 +1,4 @@ +export interface ApiKeyFormValues { + password: string; + description: string; +} diff --git a/app/react/portainer/account/CreateAccessTokenView/useCreateUserAccessTokenMutation.ts b/app/react/portainer/account/CreateAccessTokenView/useCreateUserAccessTokenMutation.ts new file mode 100644 index 000000000..299b6888f --- /dev/null +++ b/app/react/portainer/account/CreateAccessTokenView/useCreateUserAccessTokenMutation.ts @@ -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( + `/users/${userid}/tokens`, + values + ); + return response.data.rawAPIKey; + } catch (e) { + throw parseAxiosError(e); + } +}