diff --git a/packages/core/admin/admin/src/contexts/apiTokenPermissions.tsx b/packages/core/admin/admin/src/contexts/apiTokenPermissions.tsx index 733b96ce60..e9341f89b5 100644 --- a/packages/core/admin/admin/src/contexts/apiTokenPermissions.tsx +++ b/packages/core/admin/admin/src/contexts/apiTokenPermissions.tsx @@ -3,31 +3,35 @@ import * as React from 'react'; import { createContext } from '@radix-ui/react-context'; -import { Entity } from '@strapi/types'; -interface PseudoEvent { +import { List as ListContentApiPermissions } from '../../../shared/contracts/content-api/permissions'; +import { List as ListContentApiRoutes } from '../../../shared/contracts/content-api/routes'; + +export interface PseudoEvent { target: { value: string }; } interface ApiTokenPermissionsContextValue { - selectedAction: string[] | null; - routes: string[]; - selectedActions: string[]; - data: { - allActionsIds: Entity.ID[]; - permissions: { - apiId: string; - label: string; - controllers: { controller: string; actions: { actionId: string; action: string } }[]; - }[]; + value: { + selectedAction: string | null; + routes: ListContentApiRoutes.Response['data']; + selectedActions: string[]; + data: { + allActionsIds: string[]; + permissions: ListContentApiPermissions.Response['data'][]; + }; + onChange: ({ target: { value } }: PseudoEvent) => void; + onChangeSelectAll: ({ + target: { value }, + }: { + target: { value: { action: string; actionId: string }[] }; + }) => void; + setSelectedAction: ({ target: { value } }: PseudoEvent) => void; }; - onChange: ({ target: { value } }: PseudoEvent) => void; - onChangeSelectAll: ({ target: { value } }: PseudoEvent) => void; - setSelectedAction: ({ target: { value } }: PseudoEvent) => void; } interface ApiTokenPermissionsContextProviderProps extends ApiTokenPermissionsContextValue { - children: React.ReactNode[]; + children: React.ReactNode | React.ReactNode[]; } const [ApiTokenPermissionsContextProvider, useApiTokenPermissionsContext] = diff --git a/packages/core/admin/admin/src/pages/Settings/components/Tokens/LifeSpanInput/index.js b/packages/core/admin/admin/src/pages/Settings/components/Tokens/LifeSpanInput/index.js index 72b901ddb8..2ac9fcb9ca 100644 --- a/packages/core/admin/admin/src/pages/Settings/components/Tokens/LifeSpanInput/index.js +++ b/packages/core/admin/admin/src/pages/Settings/components/Tokens/LifeSpanInput/index.js @@ -4,7 +4,7 @@ import { Option, Select, Typography } from '@strapi/design-system'; import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { getDateOfExpiration } from '../../../pages/ApiTokens/EditView/utils'; +import { getDateOfExpiration } from '../../../pages/ApiTokens/EditView/utils/getDateOfExpiration'; const LifeSpanInput = ({ token, errors, values, onChange, isCreating }) => { const { formatMessage } = useIntl(); diff --git a/packages/core/admin/admin/src/pages/Settings/components/Tokens/constants.js b/packages/core/admin/admin/src/pages/Settings/components/Tokens/constants.ts similarity index 100% rename from packages/core/admin/admin/src/pages/Settings/components/Tokens/constants.js rename to packages/core/admin/admin/src/pages/Settings/components/Tokens/constants.ts diff --git a/packages/core/admin/admin/src/pages/Settings/constants.ts b/packages/core/admin/admin/src/pages/Settings/constants.ts index 87fa7331ba..1fc73ec898 100644 --- a/packages/core/admin/admin/src/pages/Settings/constants.ts +++ b/packages/core/admin/admin/src/pages/Settings/constants.ts @@ -88,30 +88,27 @@ export const ROUTES_CE: Route[] = [ }, { async Component() { - // @ts-expect-error – No types, yet. - const component = await import('./pages/ApiTokens/ProtectedListView'); + const { ProtectedListView } = await import('./pages/ApiTokens/ListView'); - return component; + return ProtectedListView; }, to: '/settings/api-tokens', exact: true, }, { async Component() { - // @ts-expect-error – No types, yet. - const component = await import('./pages/ApiTokens/ProtectedCreateView'); + const { ProtectedCreateView } = await import('./pages/ApiTokens/CreateView'); - return component; + return ProtectedCreateView; }, to: '/settings/api-tokens/create', exact: true, }, { async Component() { - // @ts-expect-error – No types, yet. - const component = await import('./pages/ApiTokens/ProtectedEditView'); + const { ProtectedEditView } = await import('./pages/ApiTokens/EditView/EditViewPage'); - return component; + return ProtectedEditView; }, to: '/settings/api-tokens/:id', exact: true, diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/CreateView.tsx b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/CreateView.tsx new file mode 100644 index 0000000000..781a852d28 --- /dev/null +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/CreateView.tsx @@ -0,0 +1,16 @@ +import { CheckPagePermissions } from '@strapi/helper-plugin'; +import { useSelector } from 'react-redux'; + +import { selectAdminPermissions } from '../../../../selectors'; + +import { EditView } from './EditView/EditViewPage'; + +export const ProtectedCreateView = () => { + const permissions = useSelector(selectAdminPermissions); + + return ( + + + + ); +}; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx similarity index 56% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx index d447c75323..5107750e83 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/EditViewPage.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useReducer, useRef, useState } from 'react'; +import * as React from 'react'; import { ContentLayout, Flex, Main } from '@strapi/design-system'; import { + CheckPagePermissions, Form, SettingsPageTitle, useFetchClient, @@ -12,77 +13,100 @@ import { useRBAC, useTracking, } from '@strapi/helper-plugin'; -import { Formik } from 'formik'; +import { AxiosError, AxiosResponse } from 'axios'; +import { Formik, FormikHelpers } from 'formik'; import { useIntl } from 'react-intl'; import { useQuery } from 'react-query'; import { useSelector } from 'react-redux'; -import { useHistory, useRouteMatch } from 'react-router-dom'; +import { useLocation, useHistory, useRouteMatch } from 'react-router-dom'; -import { ApiTokenPermissionsProvider } from '../../../../../contexts/apiTokenPermissions'; +import { + ApiTokenPermissionsContextValue, + ApiTokenPermissionsProvider, +} from '../../../../../contexts/apiTokenPermissions'; import { selectAdminPermissions } from '../../../../../selectors'; import { formatAPIErrors } from '../../../../../utils/formatAPIErrors'; import { API_TOKEN_TYPE } from '../../../components/Tokens/constants'; +// @ts-expect-error not converted yet import FormHead from '../../../components/Tokens/FormHead'; +// @ts-expect-error not converted yet import TokenBox from '../../../components/Tokens/TokenBox'; -import FormApiTokenContainer from './components/FormApiTokenContainer'; -import LoadingView from './components/LoadingView'; -import Permissions from './components/Permissions'; -import init from './init'; -import reducer, { initialState } from './reducer'; -import { schema } from './utils'; +import { FormApiTokenContainer } from './components/FormApiTokenContainer'; +import { LoadingView } from './components/LoadingView'; +import { Permissions } from './components/Permissions'; +import { schema } from './constants'; +import { initialState, reducer } from './reducer'; + +import type { + Get, + Update, + Create, + ApiToken, +} from '../../../../../../../shared/contracts/api-token'; +import type { List as ListContentApiPermissions } from '../../../../../../../shared/contracts/content-api/permissions'; +import type { List as ListContentApiRoutes } from '../../../../../../../shared/contracts/content-api/routes'; const MSG_ERROR_NAME_TAKEN = 'Name already taken'; -const ApiTokenCreateView = () => { +export const EditView = () => { useFocusWhenNavigate(); const { formatMessage } = useIntl(); const { lockApp, unlockApp } = useOverlayBlocker(); const toggleNotification = useNotification(); - const history = useHistory(); + const { state: locationState } = useLocation<{ apiToken: ApiToken }>(); const permissions = useSelector(selectAdminPermissions); - const [apiToken, setApiToken] = useState( - history.location.state?.apiToken.accessKey + const [apiToken, setApiToken] = React.useState( + locationState?.apiToken?.accessKey ? { - ...history.location.state.apiToken, + ...locationState.apiToken, } : null ); const { trackUsage } = useTracking(); - const trackUsageRef = useRef(trackUsage); const { setCurrentStep } = useGuidedTour(); const { allowedActions: { canCreate, canUpdate, canRegenerate }, - } = useRBAC(permissions.settings['api-tokens']); - const [state, dispatch] = useReducer(reducer, initialState, (state) => init(state, {})); - const { - params: { id }, - } = useRouteMatch('/settings/api-tokens/:id'); + } = useRBAC(permissions.settings?.['api-tokens']); + const [state, dispatch] = React.useReducer(reducer, initialState); + const match = useRouteMatch<{ id: string }>('/settings/api-tokens/:id'); + const id = match?.params?.id; const { get, post, put } = useFetchClient(); + const history = useHistory(); const isCreating = id === 'create'; useQuery( 'content-api-permissions', async () => { - const [permissions, routes] = await Promise.all( + await Promise.all( ['/admin/content-api/permissions', '/admin/content-api/routes'].map(async (url) => { - const { data } = await get(url); + if (url === '/admin/content-api/permissions') { + const { + data: { data }, + } = await get(url); - return data.data; + dispatch({ + type: 'UPDATE_PERMISSIONS_LAYOUT', + value: data, + }); + + return data; + } else if (url === '/admin/content-api/routes') { + const { + data: { data }, + } = await get(url); + + dispatch({ + type: 'UPDATE_ROUTES', + value: data, + }); + + return data; + } }) ); - dispatch({ - type: 'UPDATE_PERMISSIONS_LAYOUT', - value: permissions, - }); - - dispatch({ - type: 'UPDATE_ROUTES', - value: routes, - }); - if (apiToken) { if (apiToken?.type === 'read-only') { dispatch({ @@ -112,18 +136,18 @@ const ApiTokenCreateView = () => { } ); - useEffect(() => { - trackUsageRef.current(isCreating ? 'didAddTokenFromList' : 'didEditTokenFromList', { + React.useEffect(() => { + trackUsage(isCreating ? 'didAddTokenFromList' : 'didEditTokenFromList', { tokenType: API_TOKEN_TYPE, }); - }, [isCreating]); + }, [isCreating, trackUsage]); const { status } = useQuery( ['api-token', id], async () => { const { data: { data }, - } = await get(`/admin/api-tokens/${id}`); + } = await get(`/admin/api-tokens/${id}`); setApiToken({ ...data, @@ -159,37 +183,56 @@ const ApiTokenCreateView = () => { } ); - const handleSubmit = async (body, actions) => { - trackUsageRef.current(isCreating ? 'willCreateToken' : 'willEditToken', { + const handleSubmit = async ( + body: Pick & { + lifespan: Get.Response['data']['lifespan'] | undefined; + type: Get.Response['data']['type'] | undefined; + }, + actions: FormikHelpers< + Pick & { + lifespan: Get.Response['data']['lifespan'] | undefined; + type: Get.Response['data']['type'] | undefined; + } + > + ) => { + trackUsage(isCreating ? 'willCreateToken' : 'willEditToken', { tokenType: API_TOKEN_TYPE, }); + + // @ts-expect-error context assertation lockApp(); - const lifespanVal = - body.lifespan && parseInt(body.lifespan, 10) && body.lifespan !== '0' - ? parseInt(body.lifespan, 10) - : null; try { const { data: { data: response }, } = isCreating - ? await post(`/admin/api-tokens`, { - ...body, - lifespan: lifespanVal, - permissions: body.type === 'custom' ? state.selectedActions : null, - }) - : await put(`/admin/api-tokens/${id}`, { - name: body.name, - description: body.description, - type: body.type, - permissions: body.type === 'custom' ? state.selectedActions : null, - }); + ? await post, Create.Request['body']>( + `/admin/api-tokens`, + { + ...body, + // in case a token has a lifespan of "unlimited" the API only accepts zero as a number + lifespan: body.lifespan === '0' ? parseInt(body.lifespan) : null, + permissions: body.type === 'custom' ? state.selectedActions : null, + } + ) + : await put, Update.Request['body']>( + `/admin/api-tokens/${id}`, + { + name: body.name, + description: body.description, + type: body.type, + permissions: body.type === 'custom' ? state.selectedActions : null, + } + ); if (isCreating) { history.replace(`/settings/api-tokens/${response.id}`, { apiToken: response }); setCurrentStep('apiTokens.success'); } + + // @ts-expect-error context assertation unlockApp(); + setApiToken({ ...response, }); @@ -207,32 +250,40 @@ const ApiTokenCreateView = () => { }), }); - trackUsageRef.current(isCreating ? 'didCreateToken' : 'didEditToken', { - type: apiToken.type, - tokenType: API_TOKEN_TYPE, - }); - } catch (err) { - const errors = formatAPIErrors(err.response.data); - actions.setErrors(errors); - - if (err?.response?.data?.error?.message === MSG_ERROR_NAME_TAKEN) { - toggleNotification({ - type: 'warning', - message: err.response.data.message || 'notification.error.tokennamenotunique', - }); - } else { - toggleNotification({ - type: 'warning', - message: err?.response?.data?.message || 'notification.error', + if (apiToken?.type) { + trackUsage(isCreating ? 'didCreateToken' : 'didEditToken', { + type: apiToken.type, + tokenType: API_TOKEN_TYPE, }); } + } catch (err) { + if (err instanceof AxiosError && err.response) { + const errors = formatAPIErrors(err.response.data); + actions.setErrors(errors); + + if (err?.response?.data?.error?.message === MSG_ERROR_NAME_TAKEN) { + toggleNotification({ + type: 'warning', + message: err.response.data.message || 'notification.error.tokennamenotunique', + }); + } else { + toggleNotification({ + type: 'warning', + message: err?.response?.data?.message || 'notification.error', + }); + } + } + + // @ts-expect-error context assertation unlockApp(); } }; - const [hasChangedPermissions, setHasChangedPermissions] = useState(false); + const [hasChangedPermissions, setHasChangedPermissions] = React.useState(false); - const handleChangeCheckbox = ({ target: { value } }) => { + const handleChangeCheckbox = ({ + target: { value }, + }: Parameters[0]) => { setHasChangedPermissions(true); dispatch({ type: 'ON_CHANGE', @@ -240,7 +291,9 @@ const ApiTokenCreateView = () => { }); }; - const handleChangeSelectAllCheckbox = ({ target: { value } }) => { + const handleChangeSelectAllCheckbox = ({ + target: { value }, + }: Parameters[0]) => { setHasChangedPermissions(true); dispatch({ type: 'SELECT_ALL_IN_PERMISSION', @@ -248,7 +301,9 @@ const ApiTokenCreateView = () => { }); }; - const setSelectedAction = ({ target: { value } }) => { + const setSelectedAction = ({ + target: { value }, + }: Parameters[0]) => { dispatch({ type: 'SET_SELECTED_ACTION', value, @@ -266,6 +321,7 @@ const ApiTokenCreateView = () => { const isLoading = !isCreating && !apiToken && status !== 'success'; if (isLoading) { + // @ts-expect-error this is probably fine for now return ; } @@ -339,4 +395,12 @@ const ApiTokenCreateView = () => { ); }; -export default ApiTokenCreateView; +export const ProtectedEditView = () => { + const permissions = useSelector(selectAdminPermissions); + + return ( + + + + ); +}; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ActionBoundRoutes/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ActionBoundRoutes.tsx similarity index 71% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ActionBoundRoutes/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ActionBoundRoutes.tsx index 1958338994..a81321b301 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ActionBoundRoutes/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ActionBoundRoutes.tsx @@ -1,12 +1,11 @@ -import React from 'react'; - import { Flex, GridItem, Typography } from '@strapi/design-system'; import { useIntl } from 'react-intl'; -import { useApiTokenPermissions } from '../../../../../../../contexts/apiTokenPermissions'; -import BoundRoute from '../BoundRoute'; +import { useApiTokenPermissions } from '../../../../../../contexts/apiTokenPermissions'; -const ActionBoundRoutes = () => { +import { BoundRoute } from './BoundRoute'; + +export const ActionBoundRoutes = () => { const { value: { selectedAction, routes }, } = useApiTokenPermissions(); @@ -25,12 +24,14 @@ const ActionBoundRoutes = () => { > {selectedAction ? ( - {routes[actionSection]?.map((route) => { - return route.config.auth?.scope?.includes(selectedAction) || - route.handler === selectedAction ? ( - - ) : null; - })} + {actionSection && + actionSection in routes && + routes[actionSection].map((route) => { + return route.config.auth?.scope?.includes(selectedAction) || + route.handler === selectedAction ? ( + + ) : null; + })} ) : ( @@ -52,5 +53,3 @@ const ActionBoundRoutes = () => { ); }; - -export default ActionBoundRoutes; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute.tsx similarity index 59% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute.tsx index 0f3467bc5a..14d456b508 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute.tsx @@ -3,18 +3,77 @@ import React from 'react'; import { Box, Flex, Typography } from '@strapi/design-system'; import map from 'lodash/map'; import tail from 'lodash/tail'; -import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import styled from 'styled-components'; +import styled, { DefaultTheme } from 'styled-components'; -import getMethodColor from './getMethodColor'; +type HttpVerb = 'POST' | 'GET' | 'PUT' | 'DELETE'; + +type MethodColor = { + text: keyof DefaultTheme['colors']; + border: keyof DefaultTheme['colors']; + background: keyof DefaultTheme['colors']; +}; + +const getMethodColor = (verb: HttpVerb): MethodColor => { + switch (verb) { + case 'POST': { + return { + text: 'success600', + border: 'success200', + background: 'success100', + }; + } + case 'GET': { + return { + text: 'secondary600', + border: 'secondary200', + background: 'secondary100', + }; + } + case 'PUT': { + return { + text: 'warning600', + border: 'warning200', + background: 'warning100', + }; + } + case 'DELETE': { + return { + text: 'danger600', + border: 'danger200', + background: 'danger100', + }; + } + default: { + return { + text: 'neutral600', + border: 'neutral200', + background: 'neutral100', + }; + } + } +}; const MethodBox = styled(Box)` margin: -1px; border-radius: ${({ theme }) => theme.spaces[1]} 0 0 ${({ theme }) => theme.spaces[1]}; `; -function BoundRoute({ route }) { +interface BoundRouteProps { + route: { + handler: string; + method: HttpVerb; + path: string; + }; +} + +export const BoundRoute = ({ + route = { + handler: 'Nocontroller.error', + method: 'GET', + path: '/there-is-no-path', + }, +}: BoundRouteProps) => { const { formatMessage } = useIntl(); const { method, handler: title, path } = route; @@ -51,22 +110,4 @@ function BoundRoute({ route }) { ); -} - -BoundRoute.defaultProps = { - route: { - handler: 'Nocontroller.error', - method: 'GET', - path: '/there-is-no-path', - }, }; - -BoundRoute.propTypes = { - route: PropTypes.shape({ - handler: PropTypes.string, - method: PropTypes.string, - path: PropTypes.string, - }), -}; - -export default BoundRoute; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute/getMethodColor.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute/getMethodColor.js deleted file mode 100644 index 1ad903b389..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/BoundRoute/getMethodColor.js +++ /dev/null @@ -1,41 +0,0 @@ -const getMethodColor = (verb) => { - switch (verb) { - case 'POST': { - return { - text: 'success600', - border: 'success200', - background: 'success100', - }; - } - case 'GET': { - return { - text: 'secondary600', - border: 'secondary200', - background: 'secondary100', - }; - } - case 'PUT': { - return { - text: 'warning600', - border: 'warning200', - background: 'warning100', - }; - } - case 'DELETE': { - return { - text: 'danger600', - border: 'danger200', - background: 'danger100', - }; - } - default: { - return { - text: 'neutral600', - border: 'neutral200', - background: 'neutral100', - }; - } - } -}; - -export default getMethodColor; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollapsableContentType/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollabsableContentType.tsx similarity index 73% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollapsableContentType/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollabsableContentType.tsx index 139e577fe6..83d0be4d5d 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollapsableContentType/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollabsableContentType.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import * as React from 'react'; import { Accordion, @@ -13,13 +13,37 @@ import { } from '@strapi/design-system'; import { Cog } from '@strapi/icons'; import capitalize from 'lodash/capitalize'; -import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; -import { useApiTokenPermissions } from '../../../../../../../contexts/apiTokenPermissions'; +import { ContentApiPermission } from '../../../../../../../../shared/contracts/content-api/permissions'; +import { useApiTokenPermissions } from '../../../../../../contexts/apiTokenPermissions'; -import CheckboxWrapper from './CheckBoxWrapper'; +const activeCheckboxWrapperStyles = css` + background: ${(props) => props.theme.colors.primary100}; + svg { + opacity: 1; + } +`; + +const CheckboxWrapper = styled(Box)<{ isActive: boolean }>` + display: flex; + justify-content: space-between; + align-items: center; + + svg { + opacity: 0; + path { + fill: ${(props) => props.theme.colors.primary600}; + } + } + + /* Show active style both on hover and when the action is selected */ + ${(props) => props.isActive && activeCheckboxWrapperStyles} + &:hover { + ${activeCheckboxWrapperStyles} + } +`; const Border = styled.div` flex: 1; @@ -27,18 +51,27 @@ const Border = styled.div` border-top: 1px solid ${({ theme }) => theme.colors.neutral150}; `; -const CollapsableContentType = ({ - controllers, +interface CollapsableContentTypeProps { + controllers?: ContentApiPermission['controllers']; + label: ContentApiPermission['label']; + orderNumber?: number; + disabled?: boolean; + onExpanded?: (orderNumber: number) => void; + indexExpandendCollapsedContent: number | null; +} + +export const CollapsableContentType = ({ + controllers = [], label, - orderNumber, - disabled, - onExpanded, - indexExpandendCollapsedContent, -}) => { + orderNumber = 0, + disabled = false, + onExpanded = () => null, + indexExpandendCollapsedContent = null, +}: CollapsableContentTypeProps) => { const { value: { onChangeSelectAll, onChange, selectedActions, setSelectedAction, selectedAction }, } = useApiTokenPermissions(); - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = React.useState(false); const { formatMessage } = useIntl(); const handleExpandedAccordion = () => { @@ -46,7 +79,7 @@ const CollapsableContentType = ({ onExpanded(orderNumber); }; - useEffect(() => { + React.useEffect(() => { if ( indexExpandendCollapsedContent !== null && indexExpandendCollapsedContent !== orderNumber && @@ -56,7 +89,7 @@ const CollapsableContentType = ({ } }, [indexExpandendCollapsedContent, orderNumber, expanded]); - const isActionSelected = (actionId) => actionId === selectedAction; + const isActionSelected = (actionId: string) => actionId === selectedAction; return ( ); }; - -CollapsableContentType.defaultProps = { - controllers: [], - orderNumber: 0, - disabled: false, - onExpanded: () => null, - indexExpandendCollapsedContent: null, -}; - -CollapsableContentType.propTypes = { - controllers: PropTypes.array, - orderNumber: PropTypes.number, - label: PropTypes.string.isRequired, - disabled: PropTypes.bool, - onExpanded: PropTypes.func, - indexExpandendCollapsedContent: PropTypes.number, -}; - -export default CollapsableContentType; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollapsableContentType/CheckBoxWrapper.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollapsableContentType/CheckBoxWrapper.js deleted file mode 100644 index e3165e258d..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/CollapsableContentType/CheckBoxWrapper.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Box } from '@strapi/design-system'; -import styled, { css } from 'styled-components'; - -const activeCheckboxWrapperStyles = css` - background: ${(props) => props.theme.colors.primary100}; - svg { - opacity: 1; - } -`; - -const CheckboxWrapper = styled(Box)` - display: flex; - justify-content: space-between; - align-items: center; - - svg { - opacity: 0; - path { - fill: ${(props) => props.theme.colors.primary600}; - } - } - - /* Show active style both on hover and when the action is selected */ - ${(props) => props.isActive && activeCheckboxWrapperStyles} - &:hover { - ${activeCheckboxWrapperStyles} - } -`; - -export default CheckboxWrapper; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ContenTypesSection/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ContentTypesSection.tsx similarity index 52% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ContenTypesSection/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ContentTypesSection.tsx index c9e4383654..08776d4059 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ContenTypesSection/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/ContentTypesSection.tsx @@ -1,13 +1,21 @@ -import React, { useState } from 'react'; +import * as React from 'react'; import { Box } from '@strapi/design-system'; -import PropTypes from 'prop-types'; -import CollapsableContentType from '../CollapsableContentType'; +import { ContentApiPermission } from '../../../../../../../../shared/contracts/content-api/permissions'; -const ContentTypesSection = ({ section, ...props }) => { - const [indexExpandedCollpsedContent, setIndexExpandedCollpsedContent] = useState(null); - const handleExpandedCollpsedContentIndex = (index) => setIndexExpandedCollpsedContent(index); +import { CollapsableContentType } from './CollabsableContentType'; + +interface ContentTypesSectionProps { + section: ContentApiPermission[] | null; +} + +export const ContentTypesSection = ({ section = null, ...props }: ContentTypesSectionProps) => { + const [indexExpandedCollpsedContent, setIndexExpandedCollpsedContent] = React.useState< + null | number + >(null); + const handleExpandedCollpsedContentIndex = (index: number) => + setIndexExpandedCollpsedContent(index); return ( @@ -20,20 +28,9 @@ const ContentTypesSection = ({ section, ...props }) => { orderNumber={index} indexExpandendCollapsedContent={indexExpandedCollpsedContent} onExpanded={handleExpandedCollpsedContentIndex} - name={api.apiId} {...props} /> ))} ); }; - -ContentTypesSection.defaultProps = { - section: null, -}; - -ContentTypesSection.propTypes = { - section: PropTypes.arrayOf(PropTypes.object), -}; - -export default ContentTypesSection; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/FormApiTokenContainer/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/FormApiTokenContainer.tsx similarity index 63% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/FormApiTokenContainer/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/FormApiTokenContainer.tsx index f0bfe1ea20..b7f8e8e58d 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/FormApiTokenContainer/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/FormApiTokenContainer.tsx @@ -1,27 +1,50 @@ -import React from 'react'; +import * as React from 'react'; import { Box, Flex, Grid, GridItem, Typography } from '@strapi/design-system'; -import PropTypes from 'prop-types'; +import { FormikErrors } from 'formik'; import { useIntl } from 'react-intl'; -import LifeSpanInput from '../../../../../components/Tokens/LifeSpanInput'; -import TokenDescription from '../../../../../components/Tokens/TokenDescription'; -import TokenName from '../../../../../components/Tokens/TokenName'; -import TokenTypeSelect from '../../../../../components/Tokens/TokenTypeSelect'; +// @ts-expect-error not converted yet +import LifeSpanInput from '../../../../components/Tokens/LifeSpanInput'; +// @ts-expect-error not converted yet +import TokenDescription from '../../../../components/Tokens/TokenDescription'; +// @ts-expect-error not converted yet +import TokenName from '../../../../components/Tokens/TokenName'; +// @ts-expect-error not converted yet +import TokenTypeSelect from '../../../../components/Tokens/TokenTypeSelect'; -const FormApiTokenContainer = ({ - errors, +type TokenType = 'full-access' | 'read-only' | 'custom'; + +import type { ApiToken } from '../../../../../../../../shared/contracts/api-token'; + +interface FormApiTokenContainerProps { + errors?: FormikErrors>; + onChange: ({ target: { name, value } }: { target: { name: string; value: TokenType } }) => void; + canEditInputs: boolean; + values: undefined | Partial>; + isCreating: boolean; + apiToken?: null | Partial; + onDispatch: React.Dispatch; + setHasChangedPermissions: (hasChanged: boolean) => void; +} + +export const FormApiTokenContainer = ({ + errors = {}, onChange, canEditInputs, isCreating, values, - apiToken, + apiToken = {}, onDispatch, setHasChangedPermissions, -}) => { +}: FormApiTokenContainerProps) => { const { formatMessage } = useIntl(); - const handleChangeSelectApiTokenType = ({ target: { value } }) => { + const handleChangeSelectApiTokenType = ({ + target: { value }, + }: { + target: { value: TokenType }; + }) => { setHasChangedPermissions(false); if (value === 'full-access') { @@ -112,7 +135,7 @@ const FormApiTokenContainer = ({ id: 'Settings.tokens.form.type', defaultMessage: 'Token type', }} - onChange={(value) => { + onChange={(value: TokenType) => { handleChangeSelectApiTokenType({ target: { value } }); onChange({ target: { name: 'type', value } }); }} @@ -125,40 +148,3 @@ const FormApiTokenContainer = ({ ); }; - -FormApiTokenContainer.propTypes = { - errors: PropTypes.shape({ - name: PropTypes.string, - description: PropTypes.string, - lifespan: PropTypes.string, - type: PropTypes.string, - }), - onChange: PropTypes.func.isRequired, - canEditInputs: PropTypes.bool.isRequired, - values: PropTypes.shape({ - name: PropTypes.string, - description: PropTypes.string, - lifespan: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - type: PropTypes.string, - }).isRequired, - isCreating: PropTypes.bool.isRequired, - apiToken: PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - type: PropTypes.string, - lifespan: PropTypes.string, - name: PropTypes.string, - accessKey: PropTypes.string, - permissions: PropTypes.array, - description: PropTypes.string, - createdAt: PropTypes.string, - }), - onDispatch: PropTypes.func.isRequired, - setHasChangedPermissions: PropTypes.func.isRequired, -}; - -FormApiTokenContainer.defaultProps = { - errors: {}, - apiToken: {}, -}; - -export default FormApiTokenContainer; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/LoadingView/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/LoadingView.tsx similarity index 78% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/LoadingView/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/LoadingView.tsx index 6c01c069b7..286a0389c0 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/LoadingView/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/LoadingView.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Button, ContentLayout, HeaderLayout, Main } from '@strapi/design-system'; import { LoadingIndicatorPage, @@ -7,10 +5,13 @@ import { useFocusWhenNavigate, } from '@strapi/helper-plugin'; import { Check } from '@strapi/icons'; -import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -const LoadingView = ({ apiTokenName }) => { +interface LoadingViewProps { + apiTokenName?: string | null; +} + +export const LoadingView = ({ apiTokenName = null }: LoadingViewProps) => { const { formatMessage } = useIntl(); useFocusWhenNavigate(); @@ -37,13 +38,3 @@ const LoadingView = ({ apiTokenName }) => { ); }; - -LoadingView.defaultProps = { - apiTokenName: null, -}; - -LoadingView.propTypes = { - apiTokenName: PropTypes.string, -}; - -export default LoadingView; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Permissions/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Permissions.tsx similarity index 78% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Permissions/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Permissions.tsx index b791883d08..a703eba111 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Permissions/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Permissions.tsx @@ -1,13 +1,12 @@ -import React, { memo } from 'react'; - import { Flex, Grid, GridItem, Typography } from '@strapi/design-system'; import { useIntl } from 'react-intl'; -import { useApiTokenPermissions } from '../../../../../../../contexts/apiTokenPermissions'; -import ActionBoundRoutes from '../ActionBoundRoutes'; -import ContentTypesSection from '../ContenTypesSection'; +import { useApiTokenPermissions } from '../../../../../../contexts/apiTokenPermissions'; -const Permissions = ({ ...props }) => { +import { ActionBoundRoutes } from './ActionBoundRoutes'; +import { ContentTypesSection } from './ContentTypesSection'; + +export const Permissions = ({ ...props }) => { const { value: { data }, } = useApiTokenPermissions(); @@ -36,5 +35,3 @@ const Permissions = ({ ...props }) => { ); }; - -export default memo(Permissions); diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Regenerate/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Regenerate.tsx similarity index 76% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Regenerate/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Regenerate.tsx index 6de90778fa..9f25bba279 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Regenerate/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/components/Regenerate.tsx @@ -1,16 +1,20 @@ -import React, { useState } from 'react'; +import * as React from 'react'; import { Button } from '@strapi/design-system'; import { ConfirmDialog } from '@strapi/helper-plugin'; import { Refresh } from '@strapi/icons'; -import PropTypes from 'prop-types'; import { useIntl } from 'react-intl'; -import { useRegenerate } from '../../../../../hooks/useRegenerate'; +import { useRegenerate } from '../../../../hooks/useRegenerate'; -export const Regenerate = ({ onRegenerate, idToRegenerate }) => { +interface RegenerateProps { + onRegenerate?: () => void; + idToRegenerate: string | number; +} + +export const Regenerate = ({ onRegenerate = () => {}, idToRegenerate }: RegenerateProps) => { const { formatMessage } = useIntl(); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = React.useState(false); const { regenerateData, isLoadingConfirmation } = useRegenerate( '/admin/api-tokens/', idToRegenerate, @@ -63,12 +67,3 @@ export const Regenerate = ({ onRegenerate, idToRegenerate }) => { ); }; - -Regenerate.defaultProps = { onRegenerate() {} }; - -Regenerate.propTypes = { - onRegenerate: PropTypes.func, - idToRegenerate: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, -}; - -export default Regenerate; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/schema.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/constants.ts similarity index 63% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/schema.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/constants.ts index 5e1621102f..a0747fe31b 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/schema.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/constants.ts @@ -1,14 +1,12 @@ import { translatedErrors } from '@strapi/helper-plugin'; import * as yup from 'yup'; -const schema = yup.object().shape({ - name: yup.string(translatedErrors.string).max(100).required(translatedErrors.required), +export const schema = yup.object().shape({ + name: yup.string().max(100).required(translatedErrors.required), type: yup - .string(translatedErrors.string) + .string() .oneOf(['read-only', 'full-access', 'custom']) .required(translatedErrors.required), description: yup.string().nullable(), lifespan: yup.number().integer().min(0).nullable().defined(translatedErrors.required), }); - -export default schema; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/init.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/init.js deleted file mode 100644 index a9ba8b9377..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/init.js +++ /dev/null @@ -1,13 +0,0 @@ -import { transformPermissionsData } from './utils'; - -const init = (state, permissions = []) => { - return { - ...state, - selectedAction: null, - routes: [], - selectedActions: [], - data: transformPermissionsData(permissions), - }; -}; - -export default init; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/reducer.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/reducer.ts similarity index 53% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/reducer.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/reducer.ts index 160a505186..655eee6235 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/reducer.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/reducer.ts @@ -2,14 +2,75 @@ import produce from 'immer'; import pull from 'lodash/pull'; -import { transformPermissionsData } from './utils'; +import { ContentApiPermission } from '../../../../../../../shared/contracts/content-api/permissions'; +import { ApiTokenPermissionsContextValue } from '../../../../../contexts/apiTokenPermissions'; -export const initialState = { - data: {}, +import { transformPermissionsData } from './utils/transformPermissionsData'; + +type InitialState = Pick< + ApiTokenPermissionsContextValue['value'], + 'data' | 'routes' | 'selectedAction' | 'selectedActions' +>; + +interface ActionOnChange { + type: 'ON_CHANGE'; + value: string; +} + +interface ActionSelectAllInPermission { + type: 'SELECT_ALL_IN_PERMISSION'; + value: { action: string; actionId: string }[]; +} + +interface ActionSelectAllActions { + type: 'SELECT_ALL_ACTIONS'; +} + +interface ActionOnChangeReadOnly { + type: 'ON_CHANGE_READ_ONLY'; +} + +interface ActionUpdatePermissionsLayout { + type: 'UPDATE_PERMISSIONS_LAYOUT'; + value: ContentApiPermission; +} + +interface ActionUpdateRoutes { + type: 'UPDATE_ROUTES'; + value: ApiTokenPermissionsContextValue['value']['routes'] | undefined; +} + +interface ActionUpdatePermissions { + type: 'UPDATE_PERMISSIONS'; + value: any[]; +} + +interface ActionSetSelectedAction { + type: 'SET_SELECTED_ACTION'; + value: string; +} + +type Action = + | ActionOnChange + | ActionSelectAllInPermission + | ActionSelectAllActions + | ActionOnChangeReadOnly + | ActionUpdatePermissionsLayout + | ActionUpdateRoutes + | ActionUpdatePermissions + | ActionSetSelectedAction; + +export const initialState: InitialState = { + data: { + allActionsIds: [], + permissions: [], + }, + routes: {}, + selectedAction: '', selectedActions: [], }; -const reducer = (state, action) => +export const reducer = (state: typeof initialState, action: Action) => produce(state, (draftState) => { switch (action.type) { case 'ON_CHANGE': { @@ -69,5 +130,3 @@ const reducer = (state, action) => return draftState; } }); - -export default reducer; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/index.test.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/index.test.js index 18ac130492..612b6f7af7 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/index.test.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/index.test.js @@ -11,8 +11,22 @@ import { Route, MemoryRouter } from 'react-router-dom'; import { Theme } from '../../../../../../components/Theme'; import { ThemeToggleProvider } from '../../../../../../components/ThemeToggleProvider'; -import EditView from '../index'; -import { data } from '../utils/tests/dataMock'; +import { EditView } from '../EditViewPage'; + +const data = { + data: { + 'api::address': { + controllers: { + address: ['find', 'findOne'], + }, + }, + 'api::category': { + controllers: { + category: ['find', 'findOne', 'create', 'update', 'delete', 'createLocalization'], + }, + }, + }, +}; jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/reducer.test.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/reducer.test.js index bf0690f65b..adefe30860 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/reducer.test.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/tests/reducer.test.js @@ -1,9 +1,24 @@ -import init from '../init'; -import reducer from '../reducer'; -import { data } from '../utils/tests/dataMock'; +import { reducer } from '../reducer'; +import { transformPermissionsData } from '../utils/transformPermissionsData'; describe('ADMIN | Pages | API TOKENS | EditView | reducer', () => { - const initialState = init({}, data.data); + const initialState = { + selectedAction: null, + routes: [], + selectedActions: [], + data: transformPermissionsData({ + 'api::address': { + controllers: { + address: ['find', 'findOne'], + }, + }, + 'api::category': { + controllers: { + category: ['find', 'findOne', 'create', 'update', 'delete', 'createLocalization'], + }, + }, + }), + }; it('should return the initialState when the type is undefined', () => { const action = { type: undefined }; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/getDateOfExpiration.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/getDateOfExpiration.ts similarity index 66% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/getDateOfExpiration.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/getDateOfExpiration.ts index b5d3439ef9..002acb5291 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/getDateOfExpiration.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/getDateOfExpiration.ts @@ -1,16 +1,19 @@ import { addDays, format } from 'date-fns'; import * as locales from 'date-fns/locale'; -const getDateOfExpiration = (createdAt, duration, language = 'en') => { +export const getDateOfExpiration = ( + createdAt: string, + duration: number | null, + language: string = 'en' +) => { if (duration && typeof duration === 'number') { const durationInDays = duration / 24 / 60 / 60 / 1000; return format(addDays(new Date(createdAt), durationInDays), 'PPP', { + // @ts-expect-error I don't know how to fix this locale: locales[language], }); } return 'Unlimited'; }; - -export default getDateOfExpiration; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/index.js deleted file mode 100644 index b88422c87e..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import getDateOfExpiration from './getDateOfExpiration'; -import schema from './schema'; -import transformPermissionsData from './transformPermissionsData'; - -export { getDateOfExpiration, schema, transformPermissionsData }; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/dataMock.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/dataMock.js deleted file mode 100644 index 8f23118000..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/dataMock.js +++ /dev/null @@ -1,14 +0,0 @@ -export const data = { - data: { - 'api::address': { - controllers: { - address: ['find', 'findOne'], - }, - }, - 'api::category': { - controllers: { - category: ['find', 'findOne', 'create', 'update', 'delete', 'createLocalization'], - }, - }, - }, -}; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/getDateOfExpiration.test.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/getDateOfExpiration.test.js index 5cbc3d8b28..bfc12ac5e2 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/getDateOfExpiration.test.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/getDateOfExpiration.test.js @@ -1,4 +1,4 @@ -import getDateOfExpiration from '../getDateOfExpiration'; +import { getDateOfExpiration } from '../getDateOfExpiration'; const createdAt = '2022-07-05T12:16:56.821Z'; const duration = 604800000; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/transformPermissionsData.test.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/transformPermissionsData.test.js index af7f99984d..f5c0534097 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/transformPermissionsData.test.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/tests/transformPermissionsData.test.js @@ -1,6 +1,19 @@ -import transformPermissionsData from '../transformPermissionsData'; +import { transformPermissionsData } from '../transformPermissionsData'; -import { data } from './dataMock'; +const data = { + data: { + 'api::address': { + controllers: { + address: ['find', 'findOne'], + }, + }, + 'api::category': { + controllers: { + category: ['find', 'findOne', 'create', 'update', 'delete', 'createLocalization'], + }, + }, + }, +}; describe('ADMIN | Container | SettingsPage | ApiTokens | EditView | utils | transformPermissionsData', () => { it('should return transformed data correctly', () => { diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/transformPermissionsData.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/transformPermissionsData.js deleted file mode 100644 index c6a8e5b8cb..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/transformPermissionsData.js +++ /dev/null @@ -1,34 +0,0 @@ -const transformPermissionsData = (data) => { - const layout = { - allActionsIds: [], - permissions: [], - }; - - layout.permissions = Object.keys(data).map((apiId) => ({ - apiId, - label: apiId.split('::')[1], - controllers: Object.keys(data[apiId].controllers) - .map((controller) => ({ - controller, - actions: data[apiId].controllers[controller] - .map((action) => { - const actionId = `${apiId}.${controller}.${action}`; - - if (apiId.includes('api::')) { - layout.allActionsIds.push(actionId); - } - - return { - action, - actionId, - }; - }) - .flat(), - })) - .flat(), - })); - - return layout; -}; - -export default transformPermissionsData; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/transformPermissionsData.ts b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/transformPermissionsData.ts new file mode 100644 index 0000000000..7d4fdb1b98 --- /dev/null +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/EditView/utils/transformPermissionsData.ts @@ -0,0 +1,46 @@ +import { ContentApiPermission } from '../../../../../../../../shared/contracts/content-api/permissions'; + +interface Layout { + allActionsIds: string[]; + permissions: { + apiId: string; + label: string; + controllers: { controller: string; actions: { action: string; actionId: string }[] }[]; + }[]; +} + +export const transformPermissionsData = (data: ContentApiPermission) => { + const layout: Layout = { + allActionsIds: [], + permissions: [], + }; + + layout.permissions = Object.entries(data).map(([apiId, permission]) => ({ + apiId, + label: apiId.split('::')[1], + controllers: Object.keys(permission.controllers) + .map((controller) => ({ + controller, + actions: + controller in permission.controllers + ? permission.controllers[controller] + .map((action: ContentApiPermission['controllers']) => { + const actionId = `${apiId}.${controller}.${action}`; + + if (apiId.includes('api::')) { + layout.allActionsIds.push(actionId); + } + + return { + action, + actionId, + }; + }) + .flat() + : [], + })) + .flat(), + })); + + return layout; +}; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx similarity index 69% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/index.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx index 4f17cb79cd..90ca473079 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/index.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { ContentLayout, HeaderLayout, Main } from '@strapi/design-system'; +import { ContentLayout, HeaderLayout, LinkButton, Main } from '@strapi/design-system'; import { - LinkButton, + CheckPagePermissions, NoContent, NoPermissions, SettingsPageTitle, @@ -15,19 +15,68 @@ import { useTracking, } from '@strapi/helper-plugin'; import { Plus } from '@strapi/icons'; +import { AxiosError } from 'axios'; import qs from 'qs'; import { useIntl } from 'react-intl'; import { useMutation, useQuery, useQueryClient } from 'react-query'; import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { selectAdminPermissions } from '../../../../../selectors'; -import { API_TOKEN_TYPE } from '../../../components/Tokens/constants'; -import Table from '../../../components/Tokens/Table'; +import { selectAdminPermissions } from '../../../../selectors'; +import { API_TOKEN_TYPE } from '../../components/Tokens/constants'; +// @ts-expect-error not converted yet +import Table from '../../components/Tokens/Table'; -import tableHeaders from './utils/tableHeaders'; +import type { List } from '../../../../../../shared/contracts/api-token'; -const ApiTokenListView = () => { +const TABLE_HEADERS = [ + { + name: 'name', + key: 'name', + metadatas: { + label: { + id: 'Settings.apiTokens.ListView.headers.name', + defaultMessage: 'Name', + }, + sortable: true, + }, + }, + { + name: 'description', + key: 'description', + metadatas: { + label: { + id: 'Settings.apiTokens.ListView.headers.description', + defaultMessage: 'Description', + }, + sortable: false, + }, + }, + { + name: 'createdAt', + key: 'createdAt', + metadatas: { + label: { + id: 'Settings.apiTokens.ListView.headers.createdAt', + defaultMessage: 'Created at', + }, + sortable: false, + }, + }, + { + name: 'lastUsedAt', + key: 'lastUsedAt', + metadatas: { + label: { + id: 'Settings.apiTokens.ListView.headers.lastUsedAt', + defaultMessage: 'Last used', + }, + sortable: false, + }, + }, +]; + +export const ListView = () => { useFocusWhenNavigate(); const queryClient = useQueryClient(); const { formatMessage } = useIntl(); @@ -35,7 +84,7 @@ const ApiTokenListView = () => { const permissions = useSelector(selectAdminPermissions); const { allowedActions: { canCreate, canDelete, canUpdate, canRead }, - } = useRBAC(permissions.settings['api-tokens']); + } = useRBAC(permissions.settings?.['api-tokens']); const { push } = useHistory(); const { trackUsage } = useTracking(); const { startSection } = useGuidedTour(); @@ -53,7 +102,7 @@ const ApiTokenListView = () => { push({ search: qs.stringify({ sort: 'name:ASC' }, { encode: false }) }); }, [push]); - const headers = tableHeaders.map((header) => ({ + const headers = TABLE_HEADERS.map((header) => ({ ...header, metadatas: { ...header.metadatas, @@ -70,7 +119,7 @@ const ApiTokenListView = () => { const { data: { data }, - } = await get(`/admin/api-tokens`); + } = await get(`/admin/api-tokens`); trackUsage('didAccessTokenList', { number: data.length, tokenType: API_TOKEN_TYPE }); @@ -80,10 +129,12 @@ const ApiTokenListView = () => { cacheTime: 0, enabled: canRead, onError(error) { - toggleNotification({ - type: 'warning', - message: formatAPIError(error), - }); + if (error instanceof AxiosError) { + toggleNotification({ + type: 'warning', + message: formatAPIError(error), + }); + } }, } ); @@ -100,7 +151,9 @@ const ApiTokenListView = () => { trackUsage('didDeleteToken'); }, onError(error) { - toggleNotification({ type: 'warning', message: formatAPIError(error) }); + if (error instanceof AxiosError) { + toggleNotification({ type: 'warning', message: formatAPIError(error) }); + } }, } ); @@ -150,6 +203,7 @@ const ApiTokenListView = () => { contentType="api-tokens" rows={apiTokens} isLoading={isLoading} + // @ts-expect-error not converted yet onConfirmDelete={(id) => deleteMutation.mutateAsync(id)} tokens={apiTokens} tokenType={API_TOKEN_TYPE} @@ -184,4 +238,12 @@ const ApiTokenListView = () => { ); }; -export default ApiTokenListView; +export const ProtectedListView = () => { + const permissions = useSelector(selectAdminPermissions); + + return ( + + + + ); +}; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/utils/tableHeaders.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/utils/tableHeaders.js deleted file mode 100644 index a1fc8b170c..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/utils/tableHeaders.js +++ /dev/null @@ -1,48 +0,0 @@ -const tableHeaders = [ - { - name: 'name', - key: 'name', - metadatas: { - label: { - id: 'Settings.apiTokens.ListView.headers.name', - defaultMessage: 'Name', - }, - sortable: true, - }, - }, - { - name: 'description', - key: 'description', - metadatas: { - label: { - id: 'Settings.apiTokens.ListView.headers.description', - defaultMessage: 'Description', - }, - sortable: false, - }, - }, - { - name: 'createdAt', - key: 'createdAt', - metadatas: { - label: { - id: 'Settings.apiTokens.ListView.headers.createdAt', - defaultMessage: 'Created at', - }, - sortable: false, - }, - }, - { - name: 'lastUsedAt', - key: 'lastUsedAt', - metadatas: { - label: { - id: 'Settings.apiTokens.ListView.headers.lastUsedAt', - defaultMessage: 'Last used', - }, - sortable: false, - }, - }, -]; - -export default tableHeaders; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedCreateView/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedCreateView/index.js deleted file mode 100644 index ef77644990..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedCreateView/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { CheckPagePermissions } from '@strapi/helper-plugin'; -import { useSelector } from 'react-redux'; - -import { selectAdminPermissions } from '../../../../../selectors'; -import EditView from '../EditView'; - -const ProtectedApiTokenCreateView = () => { - const permissions = useSelector(selectAdminPermissions); - - return ( - - - - ); -}; - -export default ProtectedApiTokenCreateView; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedEditView/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedEditView/index.js deleted file mode 100644 index f1a643c941..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedEditView/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { CheckPagePermissions } from '@strapi/helper-plugin'; -import { useSelector } from 'react-redux'; - -import { selectAdminPermissions } from '../../../../../selectors'; -import EditView from '../EditView'; - -const ProtectedApiTokenCreateView = () => { - const permissions = useSelector(selectAdminPermissions); - - return ( - - - - ); -}; - -export default ProtectedApiTokenCreateView; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedListView/index.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedListView/index.js deleted file mode 100644 index 5d61fc72bb..0000000000 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ProtectedListView/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -import { CheckPagePermissions } from '@strapi/helper-plugin'; -import { useSelector } from 'react-redux'; - -import { selectAdminPermissions } from '../../../../../selectors'; -import ListView from '../ListView'; - -const ProtectedApiTokenListView = () => { - const permissions = useSelector(selectAdminPermissions); - - return ( - - - - ); -}; - -export default ProtectedApiTokenListView; diff --git a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/tests/index.test.js b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/tests/ListView.test.tsx similarity index 85% rename from packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/tests/index.test.js rename to packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/tests/ListView.test.tsx index 0fde16603c..3ea01027ec 100644 --- a/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/ListView/tests/index.test.js +++ b/packages/core/admin/admin/src/pages/Settings/pages/ApiTokens/tests/ListView.test.tsx @@ -10,9 +10,9 @@ import { QueryClient, QueryClientProvider } from 'react-query'; import { Provider } from 'react-redux'; import { Route, MemoryRouter } from 'react-router-dom'; -import { Theme } from '../../../../../../components/Theme'; -import { ThemeToggleProvider } from '../../../../../../components/ThemeToggleProvider'; -import ListView from '../index'; +import { Theme } from '../../../../../components/Theme'; +import { ThemeToggleProvider } from '../../../../../components/ThemeToggleProvider'; +import { ListView } from '../ListView'; jest.mock('@strapi/helper-plugin', () => ({ ...jest.requireActual('@strapi/helper-plugin'), @@ -46,8 +46,6 @@ jest.mock('@strapi/helper-plugin', () => ({ }), })); -jest.spyOn(Date, 'now').mockImplementation(() => new Date('2015-10-01T08:00:00.000Z')); - // TO BE REMOVED: we have added this mock to prevent errors in the snapshots caused by the Unicode space character // before AM/PM in the dates, after the introduction of node 18.13 jest.mock('react-intl', () => { @@ -65,8 +63,8 @@ jest.mock('react-intl', () => { }; }); -const setup = ({ path, ...props } = {}) => - render(() => , { +const setup = ({ path = '/settings/api-tokens', ...props } = {}) => + render(, { wrapper({ children }) { const client = new QueryClient({ defaultOptions: { @@ -90,7 +88,7 @@ const setup = ({ path, ...props } = {}) => - + {children} @@ -109,6 +107,7 @@ describe('ADMIN | Pages | API TOKENS | ListPage', () => { }); it('should show a list of api tokens', async () => { + // @ts-expect-error this is fine useRBAC.mockReturnValue({ allowedActions: { canCreate: true, @@ -121,11 +120,14 @@ describe('ADMIN | Pages | API TOKENS | ListPage', () => { const { getByText } = setup({ path: '/settings/api-tokens' }); + // eslint-disable-next-line testing-library/prefer-find-by await waitFor(() => expect(getByText('My super token')).toBeInTheDocument()); + // eslint-disable-next-line testing-library/prefer-find-by await waitFor(() => expect(getByText('This describe my super token')).toBeInTheDocument()); }); it('should not show the create button when the user does not have the rights to create', async () => { + // @ts-expect-error this is fine useRBAC.mockReturnValue({ allowedActions: { canCreate: false, @@ -142,6 +144,7 @@ describe('ADMIN | Pages | API TOKENS | ListPage', () => { }); it('should show the delete button when the user have the rights to delete', async () => { + // @ts-expect-error this is fine useRBAC.mockReturnValue({ allowedActions: { canCreate: false, @@ -155,11 +158,13 @@ describe('ADMIN | Pages | API TOKENS | ListPage', () => { const { container } = setup(); await waitFor(() => + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access expect(container.querySelector('button[name="delete"]')).toBeInTheDocument() ); }); it('should show the read button when the user have the rights to read and not to update', async () => { + // @ts-expect-error this is fine useRBAC.mockReturnValue({ allowedActions: { canCreate: false, @@ -172,6 +177,7 @@ describe('ADMIN | Pages | API TOKENS | ListPage', () => { const { container } = setup(); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access await waitFor(() => expect(container.querySelector('a[title*="Read"]')).toBeInTheDocument()); }); }); diff --git a/packages/core/admin/admin/src/types/permissions.ts b/packages/core/admin/admin/src/types/permissions.ts index 46c8b7ec2f..60acb8d453 100644 --- a/packages/core/admin/admin/src/types/permissions.ts +++ b/packages/core/admin/admin/src/types/permissions.ts @@ -1,6 +1,12 @@ import type { Permission } from '@strapi/helper-plugin'; -type SettingsPermissions = 'project-settings' | 'users' | 'webhooks' | 'sso' | 'roles'; +type SettingsPermissions = + | 'api-tokens' + | 'project-settings' + | 'roles' + | 'sso' + | 'users' + | 'webhooks'; interface CRUDPermissions { main: Permission[]; diff --git a/packages/core/admin/server/src/services/api-token.ts b/packages/core/admin/server/src/services/api-token.ts index 78534c697c..9f85b46ae9 100644 --- a/packages/core/admin/server/src/services/api-token.ts +++ b/packages/core/admin/server/src/services/api-token.ts @@ -1,7 +1,7 @@ import crypto from 'crypto'; import { omit, difference, isNil, isEmpty, map, isArray, uniq } from 'lodash/fp'; import { errors } from '@strapi/utils'; -import type { Update } from '../../../shared/contracts/api-token'; +import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token'; import constants from './constants'; const { ValidationError, NotFoundError } = errors; @@ -9,23 +9,13 @@ const { ValidationError, NotFoundError } = errors; type ApiTokenPermission = { id: number | `${number}`; action: string; - token: ApiToken | number; + token: DBApiToken | number; }; -export type ApiToken = { - id?: number | `${number}`; - name: string; - description: string; - accessKey: string; - lastUsedAt: number; - lifespan: number | null; - expiresAt: number; - type: 'read-only' | 'full-access' | 'custom'; +type DBApiToken = ApiToken & { permissions: (number | ApiTokenPermission)[]; }; -export type CreateActionPayload = Omit; - const SELECT_FIELDS = [ 'id', 'name', @@ -45,23 +35,24 @@ const POPULATE_FIELDS = ['permissions']; /** * Assert that a token's permissions attribute is valid for its type */ -const assertCustomTokenPermissionsValidity = (attributes: CreateActionPayload) => { +const assertCustomTokenPermissionsValidity = ( + type: ApiTokenBody['type'], + permissions: ApiTokenBody['permissions'] +) => { // Ensure non-custom tokens doesn't have permissions - if (attributes.type !== constants.API_TOKEN_TYPE.CUSTOM && !isEmpty(attributes.permissions)) { + if (type !== constants.API_TOKEN_TYPE.CUSTOM && !isEmpty(permissions)) { throw new ValidationError('Non-custom tokens should not reference permissions'); } // Custom type tokens should always have permissions attached to them - if (attributes.type === constants.API_TOKEN_TYPE.CUSTOM && !isArray(attributes.permissions)) { + if (type === constants.API_TOKEN_TYPE.CUSTOM && !isArray(permissions)) { throw new ValidationError('Missing permissions attribute for custom token'); } // Permissions provided for a custom type token should be valid/registered permissions UID - if (attributes.type === constants.API_TOKEN_TYPE.CUSTOM) { + if (type === constants.API_TOKEN_TYPE.CUSTOM) { const validPermissions = strapi.contentAPI.permissions.providers.action.keys(); - // TODO difference requires that the 2 arguments are of the same type - // @ts-expect-error - Handle type miss match between string[] and number[] - const invalidPermissions = difference(attributes.permissions, validPermissions) as string[]; + const invalidPermissions = difference(permissions, validPermissions) as string[]; if (!isEmpty(invalidPermissions)) { throw new ValidationError(`Unknown permissions provided: ${invalidPermissions.join(', ')}`); @@ -72,12 +63,12 @@ const assertCustomTokenPermissionsValidity = (attributes: CreateActionPayload) = /** * Assert that a token's lifespan is valid */ -const assertValidLifespan = ({ lifespan }: CreateActionPayload) => { +const assertValidLifespan = (lifespan: ApiTokenBody['lifespan']) => { if (isNil(lifespan)) { return; } - if (!Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan)) { + if (!Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan as number)) { throw new ValidationError( `lifespan must be one of the following values: ${Object.values(constants.API_TOKEN_LIFESPANS).join(', ')}` @@ -88,7 +79,7 @@ const assertValidLifespan = ({ lifespan }: CreateActionPayload) => { /** * Flatten a token's database permissions objects to an array of strings */ -const flattenTokenPermissions = (token: ApiToken): ApiToken => { +const flattenTokenPermissions = (token: DBApiToken): ApiToken => { if (!token) { return token; } @@ -110,9 +101,7 @@ type WhereParams = { /** * Get a token */ -const getBy = async ( - whereParams: WhereParams = {} -): Promise | null> => { +const getBy = async (whereParams: WhereParams = {}): Promise => { if (Object.keys(whereParams).length === 0) { return null; } @@ -147,7 +136,7 @@ const hash = (accessKey: string) => { .digest('hex'); }; -const getExpirationFields = (lifespan: number | null) => { +const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => { // it must be nil or a finite number >= 0 const isValidNumber = Number.isFinite(lifespan) && (lifespan as number) > 0; if (!isValidNumber && !isNil(lifespan)) { @@ -156,18 +145,18 @@ const getExpirationFields = (lifespan: number | null) => { return { lifespan: lifespan || null, - expiresAt: lifespan ? Date.now() + lifespan : null, + expiresAt: lifespan ? Date.now() + (lifespan as number) : null, }; }; /** * Create a token and its permissions */ -const create = async (attributes: CreateActionPayload): Promise => { +const create = async (attributes: ApiTokenBody): Promise => { const accessKey = crypto.randomBytes(128).toString('hex'); - assertCustomTokenPermissionsValidity(attributes); - assertValidLifespan(attributes); + assertCustomTokenPermissionsValidity(attributes.type, attributes.permissions); + assertValidLifespan(attributes.lifespan); // Create the token const apiToken: ApiToken = await strapi.query('admin::api-token').create({ @@ -252,8 +241,8 @@ For security reasons, prefer storing the secret in an environment variable and r /** * Return a list of all tokens and their permissions */ -const list = async (): Promise>> => { - const tokens: Array = await strapi.query('admin::api-token').findMany({ +const list = async (): Promise> => { + const tokens: Array = await strapi.query('admin::api-token').findMany({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, orderBy: { name: 'ASC' }, @@ -269,7 +258,7 @@ const list = async (): Promise>> => { /** * Revoke (delete) a token */ -const revoke = async (id: string | number): Promise> => { +const revoke = async (id: string | number): Promise => { return strapi .query('admin::api-token') .delete({ select: SELECT_FIELDS, populate: POPULATE_FIELDS, where: { id } }); @@ -295,9 +284,11 @@ const getByName = async (name: string) => { const update = async ( id: string | number, attributes: Update.Request['body'] -): Promise> => { +): Promise => { // retrieve token without permissions - const originalToken: ApiToken = await strapi.query('admin::api-token').findOne({ where: { id } }); + const originalToken: DBApiToken = await strapi + .query('admin::api-token') + .findOne({ where: { id } }); if (!originalToken) { throw new NotFoundError('Token not found'); @@ -310,14 +301,13 @@ const update = async ( // if we're updating the permissions on any token type, or changing from non-custom to custom, ensure they're still valid // if neither type nor permissions are changing, we don't need to validate again or else we can't allow partial update if (attributes.permissions || changingTypeToCustom) { - assertCustomTokenPermissionsValidity({ - ...originalToken, - ...attributes, - type: attributes.type || originalToken.type, - }); + assertCustomTokenPermissionsValidity( + attributes.type || originalToken.type, + attributes.permissions || originalToken.permissions + ); } - assertValidLifespan(attributes); + assertValidLifespan(attributes.lifespan); const updatedToken: ApiToken = await strapi.query('admin::api-token').update({ select: SELECT_FIELDS, diff --git a/packages/core/admin/shared/contracts/api-token.ts b/packages/core/admin/shared/contracts/api-token.ts index f583ae14c7..e325f17340 100644 --- a/packages/core/admin/shared/contracts/api-token.ts +++ b/packages/core/admin/shared/contracts/api-token.ts @@ -1,9 +1,25 @@ import { errors } from '@strapi/utils'; +import { Entity } from '@strapi/types'; -import type { ApiToken } from '../../server/src/services/api-token'; +export type ApiToken = { + accessKey: string; + createdAt: string; + description: string; + expiresAt: string; + id: Entity.ID; + lastUsedAt: string | null; + lifespan: string | number; + name: string; + permissions: string[]; + type: 'custom' | 'full-access' | 'read-only'; + updatedAt: string; +}; -type ApiTokenBody = Pick; -type ApiTokenResponse = Omit; +export interface ApiTokenBody extends Pick { + lifespan?: ApiToken['lifespan'] | null; + permissions?: ApiToken['permissions'] | null; + type: ApiToken['type'] | undefined; +} /** * POST /api-tokens - Create an api token @@ -30,7 +46,7 @@ export declare namespace List { } export interface Response { - data: ApiTokenResponse[]; + data: ApiToken[]; error?: errors.ApplicationError; } } @@ -49,7 +65,7 @@ export declare namespace Revoke { } export interface Response { - data: ApiTokenResponse; + data: ApiToken; error?: errors.ApplicationError; } } @@ -68,7 +84,7 @@ export declare namespace Get { } export interface Response { - data: ApiTokenResponse; + data: ApiToken; error?: errors.ApplicationError; } } @@ -87,7 +103,7 @@ export declare namespace Update { } export interface Response { - data: ApiTokenResponse; + data: ApiToken; error?: errors.ApplicationError | errors.YupValidationError; } } diff --git a/packages/core/admin/shared/contracts/content-api/permissions.ts b/packages/core/admin/shared/contracts/content-api/permissions.ts new file mode 100644 index 0000000000..7ea9b9002f --- /dev/null +++ b/packages/core/admin/shared/contracts/content-api/permissions.ts @@ -0,0 +1,19 @@ +export interface ContentApiPermission { + apiId?: string; + label?: string; + controllers?: { controller: string; actions: { actionId: string; action: string }[] }[]; +} + +/** + * GET /content-api/permissions - List content API permissions + */ +export declare namespace List { + export interface Request { + body: {}; + query: {}; + } + + export interface Response { + data: ContentApiPermission; + } +} diff --git a/packages/core/admin/shared/contracts/content-api/routes.ts b/packages/core/admin/shared/contracts/content-api/routes.ts new file mode 100644 index 0000000000..6ff8789439 --- /dev/null +++ b/packages/core/admin/shared/contracts/content-api/routes.ts @@ -0,0 +1,31 @@ +type Routes = Record< + string, + { + config: { + auth: { + scope: string[]; + }; + }; + handler: string; + info: { + apiName: string; + type: string; + }; + method: 'GET' | 'POST' | 'PUT' | 'DELETE'; + path: string; + }[] +>; + +/** + * GET /content-api/routes - List content API routes + */ +export declare namespace List { + export interface Request { + body: {}; + query: {}; + } + + export interface Response { + data: Routes; + } +} diff --git a/packages/core/admin/shared/contracts/user.ts b/packages/core/admin/shared/contracts/user.ts index a8fb430902..05dfdac9f9 100644 --- a/packages/core/admin/shared/contracts/user.ts +++ b/packages/core/admin/shared/contracts/user.ts @@ -23,6 +23,21 @@ export declare namespace Create { } } +/** + * /create - Create an admin user + */ +export declare namespace Create { + export interface Request { + body: AdminUserCreationPayload; + query: {}; + } + + export interface Response { + data: SanitizedAdminUser; + error?: errors.ApplicationError | errors.YupValidationError; + } +} + /** * /find - Find admin users */ diff --git a/packages/core/helper-plugin/src/features/Tracking.tsx b/packages/core/helper-plugin/src/features/Tracking.tsx index 5fb5ee38f8..260fd3361d 100644 --- a/packages/core/helper-plugin/src/features/Tracking.tsx +++ b/packages/core/helper-plugin/src/features/Tracking.tsx @@ -211,8 +211,9 @@ interface WillNavigateEvent { interface DidAccessTokenListEvent { name: 'didAccessTokenList'; - properties: TokenEvents['properties'] & { - number: string; + properties: { + tokenType: TokenEvents['properties']['tokenType']; + number: number; }; } interface LogoEvent { @@ -225,15 +226,27 @@ interface LogoEvent { interface TokenEvents { name: | 'didCopyTokenKey' + | 'didAddTokenFromList' + | 'didEditTokenFromList' | 'willAccessTokenList' | 'willAddTokenFromList' + | 'willCreateToken' | 'willDeleteToken' + | 'willEditToken' | 'willEditTokenFromList'; properties: { - tokenType: string; + tokenType: 'api-token' | 'transfer-token'; }; } +type WillModifyTokenEvent = { + name: 'didCreateToken' | 'didEditToken'; + properties: { + tokenType: TokenEvents['properties']['tokenType']; + type: 'custom' | 'full-access' | 'read-only'; + }; +}; + type EventsWithProperties = | DidAccessTokenListEvent | DidChangeModeEvent @@ -246,6 +259,7 @@ type EventsWithProperties = | DidSubmitWithErrorsFirstAdminEvent | LogoEvent | TokenEvents + | WillModifyTokenEvent | WillNavigateEvent | DidSelectContentTypeFieldTypeEvent;