mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ed7c7c54ff | ||
![]() |
1aeafdcd36 | ||
![]() |
e3eb76a86a | ||
![]() |
339ea3d197 | ||
![]() |
1fedcce151 | ||
![]() |
be26954af3 | ||
![]() |
1366892f87 |
@ -6,9 +6,16 @@ This document explains how to create and use Guided Tours in the Strapi CMS.
|
||||
|
||||
## Creating tours
|
||||
|
||||
To create a tour use the `createTour` factory function. The function takes the name of the tour and an array of steps.
|
||||
To create a tour use the `createTour` factory function. The function takes the following arguments:
|
||||
|
||||
The `content` key of a step is a render prop that receives `Step` and an object with `state` and `dispatch`.
|
||||
- `tourName`: The name of the tour
|
||||
- `steps`: An array of steps
|
||||
|
||||
Each `step` is an object with the following properties:
|
||||
|
||||
- `name`: The name of the step
|
||||
- `requiredActions` (optional): An array of actions that must be completed before the step should be displayed.
|
||||
- `content`: A render prop that receives `Step` and an object with `state` and `dispatch`.
|
||||
|
||||
`Step` has the following composable parts:
|
||||
|
||||
@ -24,6 +31,7 @@ const tours = {
|
||||
contentManager: createTour('contentManager', [
|
||||
{
|
||||
name: 'TheFeatureStepName',
|
||||
requiredActions: ['didDoSomethingImportant'],
|
||||
content: (Step) => (
|
||||
<Step.Root side="right">
|
||||
<Step.Title
|
||||
|
@ -22,8 +22,9 @@ type Action =
|
||||
payload: ValidTourName;
|
||||
};
|
||||
|
||||
type Tour = Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
|
||||
type State = {
|
||||
tours: Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>;
|
||||
tours: Tour;
|
||||
};
|
||||
|
||||
const [GuidedTourProviderImpl, unstableUseGuidedTour] = createContext<{
|
||||
@ -55,19 +56,20 @@ const UnstableGuidedTourContext = ({
|
||||
// NOTE: Maybe we just import this directly instead of a prop?
|
||||
tours: Tours;
|
||||
}) => {
|
||||
// TODO: Get local storage to init state
|
||||
// Derive the tour state from the tours object
|
||||
const tours = Object.keys(registeredTours).reduce(
|
||||
(acc, tourName) => {
|
||||
const tourLength = Object.keys(registeredTours[tourName as ValidTourName]).length;
|
||||
acc[tourName as ValidTourName] = {
|
||||
currentStep: 0,
|
||||
length: tourLength,
|
||||
isCompleted: false,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<ValidTourName, { currentStep: number; length: number; isCompleted: boolean }>
|
||||
);
|
||||
const tours = Object.keys(registeredTours).reduce((acc, tourName) => {
|
||||
const tourLength = Object.keys(registeredTours[tourName as ValidTourName]).length;
|
||||
|
||||
acc[tourName as ValidTourName] = {
|
||||
currentStep: 0,
|
||||
length: tourLength,
|
||||
isCompleted: false,
|
||||
};
|
||||
|
||||
return acc;
|
||||
}, {} as Tour);
|
||||
|
||||
const [state, dispatch] = React.useReducer(reducer, {
|
||||
tours,
|
||||
});
|
||||
|
@ -3,7 +3,8 @@ import * as React from 'react';
|
||||
import { Box, Popover } from '@strapi/design-system';
|
||||
import { styled } from 'styled-components';
|
||||
|
||||
import { useAuth } from '../../features/Auth';
|
||||
import { type GetGuidedTourMeta } from '../../../../shared/contracts/admin';
|
||||
import { useGetGuidedTourMetaQuery } from '../../services/admin';
|
||||
|
||||
import { type State, type Action, unstableUseGuidedTour, ValidTourName } from './Context';
|
||||
import { Step, createStepComponents } from './Step';
|
||||
@ -13,7 +14,7 @@ import { Step, createStepComponents } from './Step';
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const tours = {
|
||||
contentManager: createTour('contentManager', [
|
||||
TEST: createTour('TEST', [
|
||||
{
|
||||
name: 'Introduction',
|
||||
content: (Step) => (
|
||||
@ -30,6 +31,20 @@ const tours = {
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Done',
|
||||
requiredActions: ['didCreateApiToken'],
|
||||
content: (Step) => (
|
||||
<Step.Root align="start">
|
||||
<Step.Title id="tours.contentManager.CreateEntry.title" defaultMessage="Create entry" />
|
||||
<Step.Content
|
||||
id="tours.contentManager.CreateEntry.content"
|
||||
defaultMessage="Click this button to create an entry"
|
||||
/>
|
||||
<Step.Actions />
|
||||
</Step.Root>
|
||||
),
|
||||
},
|
||||
]),
|
||||
} as const;
|
||||
|
||||
@ -55,7 +70,6 @@ export const GuidedTourOverlay = styled(Box)`
|
||||
inset: 0;
|
||||
background-color: rgba(50, 50, 77, 0.2);
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const UnstableGuidedTourTooltip = ({
|
||||
@ -63,18 +77,30 @@ const UnstableGuidedTourTooltip = ({
|
||||
content,
|
||||
tourName,
|
||||
step,
|
||||
requiredActions,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
content: Content;
|
||||
tourName: ValidTourName;
|
||||
step: number;
|
||||
requiredActions?: GetGuidedTourMeta.Response['data']['completedActions'];
|
||||
}) => {
|
||||
const { data: guidedTourMeta } = useGetGuidedTourMetaQuery();
|
||||
|
||||
const state = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.state);
|
||||
const dispatch = unstableUseGuidedTour('UnstableGuidedTourTooltip', (s) => s.dispatch);
|
||||
|
||||
const Step = React.useMemo(() => createStepComponents(tourName), [tourName]);
|
||||
|
||||
const isCurrentStep = state.tours[tourName].currentStep === step;
|
||||
const isPopoverOpen = isCurrentStep && !state.tours[tourName].isCompleted;
|
||||
const hasCompletedRequiredActions =
|
||||
requiredActions?.every((action) => {
|
||||
return guidedTourMeta?.data?.completedActions.includes(action);
|
||||
}) ?? true;
|
||||
|
||||
const isEnabled =
|
||||
guidedTourMeta?.data?.isFirstSuperAdminUser && !state.tours[tourName].isCompleted;
|
||||
const isPopoverOpen = isEnabled && isCurrentStep && hasCompletedRequiredActions;
|
||||
|
||||
// Lock the scroll
|
||||
React.useEffect(() => {
|
||||
@ -106,6 +132,7 @@ const UnstableGuidedTourTooltip = ({
|
||||
type TourStep<P extends string> = {
|
||||
name: P;
|
||||
content: Content;
|
||||
requiredActions?: GetGuidedTourMeta.Response['data']['completedActions'];
|
||||
};
|
||||
|
||||
function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: string, steps: T) {
|
||||
@ -123,6 +150,7 @@ function createTour<const T extends ReadonlyArray<TourStep<string>>>(tourName: s
|
||||
tourName={tourName as ValidTourName}
|
||||
step={index}
|
||||
content={step.content}
|
||||
requiredActions={step.requiredActions}
|
||||
>
|
||||
{children}
|
||||
</UnstableGuidedTourTooltip>
|
||||
|
@ -15,6 +15,7 @@ describe('GuidedTour | reducer', () => {
|
||||
|
||||
const action: Action = {
|
||||
type: 'next_step',
|
||||
// @ts-expect-error Remove once actual tours are in place
|
||||
payload: 'contentManager',
|
||||
};
|
||||
|
||||
@ -28,6 +29,7 @@ describe('GuidedTour | reducer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
//@ts-expect-error Remove once actual tours are in place
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@ -49,6 +51,7 @@ describe('GuidedTour | reducer', () => {
|
||||
|
||||
const action: Action = {
|
||||
type: 'next_step',
|
||||
// @ts-expect-error Remove once actual tours are in place
|
||||
payload: 'contentManager',
|
||||
};
|
||||
|
||||
@ -67,6 +70,7 @@ describe('GuidedTour | reducer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
//@ts-expect-error Remove once actual tours are in place
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@ -83,6 +87,7 @@ describe('GuidedTour | reducer', () => {
|
||||
|
||||
const action: Action = {
|
||||
type: 'next_step',
|
||||
// @ts-expect-error Remove once actual tours are in place
|
||||
payload: 'contentManager',
|
||||
};
|
||||
|
||||
@ -96,6 +101,7 @@ describe('GuidedTour | reducer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
//@ts-expect-error Remove once actual tours are in place
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
@ -114,6 +120,7 @@ describe('GuidedTour | reducer', () => {
|
||||
|
||||
const action: Action = {
|
||||
type: 'skip_tour',
|
||||
// @ts-expect-error Remove once actual tours are in place
|
||||
payload: 'contentManager',
|
||||
};
|
||||
|
||||
@ -127,6 +134,7 @@ describe('GuidedTour | reducer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
//@ts-expect-error Remove once actual tours are in place
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
@ -148,6 +156,7 @@ describe('GuidedTour | reducer', () => {
|
||||
|
||||
const action: Action = {
|
||||
type: 'skip_tour',
|
||||
// @ts-expect-error Remove once actual tours are in place
|
||||
payload: 'contentManager',
|
||||
};
|
||||
|
||||
@ -166,6 +175,7 @@ describe('GuidedTour | reducer', () => {
|
||||
},
|
||||
};
|
||||
|
||||
//@ts-expect-error Remove once actual tours are in place
|
||||
expect(reducer(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
@ -6,7 +6,6 @@ import { useInitQuery, useTelemetryPropertiesQuery } from '../services/admin';
|
||||
|
||||
import { useAppInfo } from './AppInfo';
|
||||
import { useAuth } from './Auth';
|
||||
import { useStrapiApp } from './StrapiApp';
|
||||
|
||||
export interface TelemetryProperties {
|
||||
useTypescriptOnServer?: boolean;
|
||||
@ -40,42 +39,12 @@ export interface TrackingProviderProps {
|
||||
|
||||
const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
||||
const token = useAuth('App', (state) => state.token);
|
||||
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
|
||||
const { data: initData } = useInitQuery();
|
||||
const { uuid } = initData ?? {};
|
||||
|
||||
const { data } = useTelemetryPropertiesQuery(undefined, {
|
||||
skip: !initData?.uuid || !token,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (uuid && data) {
|
||||
const event = 'didInitializeAdministration';
|
||||
try {
|
||||
fetch('https://analytics.strapi.io/api/v2/track', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
// This event is anonymous
|
||||
event,
|
||||
userId: '',
|
||||
eventPropeties: {},
|
||||
groupProperties: {
|
||||
...data,
|
||||
projectId: uuid,
|
||||
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Strapi-Event': event,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// silence is golden
|
||||
}
|
||||
}
|
||||
}, [data, uuid, getAllWidgets]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
uuid,
|
||||
@ -101,7 +70,6 @@ const TrackingProvider = ({ children }: TrackingProviderProps) => {
|
||||
interface EventWithoutProperties {
|
||||
name:
|
||||
| 'changeComponentsOrder'
|
||||
| 'didAccessAuthenticatedAdministration'
|
||||
| 'didAddComponentToDynamicZone'
|
||||
| 'didBulkDeleteEntries'
|
||||
| 'didNotBulkDeleteEntries'
|
||||
@ -197,6 +165,14 @@ interface EventWithoutProperties {
|
||||
properties?: never;
|
||||
}
|
||||
|
||||
interface DidAccessAuthenticatedAdministrationEvent {
|
||||
name: 'didAccessAuthenticatedAdministration';
|
||||
properties: {
|
||||
registeredWidgets: string[];
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DidFilterMediaLibraryElementsEvent {
|
||||
name: 'didFilterMediaLibraryElements';
|
||||
properties: MediaEvents['properties'] & {
|
||||
@ -384,6 +360,7 @@ interface DidUpdateCTBSchema {
|
||||
type EventsWithProperties =
|
||||
| CreateEntryEvents
|
||||
| PublishEntryEvents
|
||||
| DidAccessAuthenticatedAdministrationEvent
|
||||
| DidAccessTokenListEvent
|
||||
| DidChangeModeEvent
|
||||
| DidCropFileEvent
|
||||
|
@ -52,13 +52,13 @@ describe('useTracking', () => {
|
||||
it('should call axios.post with all attributes by default when calling trackUsage()', async () => {
|
||||
const { result } = setup();
|
||||
|
||||
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
const res = await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).toBeCalledWith(
|
||||
'https://analytics.strapi.io/api/v2/track',
|
||||
{
|
||||
userId: 'someTestUserId',
|
||||
event: 'didAccessAuthenticatedAdministration',
|
||||
event: 'didSaveContentType',
|
||||
eventProperties: {},
|
||||
groupProperties: {
|
||||
useTypescriptOnServer: true,
|
||||
@ -70,7 +70,7 @@ describe('useTracking', () => {
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Strapi-Event': 'didAccessAuthenticatedAdministration',
|
||||
'X-Strapi-Event': 'didSaveContentType',
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -85,7 +85,7 @@ describe('useTracking', () => {
|
||||
|
||||
const { result } = setup();
|
||||
|
||||
await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).not.toBeCalled();
|
||||
|
||||
@ -97,7 +97,7 @@ describe('useTracking', () => {
|
||||
|
||||
const { result } = setup();
|
||||
|
||||
const res = await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
const res = await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).toHaveBeenCalled();
|
||||
expect(res).toEqual(null);
|
||||
@ -114,7 +114,7 @@ describe('useTracking', () => {
|
||||
|
||||
const { result } = setup();
|
||||
|
||||
await result.current.trackUsage('didAccessAuthenticatedAdministration');
|
||||
await result.current.trackUsage('didSaveContentType');
|
||||
|
||||
expect(axios.post).not.toBeCalled();
|
||||
});
|
||||
|
@ -20,6 +20,7 @@ import { UpsellBanner } from '../components/UpsellBanner';
|
||||
import { AppInfoProvider } from '../features/AppInfo';
|
||||
import { useAuth } from '../features/Auth';
|
||||
import { useConfiguration } from '../features/Configuration';
|
||||
import { useStrapiApp } from '../features/StrapiApp';
|
||||
import { useTracking } from '../features/Tracking';
|
||||
import { useMenu } from '../hooks/useMenu';
|
||||
import { useOnce } from '../hooks/useOnce';
|
||||
@ -93,14 +94,16 @@ const AdminLayout = () => {
|
||||
pluginsSectionLinks,
|
||||
} = useMenu(checkLatestStrapiVersion(strapiVersion, tagName));
|
||||
|
||||
/**
|
||||
* Make sure the event is only send once after accessing the admin panel
|
||||
* and not at runtime for example when regenerating the permissions with the ctb
|
||||
* or with i18n
|
||||
*/
|
||||
useOnce(() => {
|
||||
trackUsage('didAccessAuthenticatedAdministration');
|
||||
});
|
||||
const getAllWidgets = useStrapiApp('TrackingProvider', (state) => state.widgets.getAll);
|
||||
const projectId = appInfo?.projectId;
|
||||
React.useEffect(() => {
|
||||
if (projectId) {
|
||||
trackUsage('didAccessAuthenticatedAdministration', {
|
||||
registeredWidgets: getAllWidgets().map((widget) => widget.uid),
|
||||
projectId,
|
||||
});
|
||||
}
|
||||
}, [projectId, getAllWidgets, trackUsage]);
|
||||
|
||||
// We don't need to wait for the release query to be fetched before rendering the plugins
|
||||
// however, we need the appInfos and the permissions
|
||||
|
@ -288,14 +288,10 @@ const TABLE_HEADERS: Array<
|
||||
cellFormatter({ isActive }) {
|
||||
return (
|
||||
<Flex>
|
||||
<Status
|
||||
size="S"
|
||||
borderWidth={0}
|
||||
background="transparent"
|
||||
color="neutral800"
|
||||
variant={isActive ? 'success' : 'danger'}
|
||||
>
|
||||
<Typography>{isActive ? 'Active' : 'Inactive'}</Typography>
|
||||
<Status size="S" variant={isActive ? 'success' : 'danger'}>
|
||||
<Typography tag="span" variant="omega" fontWeight="bold">
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</Typography>
|
||||
</Status>
|
||||
</Flex>
|
||||
);
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
type UpdateProjectSettings,
|
||||
type Plugins,
|
||||
type GetLicenseLimitInformation,
|
||||
GetGuidedTourMeta,
|
||||
} from '../../../shared/contracts/admin';
|
||||
import { prefixFileUrlWithBackendUrl } from '../utils/urls';
|
||||
|
||||
@ -21,7 +22,7 @@ interface ConfigurationLogo {
|
||||
|
||||
const admin = adminApi
|
||||
.enhanceEndpoints({
|
||||
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft'],
|
||||
addTagTypes: ['ProjectSettings', 'LicenseLimits', 'LicenseTrialTimeLeft', 'GuidedTourMeta'],
|
||||
})
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
@ -114,6 +115,13 @@ const admin = adminApi
|
||||
}),
|
||||
providesTags: ['LicenseTrialTimeLeft'],
|
||||
}),
|
||||
getGuidedTourMeta: builder.query<GetGuidedTourMeta.Response, void>({
|
||||
query: () => ({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
}),
|
||||
providesTags: ['GuidedTourMeta'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
@ -127,6 +135,7 @@ const {
|
||||
useGetPluginsQuery,
|
||||
useGetLicenseLimitsQuery,
|
||||
useGetLicenseTrialTimeLeftQuery,
|
||||
useGetGuidedTourMetaQuery,
|
||||
} = admin;
|
||||
|
||||
export {
|
||||
@ -138,6 +147,7 @@ export {
|
||||
useGetPluginsQuery,
|
||||
useGetLicenseLimitsQuery,
|
||||
useGetLicenseTrialTimeLeftQuery,
|
||||
useGetGuidedTourMetaQuery,
|
||||
};
|
||||
|
||||
export type { ConfigurationLogo };
|
||||
|
@ -4,7 +4,7 @@ import { adminApi } from './api';
|
||||
|
||||
const apiTokensService = adminApi
|
||||
.enhanceEndpoints({
|
||||
addTagTypes: ['ApiToken'],
|
||||
addTagTypes: ['ApiToken', 'GuidedTourMeta'],
|
||||
})
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
@ -31,7 +31,7 @@ const apiTokensService = adminApi
|
||||
data: body,
|
||||
}),
|
||||
transformResponse: (response: ApiToken.Create.Response) => response.data,
|
||||
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }],
|
||||
invalidatesTags: [{ type: 'ApiToken' as const, id: 'LIST' }, 'GuidedTourMeta'],
|
||||
}),
|
||||
deleteAPIToken: builder.mutation<
|
||||
ApiToken.Revoke.Response['data'],
|
||||
|
@ -7,20 +7,7 @@ export default {
|
||||
path: '/license-limit-information',
|
||||
handler: 'admin.licenseLimitInformation',
|
||||
config: {
|
||||
policies: [
|
||||
'admin::isAuthenticatedAdmin',
|
||||
{
|
||||
name: 'admin::hasPermissions',
|
||||
config: {
|
||||
actions: [
|
||||
'admin::users.create',
|
||||
'admin::users.read',
|
||||
'admin::users.update',
|
||||
'admin::users.delete',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
@ -4,6 +4,7 @@ import { async } from '@strapi/utils';
|
||||
import { getService } from './utils';
|
||||
import adminActions from './config/admin-actions';
|
||||
import adminConditions from './config/admin-conditions';
|
||||
import constants from './services/constants';
|
||||
|
||||
const defaultAdminAuthSettings = {
|
||||
providers: {
|
||||
@ -88,21 +89,9 @@ const createDefaultAPITokensIfNeeded = async () => {
|
||||
const apiTokenCount = await apiTokenService.count();
|
||||
|
||||
if (usersCount === 0 && apiTokenCount === 0) {
|
||||
await apiTokenService.create({
|
||||
name: 'Read Only',
|
||||
description:
|
||||
'A default API token with read-only permissions, only used for accessing resources',
|
||||
type: 'read-only',
|
||||
lifespan: null,
|
||||
});
|
||||
|
||||
await apiTokenService.create({
|
||||
name: 'Full Access',
|
||||
description:
|
||||
'A default API token with full access permissions, used for accessing or modifying resources',
|
||||
type: 'full-access',
|
||||
lifespan: null,
|
||||
});
|
||||
for (const token of constants.DEFAULT_API_TOKENS) {
|
||||
await apiTokenService.create(token);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -5,7 +5,6 @@ import path from 'path';
|
||||
import { map, values, sumBy, pipe, flatMap, propEq } from 'lodash/fp';
|
||||
import _ from 'lodash';
|
||||
import { exists } from 'fs-extra';
|
||||
import '@strapi/types';
|
||||
import { env } from '@strapi/utils';
|
||||
import tsUtils from '@strapi/typescript-utils';
|
||||
import {
|
||||
@ -22,6 +21,7 @@ import type {
|
||||
Plugins,
|
||||
TelemetryProperties,
|
||||
UpdateProjectSettings,
|
||||
GetGuidedTourMeta,
|
||||
} from '../../../shared/contracts/admin';
|
||||
|
||||
const { isUsingTypeScript } = tsUtils;
|
||||
@ -185,4 +185,18 @@ export default {
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async getGuidedTourMeta(ctx: Context) {
|
||||
const [isFirstSuperAdminUser, completedActions] = await Promise.all([
|
||||
getService('user').isFirstSuperAdminUser(ctx.state.user.id),
|
||||
getService('guided-tour').getCompletedActions(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: {
|
||||
isFirstSuperAdminUser,
|
||||
completedActions,
|
||||
},
|
||||
} satisfies GetGuidedTourMeta.Response;
|
||||
},
|
||||
};
|
||||
|
@ -74,4 +74,12 @@ export default [
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/guided-tour-meta',
|
||||
handler: 'admin.getGuidedTourMeta',
|
||||
config: {
|
||||
policies: ['admin::isAuthenticatedAdmin'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -22,6 +22,22 @@ const constants = {
|
||||
DAYS_30: 30 * DAY_IN_MS,
|
||||
DAYS_90: 90 * DAY_IN_MS,
|
||||
},
|
||||
DEFAULT_API_TOKENS: [
|
||||
{
|
||||
name: 'Read Only',
|
||||
description:
|
||||
'A default API token with read-only permissions, only used for accessing resources',
|
||||
type: 'read-only',
|
||||
lifespan: null,
|
||||
},
|
||||
{
|
||||
name: 'Full Access',
|
||||
description:
|
||||
'A default API token with full access permissions, used for accessing or modifying resources',
|
||||
type: 'full-access',
|
||||
lifespan: null,
|
||||
},
|
||||
] as const,
|
||||
TRANSFER_TOKEN_TYPE: {
|
||||
PUSH: 'push',
|
||||
PULL: 'pull',
|
||||
|
56
packages/core/admin/server/src/services/guided-tour.ts
Normal file
56
packages/core/admin/server/src/services/guided-tour.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { Core, Internal } from '@strapi/types';
|
||||
import constants from './constants';
|
||||
|
||||
export type GuidedTourRequiredActions = {
|
||||
didCreateContentTypeSchema: boolean;
|
||||
didCreateContent: boolean;
|
||||
didCreateApiToken: boolean;
|
||||
};
|
||||
export type GuidedTourCompletedActions = keyof GuidedTourRequiredActions;
|
||||
|
||||
export const createGuidedTourService = ({ strapi }: { strapi: Core.Strapi }) => {
|
||||
const getCompletedActions = async () => {
|
||||
// Check if any content-type schemas have been create on the api:: namespace
|
||||
const contentTypeSchemaNames = Object.keys(strapi.contentTypes).filter((contentTypeUid) =>
|
||||
contentTypeUid.startsWith('api::')
|
||||
);
|
||||
const didCreateContentTypeSchema = contentTypeSchemaNames.length > 0;
|
||||
|
||||
// Check if any content has been created for content-types on the api:: namespace
|
||||
const hasContent = await (async () => {
|
||||
for (const name of contentTypeSchemaNames) {
|
||||
const count = await strapi.documents(name as Internal.UID.ContentType).count({});
|
||||
|
||||
if (count > 0) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
const didCreateContent = didCreateContentTypeSchema && hasContent;
|
||||
|
||||
// Check if any api tokens have been created besides the default ones
|
||||
const createdApiTokens = await strapi
|
||||
.documents('admin::api-token')
|
||||
.findMany({ fields: ['name', 'description'] });
|
||||
const didCreateApiToken = createdApiTokens.some((doc) =>
|
||||
constants.DEFAULT_API_TOKENS.every(
|
||||
(token) => token.name !== doc.name && token.description !== doc.description
|
||||
)
|
||||
);
|
||||
|
||||
// Compute an array of action names that have been completed
|
||||
const requiredActions = {
|
||||
didCreateContentTypeSchema,
|
||||
didCreateContent,
|
||||
didCreateApiToken,
|
||||
};
|
||||
const requiredActionNames = Object.keys(requiredActions) as Array<GuidedTourCompletedActions>;
|
||||
const completedActions = requiredActionNames.filter((key) => requiredActions[key]);
|
||||
|
||||
return completedActions;
|
||||
};
|
||||
|
||||
return {
|
||||
getCompletedActions,
|
||||
};
|
||||
};
|
@ -14,6 +14,7 @@ import * as action from './action';
|
||||
import * as apiToken from './api-token';
|
||||
import * as transfer from './transfer';
|
||||
import * as projectSettings from './project-settings';
|
||||
import { createGuidedTourService } from './guided-tour';
|
||||
|
||||
// TODO: TS - Export services one by one as this export is cjs
|
||||
export default {
|
||||
@ -32,4 +33,5 @@ export default {
|
||||
transfer,
|
||||
'project-settings': projectSettings,
|
||||
encryption,
|
||||
'guided-tour': createGuidedTourService,
|
||||
};
|
||||
|
@ -161,6 +161,31 @@ const isLastSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
|
||||
return superAdminRole.usersCount === 1 && hasSuperAdminRole(user);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user is the first super admin
|
||||
* @param userId user's id to look for
|
||||
*/
|
||||
const isFirstSuperAdminUser = async (userId: Data.ID): Promise<boolean> => {
|
||||
const currentUser = (await findOne(userId)) as AdminUser | null;
|
||||
|
||||
if (!currentUser || !hasSuperAdminRole(currentUser)) return false;
|
||||
|
||||
const [oldestUser] = await strapi.db.query('admin::user').findMany({
|
||||
populate: {
|
||||
roles: {
|
||||
where: {
|
||||
code: { $eq: SUPER_ADMIN_CODE },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
limit: 1,
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
return oldestUser.id === currentUser.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user with specific attributes exists in the database
|
||||
* @param attributes A partial user object
|
||||
@ -390,4 +415,5 @@ export default {
|
||||
displayWarningIfUsersDontHaveRole,
|
||||
resetPasswordByEmail,
|
||||
getLanguagesInUse,
|
||||
isFirstSuperAdminUser,
|
||||
};
|
||||
|
@ -10,6 +10,7 @@ import * as token from '../services/token';
|
||||
import * as apiToken from '../services/api-token';
|
||||
import * as projectSettings from '../services/project-settings';
|
||||
import * as transfer from '../services/transfer';
|
||||
import { createGuidedTourService } from '../services/guided-tour';
|
||||
|
||||
type S = {
|
||||
role: typeof role;
|
||||
@ -24,6 +25,7 @@ type S = {
|
||||
'project-settings': typeof projectSettings;
|
||||
transfer: typeof transfer;
|
||||
encryption: typeof encryption;
|
||||
'guided-tour': ReturnType<typeof createGuidedTourService>;
|
||||
};
|
||||
|
||||
type Resolve<T> = T extends (...args: unknown[]) => unknown ? T : { [K in keyof T]: T[K] };
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { Data } from '@strapi/types';
|
||||
import { errors } from '@strapi/utils';
|
||||
import type { File } from 'formidable';
|
||||
import type { GuidedTourCompletedActions } from '../../server/src/services/guided-tour';
|
||||
|
||||
export interface Logo {
|
||||
name: string;
|
||||
@ -218,3 +220,18 @@ export declare namespace GetLicenseLimitInformation {
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta data for the guided tour
|
||||
*/
|
||||
export declare namespace GetGuidedTourMeta {
|
||||
export interface Request {}
|
||||
|
||||
export interface Response {
|
||||
data: {
|
||||
isFirstSuperAdminUser: boolean;
|
||||
completedActions: GuidedTourCompletedActions[];
|
||||
};
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
||||
|
@ -285,7 +285,9 @@ export default ({ strapi }: { strapi: Core.Strapi }) => ({
|
||||
const otherStatus = await this.getManyAvailableStatus(uid, document.localizations);
|
||||
|
||||
document.localizations = document.localizations.map((d) => {
|
||||
const status = otherStatus.find((s) => s.documentId === d.documentId);
|
||||
const status = otherStatus.find(
|
||||
(s) => s.documentId === d.documentId && s.locale === d.locale
|
||||
);
|
||||
return {
|
||||
...d,
|
||||
status: this.getStatus(d, status ? [status] : []),
|
||||
|
160
tests/api/core/admin/admin-guided-tour-meta.test.api.ts
Normal file
160
tests/api/core/admin/admin-guided-tour-meta.test.api.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { createStrapiInstance } from 'api-tests/strapi';
|
||||
import { createAuthRequest } from 'api-tests/request';
|
||||
import { createTestBuilder } from 'api-tests/builder';
|
||||
import { Core } from '@strapi/types';
|
||||
|
||||
const articleContentType = {
|
||||
displayName: 'article',
|
||||
singularName: 'article',
|
||||
pluralName: 'articles',
|
||||
kind: 'collectionType',
|
||||
attributes: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let authRq;
|
||||
let strapi: Core.Strapi;
|
||||
const builder = createTestBuilder();
|
||||
|
||||
const restartWithSchema = async () => {
|
||||
await strapi.destroy();
|
||||
await builder.cleanup();
|
||||
|
||||
await builder.addContentType(articleContentType).build();
|
||||
|
||||
strapi = await createStrapiInstance();
|
||||
authRq = await createAuthRequest({ strapi });
|
||||
};
|
||||
|
||||
describe('Guided Tour Meta', () => {
|
||||
beforeAll(async () => {
|
||||
strapi = await createStrapiInstance();
|
||||
authRq = await createAuthRequest({ strapi });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Ensure each test cleans up
|
||||
await restartWithSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await strapi.destroy();
|
||||
await builder.cleanup();
|
||||
});
|
||||
|
||||
describe('GET /admin/guided-tour-meta', () => {
|
||||
/**
|
||||
* TODO:
|
||||
* clean-after-delete.test.api.ts leaks data causing the app
|
||||
* to intialize withe a schema and content. We need to ensure that test cleans up after itself
|
||||
* Skipping for now.
|
||||
*/
|
||||
test.skip('Returns correct initial state for a new installation', async () => {
|
||||
const res = await authRq({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
isFirstSuperAdminUser: true,
|
||||
completedActions: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('Detects first super admin user', async () => {
|
||||
const res = await authRq({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.isFirstSuperAdminUser).toBe(true);
|
||||
|
||||
const newUser = {
|
||||
email: 'second@user.com',
|
||||
firstname: 'second',
|
||||
lastname: 'user',
|
||||
password: 'second123',
|
||||
roles: [1],
|
||||
isActive: true,
|
||||
};
|
||||
await strapi.db.query('admin::user').create({ data: newUser });
|
||||
const request = await createAuthRequest({
|
||||
strapi,
|
||||
userInfo: newUser,
|
||||
});
|
||||
|
||||
const secondSuperAdminUserResponse = await request({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(secondSuperAdminUserResponse.status).toBe(200);
|
||||
expect(secondSuperAdminUserResponse.body.data.isFirstSuperAdminUser).toBe(false);
|
||||
});
|
||||
|
||||
test('Detects created content type schemas', async () => {
|
||||
await restartWithSchema();
|
||||
|
||||
const res = await authRq({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.completedActions).toContain('didCreateContentTypeSchema');
|
||||
});
|
||||
|
||||
test('Detects created content', async () => {
|
||||
await restartWithSchema();
|
||||
|
||||
const createdDocument = await strapi.documents('api::article.article').create({
|
||||
data: {
|
||||
name: 'Article 1',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authRq({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.completedActions).toContain('didCreateContent');
|
||||
|
||||
// Cleanup
|
||||
await strapi.documents('api::article.article').delete({
|
||||
documentId: createdDocument.documentId,
|
||||
});
|
||||
});
|
||||
|
||||
test('Detects created custom API tokens', async () => {
|
||||
// Create a custom API token
|
||||
const createdToken = await strapi.documents('admin::api-token').create({
|
||||
data: {
|
||||
name: 'Custom Token',
|
||||
type: 'read-only',
|
||||
description: 'Test token',
|
||||
accessKey: 'beep boop',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await authRq({
|
||||
url: '/admin/guided-tour-meta',
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.completedActions).toContain('didCreateApiToken');
|
||||
|
||||
// Cleanup
|
||||
await strapi.documents('admin::api-token').delete({
|
||||
documentId: createdToken.documentId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user