chore(admin): convert API Token settings page to TS (#18680)

* chore(admin): convert API Token settings page to TS

Co-Authored-By: Gustav Hansen <gu@stav.dev>

* chore(admin): cleanup api token contracts (#18862)

---------

Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>
Co-authored-by: Jamie Howard <48524071+jhoward1994@users.noreply.github.com>
This commit is contained in:
Gustav Hansen 2023-11-21 18:12:49 +01:00 committed by GitHub
parent e56518ca06
commit 03cf61f09a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 782 additions and 616 deletions

View File

@ -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[];
value: {
selectedAction: string | null;
routes: ListContentApiRoutes.Response['data'];
selectedActions: string[];
data: {
allActionsIds: Entity.ID[];
permissions: {
apiId: string;
label: string;
controllers: { controller: string; actions: { actionId: string; action: string } }[];
}[];
allActionsIds: string[];
permissions: ListContentApiPermissions.Response['data'][];
};
onChange: ({ target: { value } }: PseudoEvent) => void;
onChangeSelectAll: ({ target: { value } }: PseudoEvent) => void;
onChangeSelectAll: ({
target: { value },
}: {
target: { value: { action: string; actionId: string }[] };
}) => void;
setSelectedAction: ({ target: { value } }: PseudoEvent) => void;
};
}
interface ApiTokenPermissionsContextProviderProps extends ApiTokenPermissionsContextValue {
children: React.ReactNode[];
children: React.ReactNode | React.ReactNode[];
}
const [ApiTokenPermissionsContextProvider, useApiTokenPermissionsContext] =

View File

@ -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();

View File

@ -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,

View File

@ -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 (
<CheckPagePermissions permissions={permissions.settings?.['api-tokens'].create}>
<EditView />
</CheckPagePermissions>
);
};

View File

