@firecms/core 3.4.0-canary.cb27f16 → 3.4.0-canary.ce97da7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/components/ErrorBoundary.d.ts +1 -3
- package/dist/components/LicenseBanner.d.ts +23 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/contexts/LicenseStatusContext.d.ts +16 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/plugin_translations.d.ts +38 -0
- package/dist/core/pro_plugins.d.ts +40 -0
- package/dist/de-DMp6cIY1.js +702 -0
- package/dist/de-DMp6cIY1.js.map +1 -0
- package/dist/editor/extensions/Image.d.ts +11 -0
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/lazy_editor.d.ts +6 -0
- package/dist/editor-BSo_aS1I.js +4563 -0
- package/dist/editor-BSo_aS1I.js.map +1 -0
- package/dist/fr-Cckz4IkR.js +702 -0
- package/dist/fr-Cckz4IkR.js.map +1 -0
- package/dist/hi-CDCoIqbu.js +702 -0
- package/dist/hi-CDCoIqbu.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/useLicenseStatus.d.ts +16 -0
- package/dist/hooks/useProjectLog.d.ts +11 -6
- package/dist/hooks/useTranslation.d.ts +1 -1
- package/dist/index.es.js +7938 -15928
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +7710 -6925
- package/dist/index.umd.js.map +1 -1
- package/dist/it-BJCUqixj.js +702 -0
- package/dist/it-BJCUqixj.js.map +1 -0
- package/dist/pl-DgT_joFf.js +704 -0
- package/dist/pl-DgT_joFf.js.map +1 -0
- package/dist/pt-B3dN3x0X.js +704 -0
- package/dist/pt-B3dN3x0X.js.map +1 -0
- package/dist/types/analytics.d.ts +1 -1
- package/dist/types/firecms.d.ts +18 -1
- package/dist/types/firecms_context.d.ts +8 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/license.d.ts +54 -0
- package/dist/types/plugins.d.ts +9 -1
- package/dist/types/translations.d.ts +35 -2
- package/dist/util/date_fns_locales.d.ts +18 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/lazy_eager.d.ts +3 -3
- package/package.json +4 -4
- package/src/app/Scaffold.tsx +7 -0
- package/src/components/ErrorBoundary.tsx +12 -18
- package/src/components/LicenseBanner.tsx +209 -0
- package/src/components/index.tsx +1 -0
- package/src/contexts/LicenseStatusContext.tsx +50 -0
- package/src/core/DefaultAppBar.tsx +28 -16
- package/src/core/FireCMS.tsx +73 -47
- package/src/core/SideDialogs.tsx +44 -1
- package/src/core/index.tsx +1 -0
- package/src/core/plugin_translations.ts +87 -0
- package/src/core/pro_plugins.ts +79 -0
- package/src/editor/components/SlashCommandMenu.tsx +4 -1
- package/src/editor/extensions/Image.ts +17 -1
- package/src/editor/hooks/useProseMirror.ts +5 -0
- package/src/editor/index.ts +11 -1
- package/src/editor/lazy_editor.tsx +33 -0
- package/src/hooks/index.tsx +2 -0
- package/src/hooks/useBuildNavigationController.tsx +47 -7
- package/src/hooks/useFireCMSContext.tsx +7 -3
- package/src/hooks/useLicenseStatus.tsx +32 -0
- package/src/hooks/useProjectLog.tsx +27 -9
- package/src/hooks/useTranslation.ts +3 -1
- package/src/i18n/FireCMSi18nProvider.tsx +164 -34
- package/src/locales/de.ts +18 -2
- package/src/locales/en.ts +21 -3
- package/src/locales/es.ts +18 -2
- package/src/locales/fr.ts +18 -2
- package/src/locales/hi.ts +18 -2
- package/src/locales/it.ts +18 -2
- package/src/locales/pl.ts +18 -2
- package/src/locales/pt.ts +18 -2
- package/src/preview/components/DatePreview.tsx +2 -4
- package/src/types/analytics.ts +3 -0
- package/src/types/firecms.tsx +19 -1
- package/src/types/firecms_context.tsx +9 -0
- package/src/types/index.ts +1 -0
- package/src/types/license.ts +62 -0
- package/src/types/plugins.tsx +10 -1
- package/src/types/translations.ts +35 -2
- package/src/util/date_fns_locales.ts +168 -0
- package/src/util/index.ts +1 -0
- package/src/util/lazy_eager.tsx +30 -8
- package/src/util/navigation_utils.ts +6 -1
- package/src/util/useStorageUploadController.tsx +6 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AccessResponse, FireCMSPlugin } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keys of the plugins that need a FireCMS PRO license.
|
|
5
|
+
*
|
|
6
|
+
* Kept in sync by hand with `PRO_PLUGIN_KEYS` in
|
|
7
|
+
* `saas_backend/functions/src/business_rules/licenses.ts`, which decides from
|
|
8
|
+
* the same list whether a project needs a license. Change both together.
|
|
9
|
+
*
|
|
10
|
+
* @group Core
|
|
11
|
+
*/
|
|
12
|
+
export const PRO_PLUGIN_KEYS: readonly string[] = [
|
|
13
|
+
"collection_editor",
|
|
14
|
+
"user_management",
|
|
15
|
+
"import_export",
|
|
16
|
+
"import",
|
|
17
|
+
"export",
|
|
18
|
+
"entity_history",
|
|
19
|
+
"data_enhancement",
|
|
20
|
+
"datatalk"
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* PRO plugins that stay mounted while PRO is paused. `user_management` supplies
|
|
25
|
+
* sign-in checks and roles, so removing it would lock users out of the CMS
|
|
26
|
+
* instead of pausing a feature; its own Users and Roles views show a notice.
|
|
27
|
+
*/
|
|
28
|
+
const KEPT_WHILE_PAUSED: readonly string[] = ["user_management"];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether PRO features are paused for this license status. Any `blocked`
|
|
32
|
+
* response pauses them, whatever its `licenseState` (`expired` and
|
|
33
|
+
* `invalid_project` both come with it). An unknown status pauses nothing.
|
|
34
|
+
*
|
|
35
|
+
* @group Core
|
|
36
|
+
*/
|
|
37
|
+
export function isProPaused(licenseStatus?: AccessResponse | null): boolean {
|
|
38
|
+
return Boolean(licenseStatus?.blocked);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether this plugin is dropped while PRO is paused.
|
|
43
|
+
*
|
|
44
|
+
* @group Core
|
|
45
|
+
*/
|
|
46
|
+
export function isPausedPlugin(plugin: Pick<FireCMSPlugin, "key">): boolean {
|
|
47
|
+
return PRO_PLUGIN_KEYS.includes(plugin.key) && !KEPT_WHILE_PAUSED.includes(plugin.key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where to get or fix the license for this status: the server's
|
|
52
|
+
* `subscribeUrl` when it is an https URL, otherwise the license page on
|
|
53
|
+
* app.firecms.co with the project, when known.
|
|
54
|
+
*
|
|
55
|
+
* @group Core
|
|
56
|
+
*/
|
|
57
|
+
export function getLicenseSubscribeUrl(licenseStatus?: AccessResponse | null): string {
|
|
58
|
+
const subscribeUrl = licenseStatus?.subscribeUrl;
|
|
59
|
+
if (subscribeUrl && /^https:\/\//i.test(subscribeUrl))
|
|
60
|
+
return subscribeUrl;
|
|
61
|
+
const params = new URLSearchParams({ intent: "pro" });
|
|
62
|
+
if (licenseStatus?.projectId) params.set("projectId", licenseStatus.projectId);
|
|
63
|
+
return `https://app.firecms.co/subscriptions?${params.toString()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The plugins that stay active under this license status. Returns the same
|
|
68
|
+
* array when nothing is dropped, so it can feed memo and effect dependencies.
|
|
69
|
+
*
|
|
70
|
+
* @group Core
|
|
71
|
+
*/
|
|
72
|
+
export function filterPausedPlugins<P extends Pick<FireCMSPlugin, "key">>(
|
|
73
|
+
plugins: P[] | undefined,
|
|
74
|
+
licenseStatus?: AccessResponse | null
|
|
75
|
+
): P[] | undefined {
|
|
76
|
+
if (!plugins || !isProPaused(licenseStatus)) return plugins;
|
|
77
|
+
const active = plugins.filter(plugin => !isPausedPlugin(plugin));
|
|
78
|
+
return active.length === plugins.length ? plugins : active;
|
|
79
|
+
}
|
|
@@ -198,11 +198,14 @@ const autocompleteSuggestionItem: SuggestionItem = {
|
|
|
198
198
|
let buffer = "";
|
|
199
199
|
const result = await aiController.autocomplete(textBeforeCursor, textAfterCursor, (delta) => {
|
|
200
200
|
buffer += delta;
|
|
201
|
-
if (delta.length !== 0) {
|
|
201
|
+
if (delta.length !== 0 && !view.isDestroyed) {
|
|
202
202
|
textLoadingCommands.toggleLoadingDecoration(view.state, view.dispatch, buffer);
|
|
203
203
|
}
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// The editor may have been unmounted while the completion streamed in.
|
|
207
|
+
if (view.isDestroyed) return;
|
|
208
|
+
|
|
206
209
|
// Insert parsed text result at cursor natively
|
|
207
210
|
try {
|
|
208
211
|
// The AI controller might stream literal "\n" characters depending on its JSON decoding layer.
|
|
@@ -4,9 +4,20 @@ import { Plugin, PluginKey } from "prosemirror-state";
|
|
|
4
4
|
|
|
5
5
|
export type UploadFn = (image: File) => Promise<string>;
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Shows the image as a placeholder at `pos`, uploads it, and swaps the
|
|
9
|
+
* placeholder for the uploaded image once it has loaded.
|
|
10
|
+
*
|
|
11
|
+
* Every step after the first runs asynchronously — the FileReader callback that
|
|
12
|
+
* calls this, the upload, and the preload of the uploaded image — and the editor
|
|
13
|
+
* can be unmounted during any of them (the user closes the entity or navigates
|
|
14
|
+
* away while an image is still uploading). Dispatching on a destroyed
|
|
15
|
+
* `EditorView` throws inside ProseMirror (`docView` is null), so each step
|
|
16
|
+
* checks the view is still alive before touching it.
|
|
17
|
+
*/
|
|
7
18
|
export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<FileReader>, pos: number, upload: UploadFn, image: File) {
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
if (view.isDestroyed) return;
|
|
10
21
|
|
|
11
22
|
// @ts-ignore
|
|
12
23
|
const plugin = view.state.plugins.find((p: Plugin) => p.key === ImagePluginKey.key);
|
|
@@ -31,7 +42,12 @@ export async function onFileRead(view: EditorView, readerEvent: ProgressEvent<Fi
|
|
|
31
42
|
const src = await upload(image);
|
|
32
43
|
console.debug("Uploaded image", src);
|
|
33
44
|
|
|
45
|
+
// The upload is kept (the caller stored the file), but there is no longer
|
|
46
|
+
// an editor to put it in.
|
|
47
|
+
if (view.isDestroyed) return;
|
|
48
|
+
|
|
34
49
|
const replacePlaceholder = () => {
|
|
50
|
+
if (view.isDestroyed) return;
|
|
35
51
|
// Retrieve the LATEST state after the async upload
|
|
36
52
|
let currentDecos = plugin.getState(view.state) as DecorationSet;
|
|
37
53
|
const foundDecos = currentDecos.find(undefined, undefined, spec => spec.id === decoId);
|
|
@@ -58,6 +58,11 @@ export function useProseMirror({ initialContent, editable = true, handleImageUpl
|
|
|
58
58
|
state: defaultState,
|
|
59
59
|
editable: () => editable,
|
|
60
60
|
dispatchTransaction: (tr: Transaction) => {
|
|
61
|
+
// Async work (image uploads and their preload, AI autocomplete
|
|
62
|
+
// streams) can finish after the editor unmounted. Applying a
|
|
63
|
+
// transaction to a destroyed view throws inside ProseMirror
|
|
64
|
+
// (`docView` is null), so drop it: there is nothing to update.
|
|
65
|
+
if (editorView.isDestroyed) return;
|
|
61
66
|
const newState = editorView.state.apply(tr);
|
|
62
67
|
editorView.updateState(newState);
|
|
63
68
|
setState(newState);
|
package/src/editor/index.ts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
// The editor's runtime entry point is the lazy wrapper, not the implementation:
|
|
2
|
+
// a static re-export here would pull ProseMirror into every consumer of
|
|
3
|
+
// `@firecms/core`. The types come straight from the implementation and are
|
|
4
|
+
// erased at build time, so they cost nothing.
|
|
5
|
+
export { FireCMSEditor } from "./lazy_editor";
|
|
6
|
+
export type {
|
|
7
|
+
CustomEditorComponent,
|
|
8
|
+
MarkdownEditorConfig,
|
|
9
|
+
FireCMSEditorTextSize,
|
|
10
|
+
FireCMSEditorProps
|
|
11
|
+
} from "./editor";
|
|
2
12
|
export * from "./types";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
import { CircularProgressCenter } from "../components/CircularProgressCenter";
|
|
6
|
+
import type { FireCMSEditorProps } from "./editor";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `FireCMSEditor`, loaded on demand.
|
|
10
|
+
*
|
|
11
|
+
* The editor is built on ProseMirror — roughly 250KB across a dozen packages —
|
|
12
|
+
* and is only reachable from a markdown field, which most views never render.
|
|
13
|
+
* `MarkdownEditorFieldBinding` already loaded it lazily, but that split was
|
|
14
|
+
* defeated by `src/index.ts` re-exporting the editor from the package barrel:
|
|
15
|
+
* a static re-export puts the module back in the entry graph regardless of who
|
|
16
|
+
* imports it dynamically. So ProseMirror sat in `@firecms/core` for everyone.
|
|
17
|
+
*
|
|
18
|
+
* The name and props are unchanged, and this component carries its own Suspense
|
|
19
|
+
* boundary, so it stays a drop-in for the synchronous component it replaces —
|
|
20
|
+
* a consumer rendering it without a boundary of their own still works.
|
|
21
|
+
*/
|
|
22
|
+
const LazyFireCMSEditor = React.lazy(() =>
|
|
23
|
+
import("./editor").then((module) => ({ default: module.FireCMSEditor })));
|
|
24
|
+
|
|
25
|
+
export function FireCMSEditor(props: FireCMSEditorProps) {
|
|
26
|
+
return (
|
|
27
|
+
<React.Suspense fallback={<CircularProgressCenter/>}>
|
|
28
|
+
<LazyFireCMSEditor {...props}/>
|
|
29
|
+
</React.Suspense>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
FireCMSEditor.displayName = "FireCMSEditor";
|
package/src/hooks/index.tsx
CHANGED
|
@@ -14,6 +14,7 @@ export * from "./useDialogsController";
|
|
|
14
14
|
export * from "./useSideDialogsController";
|
|
15
15
|
export * from "./useSideEntityController";
|
|
16
16
|
export * from "./useFireCMSContext";
|
|
17
|
+
export * from "./useAnalyticsController";
|
|
17
18
|
export * from "./useSnackbarController";
|
|
18
19
|
export * from "./useModeController";
|
|
19
20
|
export * from "./useClipboard";
|
|
@@ -32,3 +33,4 @@ export * from "./useBuildModeController";
|
|
|
32
33
|
|
|
33
34
|
export * from "./useValidateAuthenticator";
|
|
34
35
|
export * from "./useTranslation";
|
|
36
|
+
export * from "./useLicenseStatus";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import equal from "react-fast-compare"
|
|
3
3
|
import { useBlocker, useNavigate } from "react-router-dom";
|
|
4
|
+
import { filterPausedPlugins, isProPaused } from "../core/pro_plugins";
|
|
5
|
+
import { useLicenseStatus } from "./useLicenseStatus";
|
|
4
6
|
|
|
5
7
|
import {
|
|
6
8
|
AuthController,
|
|
@@ -115,7 +117,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
115
117
|
views: viewsProp,
|
|
116
118
|
adminViews: adminViewsProp,
|
|
117
119
|
viewsOrder,
|
|
118
|
-
plugins,
|
|
120
|
+
plugins: allPlugins,
|
|
119
121
|
userConfigPersistence,
|
|
120
122
|
dataSourceDelegate,
|
|
121
123
|
disabled,
|
|
@@ -124,6 +126,27 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
124
126
|
|
|
125
127
|
const navigate = useNavigate();
|
|
126
128
|
|
|
129
|
+
// While PRO is paused, PRO plugins contribute no collections, collection
|
|
130
|
+
// modifications, views or navigation entries. This hook runs above
|
|
131
|
+
// `FireCMS`, so the status arrives through the store `FireCMS` publishes to,
|
|
132
|
+
// and its arrival re-renders this hook, which resolves navigation again.
|
|
133
|
+
const licenseStatus = useLicenseStatus();
|
|
134
|
+
const proPaused = isProPaused(licenseStatus);
|
|
135
|
+
const plugins = useMemo(
|
|
136
|
+
() => filterPausedPlugins(allPlugins, licenseStatus),
|
|
137
|
+
[allPlugins, licenseStatus]
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Navigation resolves asynchronously, so a resolution started before the
|
|
141
|
+
// pause changed can finish after one started since. This ref holds the
|
|
142
|
+
// current pause so the older one is dropped instead of restoring what the
|
|
143
|
+
// paused plugins contributed. Declared before the effect that refreshes.
|
|
144
|
+
const proPausedRef = useRef(proPaused);
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
proPausedRef.current = proPaused;
|
|
147
|
+
}, [proPaused]);
|
|
148
|
+
const resolvedProPausedRef = useRef<boolean | undefined>(undefined);
|
|
149
|
+
|
|
127
150
|
const collectionsRef = useRef<EntityCollection[] | undefined>(undefined);
|
|
128
151
|
const viewsRef = useRef<CMSView[] | undefined>(undefined);
|
|
129
152
|
const adminViewsRef = useRef<CMSView[] | undefined>(undefined);
|
|
@@ -302,7 +325,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
302
325
|
groups: uniqueGroups,
|
|
303
326
|
onNavigationEntriesUpdate: onNavigationEntriesUpdateCallback!,
|
|
304
327
|
};
|
|
305
|
-
}, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups]);
|
|
328
|
+
}, [navigationGroupMappings, buildCMSUrlPath, buildUrlCollectionPath, pluginGroups, plugins]);
|
|
306
329
|
|
|
307
330
|
const onNavigationEntriesOrderUpdate = useCallback((entries: NavigationGroupMapping[]) => {
|
|
308
331
|
if (!plugins) {
|
|
@@ -342,6 +365,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
342
365
|
|
|
343
366
|
console.debug("Refreshing navigation");
|
|
344
367
|
|
|
368
|
+
const resolvingWhilePaused = proPaused;
|
|
369
|
+
|
|
345
370
|
try {
|
|
346
371
|
|
|
347
372
|
const [resolvedCollections = [], resolvedViews, resolvedAdminViews = []] = await Promise.all([
|
|
@@ -351,10 +376,21 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
351
376
|
]
|
|
352
377
|
);
|
|
353
378
|
|
|
379
|
+
// Superseded: a newer resolution runs with the current pause.
|
|
380
|
+
if (resolvingWhilePaused !== proPausedRef.current)
|
|
381
|
+
return;
|
|
382
|
+
|
|
354
383
|
const computedTopLevelNav = computeTopNavigation(resolvedCollections, resolvedViews, resolvedAdminViews, viewsOrder, undefined, onNavigationEntriesOrderUpdate);
|
|
355
384
|
|
|
356
|
-
|
|
357
|
-
|
|
385
|
+
// When the pause changes, everything below is replaced, including
|
|
386
|
+
// what the comparisons ignore: they skip functions, and a plugin may
|
|
387
|
+
// have added only collection callbacks, or only regrouped entries.
|
|
388
|
+
const pauseChanged = resolvedProPausedRef.current !== undefined
|
|
389
|
+
&& resolvedProPausedRef.current !== resolvingWhilePaused;
|
|
390
|
+
resolvedProPausedRef.current = resolvingWhilePaused;
|
|
391
|
+
|
|
392
|
+
let shouldUpdateTopLevelNav = pauseChanged;
|
|
393
|
+
if (pauseChanged || !areCollectionListsEqual(collectionsRef.current ?? [], resolvedCollections)) {
|
|
358
394
|
collectionsRef.current = resolvedCollections;
|
|
359
395
|
console.debug("Collections have changed", resolvedCollections);
|
|
360
396
|
shouldUpdateTopLevelNav = true;
|
|
@@ -363,7 +399,7 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
363
399
|
collectionsRef.current = resolvedCollections;
|
|
364
400
|
shouldUpdateTopLevelNav = true;
|
|
365
401
|
}
|
|
366
|
-
if (!equal(viewsRef.current, resolvedViews)) {
|
|
402
|
+
if (pauseChanged || !equal(viewsRef.current, resolvedViews)) {
|
|
367
403
|
viewsRef.current = resolvedViews;
|
|
368
404
|
shouldUpdateTopLevelNav = true;
|
|
369
405
|
}
|
|
@@ -400,6 +436,8 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
400
436
|
viewsProp,
|
|
401
437
|
adminViewsProp,
|
|
402
438
|
computeTopNavigation,
|
|
439
|
+
plugins,
|
|
440
|
+
proPaused
|
|
403
441
|
]);
|
|
404
442
|
|
|
405
443
|
useEffect(() => {
|
|
@@ -588,7 +626,9 @@ export function useBuildNavigationController<EC extends EntityCollection, USER e
|
|
|
588
626
|
getParentCollectionIds,
|
|
589
627
|
convertIdsToPaths,
|
|
590
628
|
navigate,
|
|
591
|
-
|
|
629
|
+
// All of them, paused or not: `FireCMS` sends every key to the license
|
|
630
|
+
// check and applies the same pause to what it renders.
|
|
631
|
+
plugins: allPlugins
|
|
592
632
|
};
|
|
593
633
|
}
|
|
594
634
|
|
|
@@ -12,6 +12,7 @@ import { useCustomizationController } from "./useCustomizationController";
|
|
|
12
12
|
import { useAnalyticsController } from "./useAnalyticsController";
|
|
13
13
|
import React, { useEffect } from "react";
|
|
14
14
|
import { useInternalUserManagementController } from "./useInternalUserManagementController";
|
|
15
|
+
import { useLicenseStatus } from "./useLicenseStatus";
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Hook to retrieve the {@link FireCMSContext}.
|
|
@@ -36,6 +37,7 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
|
|
|
36
37
|
const customizationController = useCustomizationController();
|
|
37
38
|
const analyticsController = useAnalyticsController();
|
|
38
39
|
const userManagement = useInternalUserManagementController<USER>();
|
|
40
|
+
const licenseStatus = useLicenseStatus();
|
|
39
41
|
|
|
40
42
|
const fireCMSContextRef = React.useRef<FireCMSContext<USER, AuthControllerType>>({
|
|
41
43
|
authController,
|
|
@@ -49,7 +51,8 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
|
|
|
49
51
|
dialogsController,
|
|
50
52
|
customizationController,
|
|
51
53
|
analyticsController,
|
|
52
|
-
userManagement
|
|
54
|
+
userManagement,
|
|
55
|
+
licenseStatus
|
|
53
56
|
});
|
|
54
57
|
|
|
55
58
|
useEffect(() => {
|
|
@@ -65,9 +68,10 @@ export const useFireCMSContext = <USER extends User = User, AuthControllerType e
|
|
|
65
68
|
dialogsController,
|
|
66
69
|
customizationController,
|
|
67
70
|
analyticsController,
|
|
68
|
-
userManagement
|
|
71
|
+
userManagement,
|
|
72
|
+
licenseStatus
|
|
69
73
|
};
|
|
70
|
-
}, [authController, dialogsController, navigation, sideDialogsController]);
|
|
74
|
+
}, [authController, dialogsController, navigation, sideDialogsController, licenseStatus]);
|
|
71
75
|
|
|
72
76
|
return fireCMSContextRef.current;
|
|
73
77
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useContext, useSyncExternalStore } from "react";
|
|
2
|
+
import { AccessResponse } from "../types";
|
|
3
|
+
import {
|
|
4
|
+
getLicenseStatusSnapshot,
|
|
5
|
+
getServerLicenseStatusSnapshot,
|
|
6
|
+
LicenseStatusContext,
|
|
7
|
+
subscribeLicenseStatus
|
|
8
|
+
} from "../contexts/LicenseStatusContext";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The FireCMS license status of this project: whether PRO runs on a trial, is
|
|
12
|
+
* licensed, or is paused, plus the link to get a license.
|
|
13
|
+
*
|
|
14
|
+
* `null` until the license check answers, and when it is not sent at all
|
|
15
|
+
* (`telemetry={false}` without an `apiKey`). A response from an older server
|
|
16
|
+
* may lack `licenseState`; treat that as unknown. PRO features are paused when
|
|
17
|
+
* `blocked` is true; use `isProPaused(status)` for that check.
|
|
18
|
+
*
|
|
19
|
+
* Works inside `FireCMS` and also above it, e.g. in the component that calls
|
|
20
|
+
* `useBuildNavigationController`.
|
|
21
|
+
*
|
|
22
|
+
* @group Hooks and utilities
|
|
23
|
+
*/
|
|
24
|
+
export function useLicenseStatus(): AccessResponse | null {
|
|
25
|
+
const fromContext = useContext(LicenseStatusContext);
|
|
26
|
+
const published = useSyncExternalStore(
|
|
27
|
+
subscribeLicenseStatus,
|
|
28
|
+
getLicenseStatusSnapshot,
|
|
29
|
+
getServerLicenseStatusSnapshot
|
|
30
|
+
);
|
|
31
|
+
return fromContext !== undefined ? fromContext : published;
|
|
32
|
+
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import { AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
|
|
2
|
+
import { AccessResponse, AuthController, DataSourceDelegate, FireCMSPlugin } from "../types";
|
|
3
|
+
import { PRO_PLUGIN_KEYS } from "../core/pro_plugins";
|
|
3
4
|
|
|
4
5
|
export const DEFAULT_SERVER_DEV = "https://api-kdoe6pj3qq-ey.a.run.app";
|
|
5
6
|
export const DEFAULT_SERVER = "https://api.firecms.co";
|
|
6
7
|
|
|
7
|
-
export type AccessResponse = {
|
|
8
|
-
blocked?: boolean;
|
|
9
|
-
message?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
8
|
async function makeRequest(authController: AuthController, dataSourceKey: string, pluginKeys: string[] | undefined, apiKey?: string): Promise<AccessResponse> {
|
|
13
9
|
let idToken: string | null;
|
|
14
10
|
try {
|
|
@@ -40,24 +36,46 @@ export interface UseProjectLogParams {
|
|
|
40
36
|
apiKey?: string;
|
|
41
37
|
authController: AuthController;
|
|
42
38
|
dataSourceDelegate: DataSourceDelegate;
|
|
39
|
+
/**
|
|
40
|
+
* Every plugin the app mounts, before any are paused: the server decides
|
|
41
|
+
* from these keys whether the project needs a license.
|
|
42
|
+
*/
|
|
43
43
|
plugins?: FireCMSPlugin<any, any, any>[];
|
|
44
|
+
/**
|
|
45
|
+
* When false, no request is sent, unless the request is a license check:
|
|
46
|
+
* with an `apiKey`, or with a PRO plugin mounted, it is always sent.
|
|
47
|
+
*/
|
|
48
|
+
telemetry?: boolean;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
export function useProjectLog({
|
|
47
52
|
authController,
|
|
48
53
|
dataSourceDelegate,
|
|
49
54
|
plugins,
|
|
50
|
-
apiKey
|
|
55
|
+
apiKey,
|
|
56
|
+
telemetry = true
|
|
51
57
|
}: UseProjectLogParams): AccessResponse | null {
|
|
52
58
|
const [accessResponse, setAccessResponse] = useState<AccessResponse | null>(null);
|
|
53
59
|
const accessedUserRef = useRef<string | null>(null);
|
|
54
60
|
const dataSourceKey = dataSourceDelegate.key;
|
|
55
61
|
const pluginKeys = plugins?.map(plugin => plugin.key);
|
|
62
|
+
// Opting out is for apps without PRO plugins. With one mounted, the
|
|
63
|
+
// request is what starts the trial and pauses PRO when it ends, so
|
|
64
|
+
// skipping it would unlock PRO for good.
|
|
65
|
+
const runsProPlugin = (pluginKeys ?? []).some(key => PRO_PLUGIN_KEYS.includes(key));
|
|
66
|
+
const enabled = Boolean(apiKey) || runsProPlugin || telemetry !== false;
|
|
56
67
|
useEffect(() => {
|
|
68
|
+
if (!enabled) return;
|
|
57
69
|
if (authController.user && authController.user.uid !== accessedUserRef.current && !authController.initialLoading) {
|
|
58
|
-
makeRequest(authController, dataSourceKey, pluginKeys, apiKey)
|
|
70
|
+
makeRequest(authController, dataSourceKey, pluginKeys, apiKey)
|
|
71
|
+
.then((response) => setAccessResponse(response && typeof response === "object" ? response : null))
|
|
72
|
+
.catch((error) => {
|
|
73
|
+
// The check never gates the CMS, so a failed one only
|
|
74
|
+
// leaves the license status unknown.
|
|
75
|
+
console.debug("FireCMS license check failed", error);
|
|
76
|
+
});
|
|
59
77
|
accessedUserRef.current = authController.user.uid;
|
|
60
78
|
}
|
|
61
|
-
}, [authController, dataSourceKey, pluginKeys]);
|
|
79
|
+
}, [authController, dataSourceKey, pluginKeys, enabled]);
|
|
62
80
|
return accessResponse;
|
|
63
81
|
}
|
|
@@ -23,8 +23,10 @@ export function useTranslation() {
|
|
|
23
23
|
* Also supports i18next interpolation variables, e.g.
|
|
24
24
|
* t("add_to_field", { fieldName: "Tags" })
|
|
25
25
|
* t("error_deleting", { message: err.message })
|
|
26
|
+
* Pass `count` as a number to pick a plural form (`key_one`, falling back
|
|
27
|
+
* to `key`); i18next ignores a string `count`.
|
|
26
28
|
*/
|
|
27
|
-
const typedT = (key: string, vars?: Record<string, string>): string =>
|
|
29
|
+
const typedT = (key: string, vars?: Record<string, string | number>): string =>
|
|
28
30
|
t(key, vars) as string;
|
|
29
31
|
|
|
30
32
|
return { t: typedT, i18n };
|