diff --git a/frontend/src/component/admin/menu/AdminMenu.tsx b/frontend/src/component/admin/menu/AdminMenu.tsx index 7fa2d3dbac..b7de0a76ed 100644 --- a/frontend/src/component/admin/menu/AdminMenu.tsx +++ b/frontend/src/component/admin/menu/AdminMenu.tsx @@ -35,6 +35,16 @@ function AdminMenu() { } /> + {flags.serviceAccounts && ( + + Service accounts + + } + /> + )} {flags.UG && ( { + const { hasAccess } = useContext(AccessContext); + + return ( +
+ + } + elseShow={} + /> +
+ ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx new file mode 100644 index 0000000000..a72ceb6322 --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountDeleteDialog/ServiceAccountDeleteDialog.tsx @@ -0,0 +1,43 @@ +import { Alert, styled } from '@mui/material'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { IUser } from 'interfaces/user'; + +const StyledLabel = styled('p')(({ theme }) => ({ + marginTop: theme.spacing(3), +})); + +interface IServiceAccountDeleteDialogProps { + serviceAccount?: IUser; + open: boolean; + setOpen: React.Dispatch>; + onConfirm: (serviceAccount: IUser) => void; +} + +export const ServiceAccountDeleteDialog = ({ + serviceAccount, + open, + setOpen, + onConfirm, +}: IServiceAccountDeleteDialogProps) => { + return ( + onConfirm(serviceAccount!)} + onClose={() => { + setOpen(false); + }} + > + + Deleting this service account may break any existing + implementations currently using it. + + + You are about to delete service account:{' '} + {serviceAccount?.name} + + + ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx new file mode 100644 index 0000000000..38acc54e51 --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountModal/ServiceAccountModal.tsx @@ -0,0 +1,438 @@ +import { + Button, + FormControl, + FormControlLabel, + Link, + Radio, + RadioGroup, + styled, + Typography, +} from '@mui/material'; +import FormTemplate from 'component/common/FormTemplate/FormTemplate'; +import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import useToast from 'hooks/useToast'; +import { FormEvent, useEffect, useState } from 'react'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import Input from 'component/common/Input/Input'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IUser } from 'interfaces/user'; +import { + IServiceAccountPayload, + useServiceAccountsApi, +} from 'hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; +import { useUsers } from 'hooks/api/getters/useUsers/useUsers'; +import { + calculateExpirationDate, + ExpirationOption, + IPersonalAPITokenFormErrors, + PersonalAPITokenForm, +} from 'component/user/Profile/PersonalAPITokensTab/CreatePersonalAPIToken/PersonalAPITokenForm/PersonalAPITokenForm'; +import { usePersonalAPITokensApi } from 'hooks/api/actions/usePersonalAPITokensApi/usePersonalAPITokensApi'; +import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; + +const StyledForm = styled('form')(() => ({ + display: 'flex', + flexDirection: 'column', + height: '100%', +})); + +const StyledInputDescription = styled('p')(({ theme }) => ({ + display: 'flex', + color: theme.palette.text.primary, + marginBottom: theme.spacing(1), + '&:not(:first-of-type)': { + marginTop: theme.spacing(4), + }, +})); + +const StyledInputSecondaryDescription = styled('p')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginBottom: theme.spacing(1), +})); + +const StyledInput = styled(Input)(({ theme }) => ({ + width: '100%', + maxWidth: theme.spacing(50), +})); + +const StyledRoleBox = styled(FormControlLabel)(({ theme }) => ({ + margin: theme.spacing(0.5, 0), + border: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(2), +})); + +const StyledRoleRadio = styled(Radio)(({ theme }) => ({ + marginRight: theme.spacing(2), +})); + +const StyledSecondaryContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(3), + backgroundColor: theme.palette.secondaryContainer, + borderRadius: theme.shape.borderRadiusMedium, + marginTop: theme.spacing(4), + marginBottom: theme.spacing(2), +})); + +const StyledInlineContainer = styled('div')(({ theme }) => ({ + padding: theme.spacing(0, 4), + '& > p:not(:first-of-type)': { + marginTop: theme.spacing(2), + }, +})); + +const StyledButtonContainer = styled('div')(({ theme }) => ({ + marginTop: 'auto', + display: 'flex', + justifyContent: 'flex-end', + [theme.breakpoints.down('sm')]: { + marginTop: theme.spacing(4), + }, +})); + +const StyledCancelButton = styled(Button)(({ theme }) => ({ + marginLeft: theme.spacing(3), +})); + +enum TokenGeneration { + LATER = 'later', + NOW = 'now', +} + +enum ErrorField { + USERNAME = 'username', +} + +interface IServiceAccountModalErrors { + [ErrorField.USERNAME]?: string; +} + +const DEFAULT_EXPIRATION = ExpirationOption['30DAYS']; + +interface IServiceAccountModalProps { + serviceAccount?: IUser; + open: boolean; + setOpen: React.Dispatch>; + newToken: (token: INewPersonalAPIToken) => void; +} + +export const ServiceAccountModal = ({ + serviceAccount, + open, + setOpen, + newToken, +}: IServiceAccountModalProps) => { + const { users } = useUsers(); + const { serviceAccounts, roles, refetch } = useServiceAccounts(); + const { addServiceAccount, updateServiceAccount, loading } = + useServiceAccountsApi(); + const { createUserPersonalAPIToken } = usePersonalAPITokensApi(); + const { setToastData, setToastApiError } = useToast(); + const { uiConfig } = useUiConfig(); + + const [name, setName] = useState(''); + const [username, setUsername] = useState(''); + const [rootRole, setRootRole] = useState(1); + const [tokenGeneration, setTokenGeneration] = useState( + TokenGeneration.LATER + ); + const [errors, setErrors] = useState({}); + + const clearError = (field: ErrorField) => { + setErrors(errors => ({ ...errors, [field]: undefined })); + }; + + const setError = (field: ErrorField, error: string) => { + setErrors(errors => ({ ...errors, [field]: error })); + }; + + const [patDescription, setPatDescription] = useState(''); + const [patExpiration, setPatExpiration] = + useState(DEFAULT_EXPIRATION); + const [patExpiresAt, setPatExpiresAt] = useState( + calculateExpirationDate(DEFAULT_EXPIRATION) + ); + const [patErrors, setPatErrors] = useState({}); + + const editing = serviceAccount !== undefined; + + useEffect(() => { + setName(serviceAccount?.name || ''); + setUsername(serviceAccount?.username || ''); + setRootRole(serviceAccount?.rootRole || 1); + setTokenGeneration(TokenGeneration.LATER); + setErrors({}); + + setPatDescription(''); + setPatExpiration(DEFAULT_EXPIRATION); + setPatExpiresAt(calculateExpirationDate(DEFAULT_EXPIRATION)); + setPatErrors({}); + }, [open, serviceAccount]); + + const getServiceAccountPayload = (): IServiceAccountPayload => ({ + name, + username, + rootRole, + }); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + try { + if (editing) { + await updateServiceAccount( + serviceAccount.id, + getServiceAccountPayload() + ); + } else { + const { id } = await addServiceAccount( + getServiceAccountPayload() + ); + if (tokenGeneration === TokenGeneration.NOW) { + const token = await createUserPersonalAPIToken(id, { + description: patDescription, + expiresAt: patExpiresAt, + }); + newToken(token); + } + } + setToastData({ + title: `Service account ${ + editing ? 'updated' : 'added' + } successfully`, + type: 'success', + }); + refetch(); + setOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const formatApiCode = () => { + return `curl --location --request ${editing ? 'PUT' : 'POST'} '${ + uiConfig.unleashUrl + }/api/admin/service-account${editing ? `/${serviceAccount.id}` : ''}' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '${JSON.stringify(getServiceAccountPayload(), undefined, 2)}'`; + }; + + const isNotEmpty = (value: string) => value.length; + const isUnique = (value: string) => + !users?.some((user: IUser) => user.username === value) && + !serviceAccounts?.some( + (serviceAccount: IUser) => serviceAccount.username === value + ); + const isValid = + isNotEmpty(name) && + isNotEmpty(username) && + (editing || isUnique(username)) && + (tokenGeneration === TokenGeneration.LATER || + isNotEmpty(patDescription)); + + const suggestUsername = () => { + if (isNotEmpty(name) && !isNotEmpty(username)) { + const normalizedFromName = `service:${name + .toLowerCase() + .replace(/ /g, '-') + .replace(/[^\w_-]/g, '')}`; + if (isUnique(normalizedFromName)) { + setUsername(normalizedFromName); + } + } + }; + + const onSetUsername = (username: string) => { + clearError(ErrorField.USERNAME); + if (!isUnique(username)) { + setError( + ErrorField.USERNAME, + 'A service account or user with that username already exists.' + ); + } + setUsername(username); + }; + + return ( + { + setOpen(false); + }} + label={editing ? 'Edit service account' : 'New service account'} + > + + +
+ + What is your new service account name? + + setName(e.target.value)} + onBlur={suggestUsername} + autoComplete="off" + required + /> + + What is your new service account username? + + onSetUsername(e.target.value)} + autoComplete="off" + required + disabled={editing} + /> + + What is your service account allowed to do? + + + setRootRole(+e.target.value)} + data-loading + > + {roles + .sort((a, b) => (a.name < b.name ? -1 : 1)) + .map(role => ( + + {role.name} + + {role.description} + +
+ } + control={ + + } + value={role.id} + /> + ))} + + + + + Token + + + In order to connect your newly created + service account, you will also need a + token.{' '} + + Read more about API tokens + + . + + + + setTokenGeneration( + e.target + .value as TokenGeneration + ) + } + name="token-generation" + > + } + label="I want to generate a token later" + /> + } + label="Generate a token now" + /> + + + + + A new personal access token (PAT) + will be generated for the service + account, so you can get started + right away. + + + } + /> + + + } + /> + + + + + { + setOpen(false); + }} + > + Cancel + + +
+
+
+ ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog.tsx new file mode 100644 index 0000000000..e90f9ef3cf --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountTokenDialog/ServiceAccountTokenDialog.tsx @@ -0,0 +1,39 @@ +import { Alert, styled, Typography } from '@mui/material'; +import { UserToken } from 'component/admin/apiToken/ConfirmToken/UserToken/UserToken'; +import { Dialogue } from 'component/common/Dialogue/Dialogue'; +import { INewPersonalAPIToken } from 'interfaces/personalAPIToken'; +import { FC } from 'react'; + +const StyledAlert = styled(Alert)(({ theme }) => ({ + marginBottom: theme.spacing(3), +})); + +interface IServiceAccountDialogProps { + open: boolean; + setOpen: React.Dispatch>; + token?: INewPersonalAPIToken; +} + +export const ServiceAccountTokenDialog: FC = ({ + open, + setOpen, + token, +}) => ( + { + if (!muiCloseReason) { + setOpen(false); + } + }} + title="Service account token created" + > + + Make sure to copy your service account API token now. You won't be + able to see it again! + + Your token: + + +); diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsActionsCell/ServiceAccountsActionsCell.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsActionsCell/ServiceAccountsActionsCell.tsx new file mode 100644 index 0000000000..e39a6bbfad --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsActionsCell/ServiceAccountsActionsCell.tsx @@ -0,0 +1,44 @@ +import { Delete, Edit } from '@mui/icons-material'; +import { Box, styled } from '@mui/material'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { ADMIN } from 'component/providers/AccessProvider/permissions'; +import { VFC } from 'react'; + +const StyledBox = styled(Box)(() => ({ + display: 'flex', + justifyContent: 'center', +})); + +interface IServiceAccountsActionsCellProps { + onEdit: (event: React.SyntheticEvent) => void; + onDelete: (event: React.SyntheticEvent) => void; +} + +export const ServiceAccountsActionsCell: VFC< + IServiceAccountsActionsCellProps +> = ({ onEdit, onDelete }) => { + return ( + + + + + + + + + ); +}; diff --git a/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx new file mode 100644 index 0000000000..c309a88f27 --- /dev/null +++ b/frontend/src/component/admin/serviceAccounts/ServiceAccountsTable/ServiceAccountsTable.tsx @@ -0,0 +1,272 @@ +import { useMemo, useState } from 'react'; +import { TablePlaceholder, VirtualizedTable } from 'component/common/Table'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { IUser } from 'interfaces/user'; +import IRole from 'interfaces/role'; +import useToast from 'hooks/useToast'; +import { formatUnknownError } from 'utils/formatUnknownError'; +import { PageContent } from 'component/common/PageContent/PageContent'; +import { PageHeader } from 'component/common/PageHeader/PageHeader'; +import { Button, useMediaQuery } from '@mui/material'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +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'; +import { UserAvatar } from 'component/common/UserAvatar/UserAvatar'; +import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; +import { useSearch } from 'hooks/useSearch'; +import { useServiceAccounts } from 'hooks/api/getters/useServiceAccounts/useServiceAccounts'; +import { useServiceAccountsApi } from 'hooks/api/actions/useServiceAccountsApi/useServiceAccountsApi'; +import { ServiceAccountModal } from './ServiceAccountModal/ServiceAccountModal'; +import { ServiceAccountDeleteDialog } from './ServiceAccountDeleteDialog/ServiceAccountDeleteDialog'; +import { ServiceAccountsActionsCell } from './ServiceAccountsActionsCell/ServiceAccountsActionsCell'; +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(); + const { removeServiceAccount } = useServiceAccountsApi(); + + const [searchValue, setSearchValue] = useState(''); + const [modalOpen, setModalOpen] = useState(false); + const [tokenDialog, setTokenDialog] = useState(false); + const [newToken, setNewToken] = useState(); + const [deleteOpen, setDeleteOpen] = useState(false); + const [selectedServiceAccount, setSelectedServiceAccount] = + useState(); + + const onDeleteConfirm = async (serviceAccount: IUser) => { + try { + await removeServiceAccount(serviceAccount.id); + setToastData({ + title: `${serviceAccount.name} has been deleted`, + type: 'success', + }); + refetch(); + setDeleteOpen(false); + } catch (error: unknown) { + setToastApiError(formatUnknownError(error)); + } + }; + + const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm')); + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); + + const columns = useMemo( + () => [ + { + Header: 'Created', + accessor: 'createdAt', + Cell: DateCell, + sortType: 'date', + width: 120, + maxWidth: 120, + }, + { + Header: 'Avatar', + accessor: 'imageUrl', + Cell: ({ row: { original: user } }: any) => ( + + + + ), + disableSortBy: true, + maxWidth: 80, + }, + { + id: 'name', + Header: 'Name', + accessor: (row: any) => row.name || '', + minWidth: 200, + Cell: ({ row: { original: user } }: any) => ( + + ), + searchable: true, + }, + { + id: 'role', + Header: 'Role', + accessor: (row: any) => + roles.find((role: IRole) => role.id === row.rootRole) + ?.name || '', + maxWidth: 120, + }, + { + Header: 'Actions', + id: 'Actions', + align: 'center', + Cell: ({ row: { original: user } }: any) => ( + { + setSelectedServiceAccount(user); + setModalOpen(true); + }} + onDelete={() => { + setSelectedServiceAccount(user); + setDeleteOpen(true); + }} + /> + ), + width: 150, + disableSortBy: true, + }, + // Always hidden -- for search + { + accessor: 'username', + Header: 'Username', + searchable: true, + }, + ], + [roles, navigate] + ); + + const [initialState] = useState({ + sortBy: [{ id: 'createdAt' }], + hiddenColumns: ['username'], + }); + + const { data, getSearchText } = useSearch( + columns, + searchValue, + serviceAccounts + ); + + const { headerGroups, rows, prepareRow, setHiddenColumns } = useTable( + { + columns: columns as any, + data, + initialState, + sortTypes, + autoResetSortBy: false, + disableSortRemove: true, + disableMultiSort: true, + defaultColumn: { + Cell: TextCell, + }, + }, + useSortBy, + useFlexLayout + ); + + useConditionallyHiddenColumns( + [ + { + condition: isExtraSmallScreen, + columns: ['imageUrl', 'role'], + }, + { + condition: isSmallScreen, + columns: ['createdAt', 'last-login'], + }, + ], + setHiddenColumns, + columns + ); + + return ( + + + + + + } + /> + + + } + > + + } + /> + + } + > + + + + 0} + show={ + + No service accounts found matching “ + {searchValue} + ” + + } + elseShow={ + + No service accounts available. Get started by + adding one. + + } + /> + } + /> + { + setNewToken(token); + setTokenDialog(true); + }} + /> + + + + ); +}; diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 69fb7e2cac..d475ed0a3c 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -378,6 +378,17 @@ exports[`returns all baseRoutes 1`] = ` "title": "Users", "type": "protected", }, + { + "component": [Function], + "flag": "serviceAccounts", + "menu": { + "adminSettings": true, + }, + "parent": "/admin", + "path": "/admin/service-accounts", + "title": "Service accounts", + "type": "protected", + }, { "component": [Function], "menu": {}, diff --git a/frontend/src/component/menu/routes.ts b/frontend/src/component/menu/routes.ts index 289c8a4860..6739758d40 100644 --- a/frontend/src/component/menu/routes.ts +++ b/frontend/src/component/menu/routes.ts @@ -62,6 +62,7 @@ import { Profile } from 'component/user/Profile/Profile'; import { InstanceAdmin } from '../admin/instance-admin/InstanceAdmin'; import { Network } from 'component/admin/network/Network'; import { MaintenanceAdmin } from '../admin/maintenance'; +import { ServiceAccounts } from 'component/admin/serviceAccounts/ServiceAccounts'; export const routes: IRoute[] = [ // Splash @@ -432,6 +433,15 @@ export const routes: IRoute[] = [ type: 'protected', menu: { adminSettings: true }, }, + { + path: '/admin/service-accounts', + parent: '/admin', + title: 'Service accounts', + component: ServiceAccounts, + type: 'protected', + menu: { adminSettings: true }, + flag: 'serviceAccounts', + }, { path: '/admin/create-user', parent: '/admin',