diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx
index 38acc54e51..09e5992e92 100644
--- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx
+++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx
@@ -31,6 +31,7 @@ import {
} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
+import { ServiceAccountTokens } from './ServiceAccountTokens/ServiceAccountTokens';
const StyledForm = styled('form')(() => ({
display: 'flex',
@@ -86,9 +87,7 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({
marginTop: 'auto',
display: 'flex',
justifyContent: 'flex-end',
- [theme.breakpoints.down('sm')]: {
- marginTop: theme.spacing(4),
- },
+ paddingTop: theme.spacing(4),
}));
const StyledCancelButton = styled(Button)(({ theme }) => ({
@@ -225,16 +224,18 @@ export const ServiceAccountModal = ({
!serviceAccounts?.some(
(serviceAccount: IUser) => serviceAccount.username === value
);
+ const isPATValid =
+ tokenGeneration === TokenGeneration.LATER ||
+ (isNotEmpty(patDescription) && patExpiresAt > new Date());
const isValid =
isNotEmpty(name) &&
isNotEmpty(username) &&
(editing || isUnique(username)) &&
- (tokenGeneration === TokenGeneration.LATER ||
- isNotEmpty(patDescription));
+ isPATValid;
const suggestUsername = () => {
if (isNotEmpty(name) && !isNotEmpty(username)) {
- const normalizedFromName = `service:${name
+ const normalizedFromName = `service-${name
.toLowerCase()
.replace(/ /g, '-')
.replace(/[^\w_-]/g, '')}`;
@@ -411,6 +412,16 @@ export const ServiceAccountModal = ({
}
+ elseShow={
+ <>
+
+ Service account tokens
+
+
+ >
+ }
/>
diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx
new file mode 100644
index 0000000000..dc8b9aad32
--- /dev/null
+++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog.tsx
@@ -0,0 +1,83 @@
+import { useEffect, useState } from 'react';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import {
+ calculateExpirationDate,
+ ExpirationOption,
+ IPersonalAPITokenFormErrors,
+ PersonalAPITokenForm,
+} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm';
+import { ICreatePersonalApiTokenPayload } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
+import { IUser } from 'interfaces/user';
+import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
+
+const DEFAULT_EXPIRATION = ExpirationOption['30DAYS'];
+
+interface IServiceAccountCreateTokenDialogProps {
+ open: boolean;
+ setOpen: React.Dispatch>;
+ serviceAccount: IUser;
+ onCreateClick: (newToken: ICreatePersonalApiTokenPayload) => void;
+}
+
+export const ServiceAccountCreateTokenDialog = ({
+ open,
+ setOpen,
+ serviceAccount,
+ onCreateClick,
+}: IServiceAccountCreateTokenDialogProps) => {
+ const { tokens = [] } = usePersonalAPITokens(serviceAccount.id);
+
+ const [patDescription, setPatDescription] = useState('');
+ const [patExpiration, setPatExpiration] =
+ useState(DEFAULT_EXPIRATION);
+ const [patExpiresAt, setPatExpiresAt] = useState(
+ calculateExpirationDate(DEFAULT_EXPIRATION)
+ );
+ const [patErrors, setPatErrors] = useState({});
+
+ useEffect(() => {
+ setPatDescription('');
+ setPatExpiration(DEFAULT_EXPIRATION);
+ setPatExpiresAt(calculateExpirationDate(DEFAULT_EXPIRATION));
+ setPatErrors({});
+ }, [open]);
+
+ const isDescriptionUnique = (description: string) =>
+ !tokens?.some(token => token.description === description);
+
+ const isPATValid =
+ patDescription.length &&
+ isDescriptionUnique(patDescription) &&
+ patExpiresAt > new Date();
+
+ return (
+
+ onCreateClick({
+ description: patDescription,
+ expiresAt: patExpiresAt,
+ })
+ }
+ disabledPrimaryButton={!isPATValid}
+ onClose={() => {
+ setOpen(false);
+ }}
+ title="New token"
+ >
+
+
+ );
+};
diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx
new file mode 100644
index 0000000000..b7047ea0c2
--- /dev/null
+++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountTokens/ServiceAccountTokens.tsx
@@ -0,0 +1,364 @@
+import { Delete } from '@mui/icons-material';
+import {
+ Button,
+ IconButton,
+ styled,
+ Tooltip,
+ Typography,
+ useMediaQuery,
+ useTheme,
+} from '@mui/material';
+import { Search } from 'component/common/Search/Search';
+import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
+import { ActionCell } from 'component/common/Table/cells/ActionCell/ActionCell';
+import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
+import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
+import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
+import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
+import { PAT_LIMIT } from '@server/util/constants';
+import { usePersonalAPITokens } from 'hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens';
+import { useSearch } from 'hooks/useSearch';
+import {
+ INewPersonalAPIToken,
+ IPersonalAPIToken,
+} from 'interfaces/personalAPIToken';
+import { useMemo, useState } from 'react';
+import { useTable, SortingRule, useSortBy, useFlexLayout } from 'react-table';
+import { sortTypes } from 'utils/sortTypes';
+import { ServiceAccountCreateTokenDialog } from './ServiceAccountCreateTokenDialog/ServiceAccountCreateTokenDialog';
+import { ServiceAccountTokenDialog } from 'component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog';
+import { TimeAgoCell } from 'component/common/Table/cells/TimeAgoCell/TimeAgoCell';
+import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
+import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
+import { IUser } from 'interfaces/user';
+import { Dialogue } from 'component/common/Dialogue/Dialogue';
+import {
+ ICreatePersonalApiTokenPayload,
+ usePersonalAPITokensApi,
+} from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi';
+import useToast from 'hooks/useToast';
+import { formatUnknownError } from 'utils/formatUnknownError';
+
+const StyledHeader = styled('div')(({ theme }) => ({
+ display: 'flex',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: theme.spacing(2),
+ gap: theme.spacing(2),
+ '& > div': {
+ [theme.breakpoints.down('md')]: {
+ marginTop: 0,
+ },
+ },
+}));
+
+const StyledTablePlaceholder = styled('div')(({ theme }) => ({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: theme.spacing(3),
+}));
+
+const StyledPlaceholderTitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.bodySize,
+ marginBottom: theme.spacing(0.5),
+}));
+
+const StyledPlaceholderSubtitle = styled(Typography)(({ theme }) => ({
+ fontSize: theme.fontSizes.smallBody,
+ color: theme.palette.text.secondary,
+ marginBottom: theme.spacing(1.5),
+}));
+
+export const tokensPlaceholder: IPersonalAPIToken[] = Array(15).fill({
+ description: 'Short description of the feature',
+ type: '-',
+ createdAt: new Date(2022, 1, 1),
+ project: 'projectID',
+});
+
+export type PageQueryType = Partial<
+ Record<'sort' | 'order' | 'search', string>
+>;
+
+const defaultSort: SortingRule = { id: 'createdAt' };
+
+interface IServiceAccountTokensProps {
+ serviceAccount: IUser;
+ readOnly?: boolean;
+}
+
+export const ServiceAccountTokens = ({
+ serviceAccount,
+ readOnly,
+}: IServiceAccountTokensProps) => {
+ const theme = useTheme();
+ const { setToastData, setToastApiError } = useToast();
+ const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
+ const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
+ const {
+ tokens = [],
+ refetchTokens,
+ loading,
+ } = usePersonalAPITokens(serviceAccount.id);
+ const { createUserPersonalAPIToken, deleteUserPersonalAPIToken } =
+ usePersonalAPITokensApi();
+
+ const [initialState] = useState(() => ({
+ sortBy: [defaultSort],
+ }));
+
+ const [searchValue, setSearchValue] = useState('');
+ const [createOpen, setCreateOpen] = useState(false);
+ const [tokenOpen, setTokenOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [newToken, setNewToken] = useState();
+ const [selectedToken, setSelectedToken] = useState();
+
+ const onCreateClick = async (newToken: ICreatePersonalApiTokenPayload) => {
+ try {
+ const token = await createUserPersonalAPIToken(
+ serviceAccount.id,
+ newToken
+ );
+ refetchTokens();
+ setCreateOpen(false);
+ setNewToken(token);
+ setTokenOpen(true);
+ setToastData({
+ title: 'Token created successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ };
+
+ const onDeleteClick = async () => {
+ if (selectedToken) {
+ try {
+ await deleteUserPersonalAPIToken(
+ serviceAccount.id,
+ selectedToken?.id
+ );
+ refetchTokens();
+ setDeleteOpen(false);
+ setToastData({
+ title: 'Token deleted successfully',
+ type: 'success',
+ });
+ } catch (error: unknown) {
+ setToastApiError(formatUnknownError(error));
+ }
+ }
+ };
+
+ const columns = useMemo(
+ () => [
+ {
+ Header: 'Description',
+ accessor: 'description',
+ Cell: HighlightCell,
+ minWidth: 100,
+ searchable: true,
+ },
+ {
+ Header: 'Expires',
+ accessor: 'expiresAt',
+ Cell: ({ value }: { value: string }) => {
+ const date = new Date(value);
+ if (date.getFullYear() > new Date().getFullYear() + 100) {
+ return Never;
+ }
+ return ;
+ },
+ sortType: 'date',
+ maxWidth: 150,
+ },
+ {
+ Header: 'Created',
+ accessor: 'createdAt',
+ Cell: DateCell,
+ sortType: 'date',
+ maxWidth: 150,
+ },
+ {
+ Header: 'Last seen',
+ accessor: 'seenAt',
+ Cell: TimeAgoCell,
+ sortType: 'date',
+ maxWidth: 150,
+ },
+ {
+ Header: 'Actions',
+ id: 'Actions',
+ align: 'center',
+ Cell: ({ row: { original: rowToken } }: any) => (
+
+
+
+ {
+ setSelectedToken(rowToken);
+ setDeleteOpen(true);
+ }}
+ >
+
+
+
+
+
+ ),
+ maxWidth: 100,
+ disableSortBy: true,
+ },
+ ],
+ [setSelectedToken, setDeleteOpen]
+ );
+
+ const {
+ data: searchedData,
+ getSearchText,
+ getSearchContext,
+ } = useSearch(columns, searchValue, tokens);
+
+ const data = useMemo(
+ () =>
+ searchedData?.length === 0 && loading
+ ? tokensPlaceholder
+ : searchedData,
+ [searchedData, loading]
+ );
+
+ const {
+ headerGroups,
+ rows,
+ prepareRow,
+ state: { sortBy },
+ setHiddenColumns,
+ } = useTable(
+ {
+ columns,
+ data,
+ initialState,
+ sortTypes,
+ autoResetSortBy: false,
+ disableSortRemove: true,
+ disableMultiSort: true,
+ },
+ useSortBy,
+ useFlexLayout
+ );
+
+ useConditionallyHiddenColumns(
+ [
+ {
+ condition: isExtraSmallScreen,
+ columns: ['expiresAt'],
+ },
+ {
+ condition: isSmallScreen,
+ columns: ['createdAt'],
+ },
+ {
+ condition: Boolean(readOnly),
+ columns: ['Actions', 'expiresAt', 'createdAt'],
+ },
+ ],
+ setHiddenColumns,
+ columns
+ );
+
+ return (
+ <>
+
+
+
+
+ }
+ />
+
+
+
+ 0}
+ show={
+
+ No tokens found matching “
+ {searchValue}
+ ”
+
+ }
+ elseShow={
+
+
+ You have no tokens for this service account
+ yet.
+
+
+ Create a service account token for access to
+ the Unleash API.
+
+
+
+ }
+ />
+ }
+ />
+
+
+ {
+ setDeleteOpen(false);
+ }}
+ title="Delete token?"
+ >
+
+ Any applications or scripts using this token "
+ {selectedToken?.description}" will no
+ longer be able to access the Unleash API. You cannot undo
+ this action.
+
+
+ >
+ );
+};
diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx
index c309a88f27..b0d280280c 100644
--- a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx
+++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx
@@ -13,7 +13,6 @@ import { useFlexLayout, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
-import { useNavigate } from 'react-router-dom';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
@@ -29,7 +28,6 @@ import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import { ServiceAccountTokenDialog } from './ServiceAccountTokenDialog/ServiceAccountTokenDialog';
export const ServiceAccountsTable = () => {
- const navigate = useNavigate();
const { setToastData, setToastApiError } = useToast();
const { serviceAccounts, roles, refetch, loading } = useServiceAccounts();
@@ -128,7 +126,7 @@ export const ServiceAccountsTable = () => {
searchable: true,
},
],
- [roles, navigate]
+ [roles]
);
const [initialState] = useState({
diff --git a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx
index de8f854b9e..4c900261db 100644
--- a/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx
+++ b/frontend/src/component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm.tsx
@@ -155,7 +155,7 @@ export const PersonalAPITokenForm = ({
if (isDescriptionUnique && !isDescriptionUnique(description)) {
setError(
ErrorField.DESCRIPTION,
- 'A personal API token with that description already exists.'
+ 'A token with that description already exists.'
);
}
setDescription(description);
diff --git a/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts b/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts
index bff677242c..da3ab31afc 100644
--- a/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts
+++ b/frontend/src/hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi.ts
@@ -1,7 +1,7 @@
import { INewPersonalAPIToken } from 'interfaces/personalAPIToken';
import useAPI from '../useApi/useApi';
-interface ICreatePersonalApiTokenPayload {
+export interface ICreatePersonalApiTokenPayload {
description: string;
expiresAt: Date;
}
@@ -53,10 +53,22 @@ export const usePersonalAPITokensApi = () => {
}
};
+ const deleteUserPersonalAPIToken = async (userId: number, id: string) => {
+ const req = createRequest(`api/admin/user-admin/${userId}/pat/${id}`, {
+ method: 'DELETE',
+ });
+ try {
+ await makeRequest(req.caller, req.id);
+ } catch (e) {
+ throw e;
+ }
+ };
+
return {
createPersonalAPIToken,
deletePersonalAPIToken,
createUserPersonalAPIToken,
+ deleteUserPersonalAPIToken,
errors,
loading,
};
diff --git a/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts b/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts
index 44c60c4dd1..6f6fc38876 100644
--- a/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts
+++ b/frontend/src/hooks/api/getters/usePersonalAPITokens/usePersonalAPITokens.ts
@@ -10,9 +10,15 @@ export interface IUsePersonalAPITokensOutput {
error?: Error;
}
-export const usePersonalAPITokens = (): IUsePersonalAPITokensOutput => {
+export const usePersonalAPITokens = (
+ userId?: number
+): IUsePersonalAPITokensOutput => {
const { data, error, mutate } = useSWR(
- formatApiPath('api/admin/user/tokens'),
+ formatApiPath(
+ userId
+ ? `api/admin/user-admin/${userId}/pat`
+ : 'api/admin/user/tokens'
+ ),
fetcher
);