@ -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<ApiToken | null>(
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);
return data.data;
})
);
if (url === '/admin/content-api/permissions') {
const {
data: { data },
} = await get<ListContentApiPermissions.Response>(url);
dispatch({
type: 'UPDATE_PERMISSIONS_LAYOUT',
value: permissions,
value: data,
});
return data;
} else if (url === '/admin/content-api/routes') {
const {
data: { data },
} = await get<ListContentApiRoutes.Response>(url);
dispatch({
type: 'UPDATE_ROUTES',
value: routes,
value: data,
});
return data;
}
})
);
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<Get.Response>(`/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<Get.Response['data'], 'name' | 'description'> & {
lifespan: Get.Response['data']['lifespan'] | undefined;
type: Get.Response['data']['type'] | undefined;
},
actions: FormikHelpers<
Pick<Get.Response['data'], 'name' | 'description'> & {
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`, {
? await post<Create.Response, AxiosResponse<Create.Response>, Create.Request['body']>(
`/admin/api-tokens`,
{
...body,
lifespan: lifespanVal,
// 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(`/admin/api-tokens/${id}`, {
}
)
: await put<Update.Response, AxiosResponse<Update.Response>, 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,11 +250,14 @@ const ApiTokenCreateView = () => {
}),
});
trackUsageRef.current(isCreating ? 'didCreateToken' : 'didEditToken', {
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);
@ -226,13 +272,18 @@ const ApiTokenCreateView = () => {
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<ApiTokenPermissionsContextValue['value']['onChange']>[0]) => {
setHasChangedPermissions(true);
dispatch({
type: 'ON_CHANGE',
@ -240,7 +291,9 @@ const ApiTokenCreateView = () => {
});
};
const handleChangeSelectAllCheckbox = ({ target: { value } }) => {
const handleChangeSelectAllCheckbox = ({
target: { value },
}: Parameters<ApiTokenPermissionsContextValue['value']['onChangeSelectAll']>[0]) => {
setHasChangedPermissions(true);
dispatch({
type: 'SELECT_ALL_IN_PERMISSION',
@ -248,7 +301,9 @@ const ApiTokenCreateView = () => {
});
};
const setSelectedAction = ({ target: { value } }) => {
const setSelectedAction = ({
target: { value },
}: Parameters<ApiTokenPermissionsContextValue['value']['setSelectedAction']>[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 <LoadingView apiTokenName={apiToken?.name} />;
}
@ -339,4 +395,12 @@ const ApiTokenCreateView = () => {
);
};
export default ApiTokenCreateView;
export const ProtectedEditView = () => {
const permissions = useSelector(selectAdminPermissions);
return (
<CheckPagePermissions permissions={permissions.settings?.['api-tokens'].read}>
<EditView />
</CheckPagePermissions>
);
};

View File

@ -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,7 +24,9 @@ const ActionBoundRoutes = () => {
>
{selectedAction ? (
<Flex direction="column" alignItems="stretch" gap={2}>
{routes[actionSection]?.map((route) => {
{actionSection &&
actionSection in routes &&
routes[actionSection].map((route) => {
return route.config.auth?.scope?.includes(selectedAction) ||
route.handler === selectedAction ? (
<BoundRoute key={route.handler} route={route} />
@ -52,5 +53,3 @@ const ActionBoundRoutes = () => {
</GridItem>
);
};
export default ActionBoundRoutes;

View File

@ -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 }) {
</Flex>
</Flex>
);
}
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;

View File

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

View File

@ -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 (
<Accordion
@ -139,22 +172,3 @@ const CollapsableContentType = ({
</Accordion>
);
};
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;

View File

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

View File

@ -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 (
<Box padding={4} background="neutral0">
@ -20,20 +28,9 @@ const ContentTypesSection = ({ section, ...props }) => {
orderNumber={index}
indexExpandendCollapsedContent={indexExpandedCollpsedContent}
onExpanded={handleExpandedCollpsedContentIndex}
name={api.apiId}
{...props}
/>
))}
</Box>
);
};
ContentTypesSection.defaultProps = {
section: null,
};
ContentTypesSection.propTypes = {
section: PropTypes.arrayOf(PropTypes.object),
};
export default ContentTypesSection;

View File

@ -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<Pick<ApiToken, 'name' | 'description' | 'lifespan' | 'type'>>;
onChange: ({ target: { name, value } }: { target: { name: string; value: TokenType } }) => void;
canEditInputs: boolean;
values: undefined | Partial<Pick<ApiToken, 'name' | 'description' | 'lifespan' | 'type'>>;
isCreating: boolean;
apiToken?: null | Partial<ApiToken>;
onDispatch: React.Dispatch<any>;
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 = ({
</Box>
);
};
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;

View File

@ -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 }) => {
</Main>
);
};
LoadingView.defaultProps = {
apiTokenName: null,
};
LoadingView.propTypes = {
apiTokenName: PropTypes.string,
};
export default LoadingView;

View File

@ -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 }) => {
</Grid>
);
};
export default memo(Permissions);

View File

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

View File

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

View File

@ -1,13 +0,0 @@
import { transformPermissionsData } from './utils';
const init = (state, permissions = []) => {
return {
...state,
selectedAction: null,
routes: [],
selectedActions: [],
data: transformPermissionsData(permissions),
};
};
export default init;

View File

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

View File

@ -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'),

View File

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

View File

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

View File

@ -1,5 +0,0 @@
import getDateOfExpiration from './getDateOfExpiration';
import schema from './schema';
import transformPermissionsData from './transformPermissionsData';
export { getDateOfExpiration, schema, transformPermissionsData };

View File

@ -1,14 +0,0 @@
export const data = {
data: {
'api::address': {
controllers: {
address: ['find', 'findOne'],
},
},
'api::category': {
controllers: {
category: ['find', 'findOne', 'create', 'update', 'delete', 'createLocalization'],
},
},
},
};

View File

@ -1,4 +1,4 @@
import getDateOfExpiration from '../getDateOfExpiration';
import { getDateOfExpiration } from '../getDateOfExpiration';
const createdAt = '2022-07-05T12:16:56.821Z';
const duration = 604800000;

View File

@ -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', () => {

View File

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

View File

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

View File

@ -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<List.Response>(`/admin/api-tokens`);
trackUsage('didAccessTokenList', { number: data.length, tokenType: API_TOKEN_TYPE });
@ -80,10 +129,12 @@ const ApiTokenListView = () => {
cacheTime: 0,
enabled: canRead,
onError(error) {
if (error instanceof AxiosError) {
toggleNotification({
type: 'warning',
message: formatAPIError(error),
});
}
},
}
);
@ -100,7 +151,9 @@ const ApiTokenListView = () => {
trackUsage('didDeleteToken');
},
onError(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 (
<CheckPagePermissions permissions={permissions.settings?.['api-tokens'].main}>
<ListView />
</CheckPagePermissions>
);
};

View File

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

View File

@ -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 (
<CheckPagePermissions permissions={permissions.settings['api-tokens'].create}>
<EditView />
</CheckPagePermissions>
);
};
export default ProtectedApiTokenCreateView;

View File

@ -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 (
<CheckPagePermissions permissions={permissions.settings['api-tokens'].read}>
<EditView />
</CheckPagePermissions>
);
};
export default ProtectedApiTokenCreateView;

