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 @@
-
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;
+}