diff --git a/app/portainer/components/datatables/access-tokens-datatable/access-tokens-datatable.controller.js b/app/portainer/components/datatables/access-tokens-datatable/access-tokens-datatable.controller.js deleted file mode 100644 index d0aafbcd7..000000000 --- a/app/portainer/components/datatables/access-tokens-datatable/access-tokens-datatable.controller.js +++ /dev/null @@ -1,39 +0,0 @@ -export default class AccessTokensDatatableController { - /* @ngInject*/ - constructor($scope, $state, $controller, DatatableService) { - angular.extend(this, $controller('GenericDatatableController', { $scope: $scope })); - - this.onClickAdd = () => { - if (this.uiCanExit()) { - $state.go('portainer.account.new-access-token'); - } - }; - - this.$onInit = function () { - this.setDefaults(); - this.prepareTableFromDataset(); - - const storedOrder = DatatableService.getDataTableOrder(this.tableKey); - if (storedOrder !== null) { - this.state.reverseOrder = storedOrder.reverse; - this.state.orderBy = storedOrder.orderBy; - } - - const textFilter = DatatableService.getDataTableTextFilters(this.tableKey); - if (textFilter !== null) { - this.state.textFilter = textFilter; - this.onTextFilterChange(); - } - - const storedFilters = DatatableService.getDataTableFilters(this.tableKey); - if (storedFilters !== null) { - this.filters = storedFilters; - } - if (this.filters && this.filters.state) { - this.filters.state.open = false; - } - - this.onSettingsRepeaterChange(); - }; - } -} diff --git a/app/portainer/components/datatables/access-tokens-datatable/access-tokens-datatable.html b/app/portainer/components/datatables/access-tokens-datatable/access-tokens-datatable.html deleted file mode 100644 index dd6c9302e..000000000 --- a/app/portainer/components/datatables/access-tokens-datatable/access-tokens-datatable.html +++ /dev/null @@ -1,135 +0,0 @@ -
- - -
-
-
- -
- {{ $ctrl.titleText }} -
- -
- - -
-
-
- - - - - - - - - - - - - - - - - - - - -
-
- - - - - -
-
- - - - - -
- - - - - {{ item.description }} - - {{ item.prefix }} - - {{ item.dateCreated | getisodatefromtimestamp }} - - {{ item.lastUsed | getisodatefromtimestamp }} -
Loading...
-
- -
-
-
diff --git a/app/portainer/components/datatables/access-tokens-datatable/index.js b/app/portainer/components/datatables/access-tokens-datatable/index.js deleted file mode 100644 index b90b3c9dd..000000000 --- a/app/portainer/components/datatables/access-tokens-datatable/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import angular from 'angular'; -import controller from './access-tokens-datatable.controller'; - -angular.module('portainer.app').component('accessTokensDatatable', { - templateUrl: './access-tokens-datatable.html', - controller, - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - removeAction: '<', - uiCanExit: '<', - }, -}); diff --git a/app/portainer/react/components/account.ts b/app/portainer/react/components/account.ts index cb75d29c4..3d77b91b6 100644 --- a/app/portainer/react/components/account.ts +++ b/app/portainer/react/components/account.ts @@ -4,6 +4,8 @@ import { r2a } from '@/react-tools/react2angular'; import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { withReactQuery } from '@/react-tools/withReactQuery'; +import { HelmRepositoryDatatable } from '@/react/portainer/account/AccountView/HelmRepositoryDatatable'; +import { AccessTokensDatatable } from '@/react/portainer/account/AccountView/AccessTokensDatatable'; import { ApplicationSettingsWidget } from '@/react/portainer/account/AccountView/ApplicationSettings'; export const accountModule = angular @@ -14,4 +16,17 @@ export const accountModule = angular withUIRouter(withReactQuery(withCurrentUser(ApplicationSettingsWidget))), [] ) + ) + .component( + 'helmRepositoryDatatable', + r2a( + withUIRouter(withReactQuery(withCurrentUser(HelmRepositoryDatatable))), + [] + ) + ) + .component( + 'accessTokensDatatable', + r2a(withUIRouter(withReactQuery(withCurrentUser(AccessTokensDatatable))), [ + 'canExit', + ]) ).name; diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 468b40d4d..4eae9025b 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -8,7 +8,6 @@ import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsB import { withFormValidation } from '@/react-tools/withFormValidation'; import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable'; import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector'; -import { HelmRepositoryDatatable } from '@/react/portainer/account/AccountView/HelmRepositoryDatatable'; import { withControlledInput } from '@/react-tools/withControlledInput'; import { @@ -241,13 +240,6 @@ export const ngModule = angular .component( 'associatedEndpointsSelector', r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value']) - ) - .component( - 'helmRepositoryDatatable', - r2a( - withUIRouter(withReactQuery(withCurrentUser(HelmRepositoryDatatable))), - [] - ) ); export const componentsModule = ngModule.name; diff --git a/app/portainer/views/account/account.html b/app/portainer/views/account/account.html index 589c34ad9..6321d63e2 100644 --- a/app/portainer/views/account/account.html +++ b/app/portainer/views/account/account.html @@ -88,18 +88,6 @@ -
-
- -
-
+ diff --git a/app/portainer/views/account/accountController.js b/app/portainer/views/account/accountController.js index b1c9c1885..c84896f5c 100644 --- a/app/portainer/views/account/accountController.js +++ b/app/portainer/views/account/accountController.js @@ -1,4 +1,4 @@ -import { confirmChangePassword, confirmDelete } from '@@/modals/confirm'; +import { confirmChangePassword } from '@@/modals/confirm'; import { openDialog } from '@@/modals/Dialog'; import { buildConfirmButton } from '@@/modals/utils'; @@ -68,35 +68,7 @@ angular.module('portainer.app').controller('AccountController', [ return this.uiCanExit(); }; - $scope.removeAction = (selectedTokens) => { - const msg = 'Do you want to remove the selected access token(s)? Any script or application using these tokens will no longer be able to invoke the Portainer API.'; - - confirmDelete(msg).then((confirmed) => { - if (!confirmed) { - return; - } - let actionCount = selectedTokens.length; - selectedTokens.forEach((token) => { - UserService.deleteAccessToken($scope.userID, token.id) - .then(() => { - Notifications.success('Success', 'Token successfully removed'); - var index = $scope.tokens.indexOf(token); - $scope.tokens.splice(index, 1); - }) - .catch((err) => { - Notifications.error('Failure', err, 'Unable to remove token'); - }) - .finally(() => { - --actionCount; - if (actionCount === 0) { - $state.reload(); - } - }); - }); - }); - }; - - async function initView() { + function initView() { const state = StateManager.getState(); const userDetails = Authentication.getUserDetails(); $scope.userID = userDetails.ID; @@ -127,14 +99,6 @@ angular.module('portainer.app').controller('AccountController', [ .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings'); }); - - UserService.getAccessTokens($scope.userID) - .then(function success(data) { - $scope.tokens = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve user tokens'); - }); } initView(); diff --git a/app/react/portainer/account/AccountView/AccessTokensDatatable/AccessTokensDatatable.tsx b/app/react/portainer/account/AccountView/AccessTokensDatatable/AccessTokensDatatable.tsx new file mode 100644 index 000000000..1629ea3f9 --- /dev/null +++ b/app/react/portainer/account/AccountView/AccessTokensDatatable/AccessTokensDatatable.tsx @@ -0,0 +1,32 @@ +import { Key } from 'lucide-react'; + +import { Datatable } from '@@/datatables'; +import { createPersistedStore } from '@@/datatables/types'; +import { useTableState } from '@@/datatables/useTableState'; + +import { useAccessTokens } from '../../access-tokens/queries/useAccessTokens'; + +import { columns } from './columns'; +import { TableActions } from './TableActions'; + +const tableKey = 'access-tokens'; +const store = createPersistedStore(tableKey); + +export function AccessTokensDatatable({ canExit }: { canExit?: boolean }) { + const query = useAccessTokens(); + const tableState = useTableState(store, tableKey); + + return ( + ( + + )} + /> + ); +} diff --git a/app/react/portainer/account/AccountView/AccessTokensDatatable/TableActions.tsx b/app/react/portainer/account/AccountView/AccessTokensDatatable/TableActions.tsx new file mode 100644 index 000000000..3d4a4cc34 --- /dev/null +++ b/app/react/portainer/account/AccountView/AccessTokensDatatable/TableActions.tsx @@ -0,0 +1,41 @@ +import { notifySuccess } from '@/portainer/services/notifications'; + +import { DeleteButton } from '@@/buttons/DeleteButton'; +import { AddButton } from '@@/buttons'; + +import { AccessToken } from '../../access-tokens/types'; + +import { useDeleteAccessTokensMutation } from './useDeleteAccessTokensMutation'; + +export function TableActions({ + selectedItems, + canExit, +}: { + selectedItems: AccessToken[]; + canExit?: boolean; +}) { + const deleteMutation = useDeleteAccessTokensMutation(); + + return ( + <> + + + + Add access token + + + ); + + function handleRemove() { + const ids = selectedItems.map((item) => item.id); + deleteMutation.mutate(ids, { + onSuccess() { + notifySuccess('Success', 'Access token(s) removed'); + }, + }); + } +} diff --git a/app/react/portainer/account/AccountView/AccessTokensDatatable/columns.ts b/app/react/portainer/account/AccountView/AccessTokensDatatable/columns.ts new file mode 100644 index 000000000..1ee7a8352 --- /dev/null +++ b/app/react/portainer/account/AccountView/AccessTokensDatatable/columns.ts @@ -0,0 +1,24 @@ +import { createColumnHelper } from '@tanstack/react-table'; + +import { isoDateFromTimestamp } from '@/portainer/filters/filters'; + +import { AccessToken } from '../../access-tokens/types'; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.accessor('description', { + header: 'Description', + }), + columnHelper.accessor('prefix', { + header: 'Prefix', + }), + columnHelper.accessor('dateCreated', { + header: 'Created', + cell: ({ getValue }) => isoDateFromTimestamp(getValue()), + }), + columnHelper.accessor('lastUsed', { + header: 'Last Used', + cell: ({ getValue }) => isoDateFromTimestamp(getValue()), + }), +]; diff --git a/app/react/portainer/account/AccountView/AccessTokensDatatable/index.ts b/app/react/portainer/account/AccountView/AccessTokensDatatable/index.ts new file mode 100644 index 000000000..f3264f154 --- /dev/null +++ b/app/react/portainer/account/AccountView/AccessTokensDatatable/index.ts @@ -0,0 +1 @@ +export { AccessTokensDatatable } from './AccessTokensDatatable'; diff --git a/app/react/portainer/account/AccountView/AccessTokensDatatable/useDeleteAccessTokensMutation.ts b/app/react/portainer/account/AccountView/AccessTokensDatatable/useDeleteAccessTokensMutation.ts new file mode 100644 index 000000000..d605de89f --- /dev/null +++ b/app/react/portainer/account/AccountView/AccessTokensDatatable/useDeleteAccessTokensMutation.ts @@ -0,0 +1,40 @@ +import { useMutation, useQueryClient } from 'react-query'; + +import { withError, withInvalidate } from '@/react-tools/react-query'; +import { useCurrentUser } from '@/react/hooks/useUser'; +import { promiseSequence } from '@/portainer/helpers/promise-utils'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { AccessToken } from '../../access-tokens/types'; +import { buildUrl } from '../../access-tokens/queries/build-url'; +import { queryKeys } from '../../access-tokens/queries/query-keys'; + +export function useDeleteAccessTokensMutation() { + const { user } = useCurrentUser(); + + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (ids: Array) => + deleteAccessTokens(user.Id, ids), + ...withError('Failed to delete access tokens'), + ...withInvalidate(queryClient, [queryKeys.base(user.Id)]), + }); +} + +async function deleteAccessTokens( + userId: number, + tokenIds: Array +) { + return promiseSequence( + tokenIds.map((tokenId) => () => deleteAccessToken(userId, tokenId)) + ); +} + +async function deleteAccessToken(userId: number, id: AccessToken['id']) { + try { + await axios.delete(buildUrl(userId, id)); + } catch (e) { + throw parseAxiosError(e, 'Unable to delete access token'); + } +} diff --git a/app/react/portainer/account/access-tokens/queries/build-url.ts b/app/react/portainer/account/access-tokens/queries/build-url.ts new file mode 100644 index 000000000..861f4748e --- /dev/null +++ b/app/react/portainer/account/access-tokens/queries/build-url.ts @@ -0,0 +1,8 @@ +import { UserId } from '@/portainer/users/types'; + +import { AccessToken } from '../types'; + +export function buildUrl(userId: UserId, id?: AccessToken['id']) { + const baseUrl = `/users/${userId}/tokens`; + return id ? `${baseUrl}/${id}` : baseUrl; +} diff --git a/app/react/portainer/account/access-tokens/queries/query-keys.ts b/app/react/portainer/account/access-tokens/queries/query-keys.ts new file mode 100644 index 000000000..6ee78c4e9 --- /dev/null +++ b/app/react/portainer/account/access-tokens/queries/query-keys.ts @@ -0,0 +1,6 @@ +import { userQueryKeys } from '@/portainer/users/queries/queryKeys'; +import { UserId } from '@/portainer/users/types'; + +export const queryKeys = { + base: (userId: UserId) => [...userQueryKeys.user(userId), 'tokens'] as const, +}; diff --git a/app/react/portainer/account/access-tokens/queries/useAccessTokens.ts b/app/react/portainer/account/access-tokens/queries/useAccessTokens.ts new file mode 100644 index 000000000..033c4805b --- /dev/null +++ b/app/react/portainer/account/access-tokens/queries/useAccessTokens.ts @@ -0,0 +1,27 @@ +import { useQuery } from 'react-query'; + +import { useCurrentUser } from '@/react/hooks/useUser'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { AccessToken } from '../types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './build-url'; + +export function useAccessTokens() { + const { user } = useCurrentUser(); + + return useQuery({ + queryKey: queryKeys.base(user.Id), + queryFn: () => getAccessTokens(user.Id), + }); +} + +async function getAccessTokens(userId: number) { + try { + const { data } = await axios.get>(buildUrl(userId)); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to get access tokens'); + } +} diff --git a/app/react/portainer/account/access-tokens/types.ts b/app/react/portainer/account/access-tokens/types.ts new file mode 100644 index 000000000..b099c2dba --- /dev/null +++ b/app/react/portainer/account/access-tokens/types.ts @@ -0,0 +1,22 @@ +/** + * AccessToken represents an API key + */ +export interface AccessToken { + id: number; + + userId: number; + + description: string; + + /** API key identifier (7 char prefix) */ + prefix: string; + + /** Unix timestamp (UTC) when the API key was created */ + dateCreated: number; + + /** Unix timestamp (UTC) when the API key was last used */ + lastUsed: number; + + /** Digest represents SHA256 hash of the raw API key */ + digest?: string; +}