View File

@ -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 (
<CheckPagePermissions permissions={permissions.settings['api-tokens'].main}>
<ListView />
</CheckPagePermissions>
);
};
export default ProtectedApiTokenListView;

View File

@ -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(() => <ListView {...props} />, {
const setup = ({ path = '/settings/api-tokens', ...props } = {}) =>
render(<ListView {...props} />, {
wrapper({ children }) {
const client = new QueryClient({
defaultOptions: {
@ -90,7 +88,7 @@ const setup = ({ path, ...props } = {}) =>
<IntlProvider messages={{}} defaultLocale="en" textComponent="span" locale="en">
<ThemeToggleProvider themes={{ light: lightTheme, dark: darkTheme }}>
<Theme>
<MemoryRouter initialEntries={[path || '/settings/api-tokens']}>
<MemoryRouter initialEntries={[path]}>
<Route path="/settings/api-tokens">{children}</Route>
</MemoryRouter>
</Theme>
@ -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());
});
});

View File

@ -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[];

View File

@ -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<ApiToken, 'id' | 'accessKey' | 'expiresAt' | 'lastUsedAt'>;
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<Omit<ApiToken, 'accessKey'> | null> => {
const getBy = async (whereParams: WhereParams = {}): Promise<ApiToken | null> => {
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<ApiToken> => {
const create = async (attributes: ApiTokenBody): Promise<ApiToken> => {
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<Array<Omit<ApiToken, 'accessKey'>>> => {
const tokens: Array<ApiToken> = await strapi.query('admin::api-token').findMany({
const list = async (): Promise<Array<ApiToken>> => {
const tokens: Array<DBApiToken> = await strapi.query('admin::api-token').findMany({
select: SELECT_FIELDS,
populate: POPULATE_FIELDS,
orderBy: { name: 'ASC' },
@ -269,7 +258,7 @@ const list = async (): Promise<Array<Omit<ApiToken, 'accessKey'>>> => {
/**
* Revoke (delete) a token
*/
const revoke = async (id: string | number): Promise<Omit<ApiToken, 'accessKey'>> => {
const revoke = async (id: string | number): Promise<ApiToken> => {
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<Omit<ApiToken, 'accessKey'>> => {
): Promise<ApiToken> => {
// 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,

View File

@ -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<ApiToken, 'lifespan' | 'description' | 'type' | 'name' | 'permissions'>;
type ApiTokenResponse = Omit<ApiToken, 'accessKey'>;
export interface ApiTokenBody extends Pick<ApiToken, 'description' | 'name'> {
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;
}
}

View File

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

View File

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

View File

@ -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
*/

View File

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