mirror of
https://github.com/strapi/strapi.git
synced 2025-10-17 02:53:22 +00:00
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:
parent
e56518ca06
commit
03cf61f09a
@ -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] =
|
||||
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
if (url === '/admin/content-api/permissions') {
|
||||
const {
|
||||
data: { data },
|
||||
} = await get<ListContentApiPermissions.Response>(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<ListContentApiRoutes.Response>(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<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`, {
|
||||
...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.Response, AxiosResponse<Create.Response>, 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.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,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<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>
|
||||
);
|
||||
};
|
@ -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 ? (
|
||||
<Flex direction="column" alignItems="stretch" gap={2}>
|
||||
{routes[actionSection]?.map((route) => {
|
||||
return route.config.auth?.scope?.includes(selectedAction) ||
|
||||
route.handler === selectedAction ? (
|
||||
<BoundRoute key={route.handler} route={route} />
|
||||
) : null;
|
||||
})}
|
||||
{actionSection &&
|
||||
actionSection in routes &&
|
||||
routes[actionSection].map((route) => {
|
||||
return route.config.auth?.scope?.includes(selectedAction) ||
|
||||
route.handler === selectedAction ? (
|
||||
<BoundRoute key={route.handler} route={route} />
|
||||
) : null;
|
||||
})}
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex direction="column" alignItems="stretch" gap={2}>
|
||||
@ -52,5 +53,3 @@ const ActionBoundRoutes = () => {
|
||||
</GridItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionBoundRoutes;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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;
|
@ -1,13 +0,0 @@
|
||||
import { transformPermissionsData } from './utils';
|
||||
|
||||
const init = (state, permissions = []) => {
|
||||
return {
|
||||
...state,
|
||||
selectedAction: null,
|
||||
routes: [],
|
||||
selectedActions: [],
|
||||
data: transformPermissionsData(permissions),
|
||||
};
|
||||
};
|
||||
|
||||
export default init;
|
@ -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;
|
@ -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'),
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
@ -1,5 +0,0 @@
|
||||
import getDateOfExpiration from './getDateOfExpiration';
|
||||
import schema from './schema';
|
||||
import transformPermissionsData from './transformPermissionsData';
|
||||
|
||||
export { getDateOfExpiration, schema, transformPermissionsData };
|
@ -1,14 +0,0 @@
|
||||
export const data = {
|
||||
data: {
|
||||
'api::address': {
|
||||
controllers: {
|
||||
address: ['find', 'findOne'],
|
||||
},
|
||||
},
|
||||
'api::category': {
|
||||
controllers: {
|
||||
category: ['find', 'findOne', 'create', 'update', 'delete', 'createLocalization'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import getDateOfExpiration from '../getDateOfExpiration';
|
||||
import { getDateOfExpiration } from '../getDateOfExpiration';
|
||||
|
||||
const createdAt = '2022-07-05T12:16:56.821Z';
|
||||
const duration = 604800000;
|
||||
|
@ -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', () => {
|
||||
|
@ -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;
|
@ -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;
|
||||
};
|
@ -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) {
|
||||
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 (
|
||||
<CheckPagePermissions permissions={permissions.settings?.['api-tokens'].main}>
|
||||
<ListView />
|
||||
</CheckPagePermissions>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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());
|
||||
});
|
||||
});
|
@ -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[];
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
31
packages/core/admin/shared/contracts/content-api/routes.ts
Normal file
31
packages/core/admin/shared/contracts/content-api/routes.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
@ -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;
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user