@lowdefy/client 6.1.0 → 7.1.0

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.
Files changed (51) hide show
  1. package/dist/Client.js +54 -17
  2. package/dist/Context.js +30 -12
  3. package/dist/HtmlOverlay.js +127 -0
  4. package/dist/IconProvider.js +24 -0
  5. package/dist/MountEvents.js +3 -3
  6. package/dist/adapters/createRouter.js +9 -3
  7. package/dist/adapters/url.js +1 -7
  8. package/dist/auth/createAuthMethods.js +1034 -41
  9. package/dist/block/Block.js +6 -3
  10. package/dist/block/CategorySwitch.js +6 -0
  11. package/dist/block/Container.js +3 -0
  12. package/dist/block/InputContainer.js +8 -2
  13. package/dist/block/List.js +3 -0
  14. package/dist/block/LoadingBlock.js +6 -12
  15. package/dist/block/LoadingContainer.js +6 -1
  16. package/dist/block/LoadingList.js +6 -1
  17. package/dist/block/loadingBlockMethods.js +26 -0
  18. package/dist/claimCallerReload.js +38 -0
  19. package/dist/createAntdIconConfig.js +91 -0
  20. package/dist/createCallAPI.js +6 -2
  21. package/dist/createCallRequest.js +6 -2
  22. package/dist/createHandleError.js +6 -4
  23. package/dist/createIcon.js +138 -36
  24. package/dist/createPageLifecycleManager.js +172 -0
  25. package/dist/formatIconTitle.js +26 -0
  26. package/dist/getActiveLocale.js +27 -0
  27. package/dist/getContextKey.js +29 -0
  28. package/dist/getMessageIcon.js +43 -0
  29. package/dist/hasOtherRoles.js +30 -0
  30. package/dist/index.js +1 -0
  31. package/dist/initLowdefyContext.js +103 -4
  32. package/dist/isOtherCaller.js +29 -0
  33. package/dist/recordCallerStamp.js +31 -0
  34. package/dist/renderIconNodes.js +46 -0
  35. package/dist/request.js +72 -5
  36. package/dist/setLowdefyUser.js +32 -0
  37. package/dist/setupLink.js +19 -13
  38. package/dist/shouldReloadForAuthError.js +39 -0
  39. package/dist/shouldReloadForBuild.js +44 -0
  40. package/dist/shouldReloadForCaller.js +50 -0
  41. package/dist/storage/clearUserStorage.js +36 -0
  42. package/dist/storage/clearUserStorageOnOwnerChange.js +41 -0
  43. package/dist/storage/createStorage.js +48 -0
  44. package/dist/storage/listUserStorageKeys.js +36 -0
  45. package/dist/storage/readStorageItem.js +31 -0
  46. package/dist/storage/userStorageKey.js +24 -0
  47. package/dist/storage/userStoragePrefix.js +25 -0
  48. package/dist/useDarkMode.js +17 -4
  49. package/dist/useLocale.js +18 -5
  50. package/dist/websocket/createWebSocketClient.js +234 -98
  51. package/package.json +11 -10
package/dist/request.js CHANGED
@@ -13,20 +13,87 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { decodeServerError } from '@lowdefy/engine';
16
- import { translate } from '@lowdefy/helpers';
17
- async function request({ url, method = 'GET', body }) {
16
+ import { expectedErrorNames, lowdefyErrorTypes } from '@lowdefy/errors';
17
+ import { translate, type } from '@lowdefy/helpers';
18
+ import recordCallerStamp from './recordCallerStamp.js';
19
+ import shouldReloadForAuthError from './shouldReloadForAuthError.js';
20
+ import shouldReloadForBuild from './shouldReloadForBuild.js';
21
+ import shouldReloadForCaller from './shouldReloadForCaller.js';
22
+ // buildId is the build this bundle was made from. The production server
23
+ // refuses a call from another build with a 409 before running anything, since
24
+ // an old page's payload can be the wrong shape for the new build's request and
25
+ // still be written. The tab then reloads once onto the current build. The
26
+ // promise never settles: the page is unloading, and a rejection would run the
27
+ // action's catch actions and report the refusal as an error. Only the
28
+ // production client carries a build id; the server lets a call naming none
29
+ // through (dev and e2e pages, third-party webhooks).
30
+ //
31
+ // lowdefy.user is the caller the page renders. The tab also reloads when the
32
+ // server answered as another caller or with other roles, or refused the
33
+ // rendered caller in a way that means the page itself is no longer theirs
34
+ // (shouldReloadForCaller, shouldReloadForAuthError), so it never shows one
35
+ // caller's data to another. Otherwise the response's caller stamp is kept as
36
+ // the last one seen (recordCallerStamp).
37
+ // reloadOnAuthorizationError is set for page requests, which share the page's
38
+ // auth, and not for endpoints, which carry their own.
39
+ async function request({ buildId, url, method = 'GET', body, reloadOnAuthorizationError = false, lowdefy }) {
40
+ // Read per call: the rendered caller changes with the session.
41
+ const { user } = lowdefy;
42
+ const headers = {
43
+ 'Content-Type': 'application/json'
44
+ };
45
+ if (!type.isNone(buildId)) {
46
+ headers['x-lowdefy-build'] = buildId;
47
+ }
18
48
  const res = await fetch(url, {
19
49
  method,
20
- headers: {
21
- 'Content-Type': 'application/json'
22
- },
50
+ headers,
23
51
  body: JSON.stringify(body)
24
52
  });
53
+ const serverCaller = res.headers.get('x-lowdefy-caller');
54
+ if (shouldReloadForCaller({
55
+ serverCaller,
56
+ user,
57
+ window
58
+ })) {
59
+ window.location.reload();
60
+ return new Promise(()=>{});
61
+ }
62
+ recordCallerStamp({
63
+ lowdefy,
64
+ serverCaller
65
+ });
25
66
  if (!res.ok) {
26
67
  const body = await res.json();
68
+ if (res.status === 409 && shouldReloadForBuild({
69
+ bundleBuildId: buildId,
70
+ serverBuildId: body?.buildId,
71
+ window
72
+ })) {
73
+ window.location.reload();
74
+ return new Promise(()=>{});
75
+ }
27
76
  if (body?.['~e']) {
28
77
  throw decodeServerError(body);
29
78
  }
79
+ // The server answers an expected outcome (an auth gate's 401 or 403, a
80
+ // UserError's 400) itself, as { name, message } with no serialized error.
81
+ // Rebuilding its class keeps the name the engine, handleError and the dev
82
+ // tools tell an expected outcome from a fault by.
83
+ if (expectedErrorNames.has(body?.name)) {
84
+ const ExpectedError = lowdefyErrorTypes[body.name];
85
+ const error = new ExpectedError(body.message);
86
+ if (shouldReloadForAuthError({
87
+ error,
88
+ reloadOnAuthorizationError,
89
+ user,
90
+ window
91
+ })) {
92
+ window.location.reload();
93
+ return new Promise(()=>{});
94
+ }
95
+ throw error;
96
+ }
30
97
  throw new Error(body.message || translate({
31
98
  key: 'client.requestError'
32
99
  }));
@@ -0,0 +1,32 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ // Sets the caller the app renders. CallAPI results (lowdefy.apiResponses, read
17
+ // by _api) are app-level, so they are kept per organization as page contexts
18
+ // are (see the engine's getContext): when the caller's organization changes,
19
+ // the results fetched under the previous one are dropped, so none is read
20
+ // under another organization. Results fetched with no organization hold no
21
+ // organization's data and are kept when the caller gains one. A call still in
22
+ // flight writes into the dropped object. Author global state is left alone.
23
+ function setLowdefyUser({ lowdefy, user }) {
24
+ const organizationId = user?.organization_id ?? null;
25
+ const resultsOrganizationId = lowdefy._internal.apiResponsesOrganizationId;
26
+ if (!type.isNone(resultsOrganizationId) && resultsOrganizationId !== organizationId) {
27
+ lowdefy.apiResponses = {};
28
+ }
29
+ lowdefy._internal.apiResponsesOrganizationId = organizationId;
30
+ lowdefy.user = user ?? null;
31
+ }
32
+ export default setLowdefyUser;
package/dist/setupLink.js CHANGED
@@ -13,12 +13,27 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */ import { createLink } from '@lowdefy/engine';
16
+ import { ConfigError } from '@lowdefy/errors';
16
17
  import { createUrl } from './adapters/url.js';
17
18
  function setupLink(lowdefy) {
18
19
  const { router } = lowdefy._internal;
19
20
  const { window } = lowdefy._internal.globals;
20
21
  const backLink = ()=>router.back();
21
22
  const disabledLink = ()=>{};
23
+ // window.open returns null when the browser blocks the popup - tell the user
24
+ // rather than throwing a TypeError on the null handle.
25
+ const openNewTab = (target)=>{
26
+ const handle = window.open(target, '_blank');
27
+ if (!handle) {
28
+ lowdefy._internal.displayMessage({
29
+ content: lowdefy._internal.translate('client.popupBlocked'),
30
+ status: 'info',
31
+ duration: 10
32
+ });
33
+ return;
34
+ }
35
+ return handle.focus();
36
+ };
22
37
  // `href` wins over `url`, and is used verbatim — the same precedence the anchor
23
38
  // renderer applies (createLinkComponent.js). createLink only ever sets one of
24
39
  // them, and it gives `url` a protocol before we see it while leaving `href`
@@ -29,16 +44,7 @@ function setupLink(lowdefy) {
29
44
  const newOriginLink = ({ href, url, query, newTab })=>{
30
45
  const target = href ?? `${url}${query ? `?${query}` : ''}`;
31
46
  if (newTab) {
32
- const handle = window.open(target, '_blank');
33
- if (!handle) {
34
- lowdefy._internal.displayMessage({
35
- content: lowdefy._internal.translate('client.popupBlocked'),
36
- status: 'info',
37
- duration: 10
38
- });
39
- return;
40
- }
41
- return handle.focus();
47
+ return openNewTab(target);
42
48
  }
43
49
  return window.location.assign(target);
44
50
  };
@@ -47,11 +53,11 @@ function setupLink(lowdefy) {
47
53
  // let a same-page Link reflect state into the url without moving the page.
48
54
  const sameOriginLink = ({ newTab, pathname, query, replace, scroll, setInput })=>{
49
55
  if (newTab) {
50
- return window.open(`${window.location.origin}${createUrl({
56
+ return openNewTab(`${window.location.origin}${createUrl({
51
57
  basePath: lowdefy.basePath,
52
58
  pathname,
53
59
  query
54
- })}`, '_blank').focus();
60
+ })}`);
55
61
  }
56
62
  setInput();
57
63
  const navigate = replace ? router.replace : router.push;
@@ -62,7 +68,7 @@ function setupLink(lowdefy) {
62
68
  });
63
69
  };
64
70
  const noLink = ()=>{
65
- throw new Error(`Invalid Link.`);
71
+ throw new ConfigError('Invalid Link: no target resolved.');
66
72
  };
67
73
  return createLink({
68
74
  backLink,
@@ -0,0 +1,39 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import claimCallerReload from './claimCallerReload.js';
17
+ // True when an auth refusal means the page shows a caller the server no longer
18
+ // admits, so it must reload and let the server render the page again. A 401
19
+ // while a user is rendered means the session ended; with no user it is an
20
+ // ordinary refusal on a public page and is thrown, so it cannot loop. A
21
+ // request's 403 reloads too: page requests share their page's auth, so the
22
+ // caller lost the page. An endpoint's 403 does not, since endpoints carry their
23
+ // own roles and a refusal is an ordinary outcome there. A two-factor enrolment
24
+ // refusal never reloads. Matched by name: plugins bundle their own
25
+ // @lowdefy/errors copy.
26
+ function shouldReloadForAuthError({ error, reloadOnAuthorizationError, user, window }) {
27
+ if (error.name === 'AuthenticationError' && !type.isNone(user)) {
28
+ return claimCallerReload({
29
+ window
30
+ });
31
+ }
32
+ if (error.name === 'AuthorizationError' && reloadOnAuthorizationError === true) {
33
+ return claimCallerReload({
34
+ window
35
+ });
36
+ }
37
+ return false;
38
+ }
39
+ export default shouldReloadForAuthError;
@@ -0,0 +1,44 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ // sessionStorage key holding the server build this tab last reloaded for.
17
+ const RELOADED_FOR_BUILD_KEY = 'lowdefy.reloadedForBuild';
18
+ // True when the server answered from a different build than the bundle this
19
+ // tab is running, so the tab must do a full load to pick up the current
20
+ // bundle. Without this a page config from the new build can reference _js
21
+ // functions the old bundle never shipped, and the old page's requests can
22
+ // name requests, endpoints or auth routes the new build renamed or removed.
23
+ //
24
+ // A tab reloads at most once per server build: if the freshly loaded bundle
25
+ // still disagrees with the server (a rolling deploy answering from mixed
26
+ // versions, a proxy caching old HTML), reloading again would loop forever.
27
+ // The old-config-on-old-bundle throw is the lesser failure in that case.
28
+ function shouldReloadForBuild({ bundleBuildId, serverBuildId, window }) {
29
+ if (type.isNone(serverBuildId) || serverBuildId === bundleBuildId) {
30
+ return false;
31
+ }
32
+ try {
33
+ if (window.sessionStorage.getItem(RELOADED_FOR_BUILD_KEY) === serverBuildId) {
34
+ return false;
35
+ }
36
+ window.sessionStorage.setItem(RELOADED_FOR_BUILD_KEY, serverBuildId);
37
+ } catch (error) {
38
+ // Browsers with storage disabled throw on sessionStorage access. Without
39
+ // the once-per-build record the loop guard cannot work, so do not reload.
40
+ return false;
41
+ }
42
+ return true;
43
+ }
44
+ export default shouldReloadForBuild;
@@ -0,0 +1,50 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { AuthenticationError } from '@lowdefy/errors';
16
+ import { parseCallerStamp } from '@lowdefy/helpers';
17
+ import claimCallerReload from './claimCallerReload.js';
18
+ import hasOtherRoles from './hasOtherRoles.js';
19
+ import isOtherCaller from './isOtherCaller.js';
20
+ // True when the page must reload because the server answered as another caller
21
+ // than the page renders - another person signed in, or the organization was
22
+ // switched, in another tab - or answered the same caller with other roles, so
23
+ // the page never shows two callers' data or access the person no longer holds.
24
+ // serverCaller is the response's x-lowdefy-caller header. A changed session or
25
+ // membership list with the same person, organization and roles does not reload.
26
+ // A second difference within the reload interval throws instead of reloading
27
+ // again (see claimCallerReload).
28
+ function shouldReloadForCaller({ serverCaller, user, window }) {
29
+ const stamp = parseCallerStamp(serverCaller);
30
+ const otherCaller = isOtherCaller({
31
+ stamp,
32
+ user
33
+ });
34
+ if (!otherCaller && !hasOtherRoles({
35
+ stamp,
36
+ user
37
+ })) {
38
+ return false;
39
+ }
40
+ if (claimCallerReload({
41
+ window
42
+ })) {
43
+ return true;
44
+ }
45
+ if (otherCaller) {
46
+ throw new AuthenticationError('The server answered as a different user or organization than this page shows. Reload the page.');
47
+ }
48
+ throw new AuthenticationError('The server answered with different roles than this page shows. Reload the page.');
49
+ }
50
+ export default shouldReloadForCaller;
@@ -0,0 +1,36 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ import listUserStorageKeys from './listUserStorageKeys.js';
17
+ // Removes every user key of this app not owned by keepUserId (all of them when keepUserId is
18
+ // null), and the session storage Lowdefy writes for the person browsing. Device preferences and
19
+ // the build reload markers stay.
20
+ function clearUserStorage({ basePath, keepUserId, slug, window }) {
21
+ try {
22
+ listUserStorageKeys({
23
+ basePath,
24
+ slug,
25
+ storage: window.localStorage
26
+ }).filter(({ userId })=>type.isNone(keepUserId) || userId !== keepUserId).forEach(({ key })=>window.localStorage.removeItem(key));
27
+ } catch {
28
+ // Storage the browser blocks holds nothing to clear.
29
+ }
30
+ try {
31
+ window.sessionStorage.removeItem('lowdefy_scroll');
32
+ } catch {
33
+ // Storage the browser blocks holds nothing to clear.
34
+ }
35
+ }
36
+ export default clearUserStorage;
@@ -0,0 +1,41 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import clearUserStorage from './clearUserStorage.js';
16
+ import listUserStorageKeys from './listUserStorageKeys.js';
17
+ // The person signed in (or nobody) may not be the one who left data on this browser: another
18
+ // person signed in on it, or a session ended without Logout running. Their keys go before any
19
+ // block reads storage.
20
+ function clearUserStorageOnOwnerChange({ basePath, slug, userId, window }) {
21
+ let entries;
22
+ try {
23
+ entries = listUserStorageKeys({
24
+ basePath,
25
+ slug,
26
+ storage: window.localStorage
27
+ });
28
+ } catch {
29
+ // Storage the browser blocks holds nothing to clear.
30
+ return;
31
+ }
32
+ if (entries.some((entry)=>entry.userId !== userId)) {
33
+ clearUserStorage({
34
+ basePath,
35
+ keepUserId: userId,
36
+ slug,
37
+ window
38
+ });
39
+ }
40
+ }
41
+ export default clearUserStorageOnOwnerChange;
@@ -0,0 +1,48 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { appStorageKey } from '@lowdefy/helpers';
16
+ import clearUserStorage from './clearUserStorage.js';
17
+ import clearUserStorageOnOwnerChange from './clearUserStorageOnOwnerChange.js';
18
+ import userStorageKey from './userStorageKey.js';
19
+ // Storage keys for this app, reached by blocks and actions through their methods. getUserId is
20
+ // read on every call, so a user key always names the person signed in now.
21
+ function createStorage({ basePath, getUserId, slug, window }) {
22
+ return {
23
+ appKey: (name)=>appStorageKey({
24
+ basePath,
25
+ name,
26
+ slug
27
+ }),
28
+ clearUserStorage: ({ keepUserId })=>clearUserStorage({
29
+ basePath,
30
+ keepUserId,
31
+ slug,
32
+ window
33
+ }),
34
+ clearUserStorageOnOwnerChange: ()=>clearUserStorageOnOwnerChange({
35
+ basePath,
36
+ slug,
37
+ userId: getUserId(),
38
+ window
39
+ }),
40
+ userKey: (name)=>userStorageKey({
41
+ basePath,
42
+ name,
43
+ slug,
44
+ userId: getUserId()
45
+ })
46
+ };
47
+ }
48
+ export default createStorage;
@@ -0,0 +1,36 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import userStoragePrefix from './userStoragePrefix.js';
16
+ // Every user key of this app in storage, with its owner's user id (null for an anonymous
17
+ // visitor). Keys are read before any is removed, since removing shifts storage indexes.
18
+ function listUserStorageKeys({ basePath, slug, storage }) {
19
+ const prefix = userStoragePrefix({
20
+ basePath,
21
+ slug
22
+ });
23
+ const entries = [];
24
+ for(let index = 0; index < storage.length; index += 1){
25
+ const key = storage.key(index);
26
+ if (key.startsWith(prefix)) {
27
+ const owner = key.slice(prefix.length, key.indexOf(':', prefix.length));
28
+ entries.push({
29
+ key,
30
+ userId: owner === '' ? null : decodeURIComponent(owner)
31
+ });
32
+ }
33
+ }
34
+ return entries;
35
+ }
36
+ export default listUserStorageKeys;
@@ -0,0 +1,31 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { type } from '@lowdefy/helpers';
16
+ // Reads key, moving a value left under legacyKey (written before keys were kept per app) to it
17
+ // the first time. Returns null when neither holds a value or the browser blocks storage.
18
+ function readStorageItem({ key, legacyKey, storage }) {
19
+ try {
20
+ const value = storage.getItem(key);
21
+ if (!type.isNone(value)) return value;
22
+ const legacyValue = storage.getItem(legacyKey);
23
+ if (type.isNone(legacyValue)) return null;
24
+ storage.setItem(key, legacyValue);
25
+ storage.removeItem(legacyKey);
26
+ return legacyValue;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ export default readStorageItem;
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import userStoragePrefix from './userStoragePrefix.js';
16
+ // The user id is encoded so the owner always ends at the next ":", whatever the id holds. An
17
+ // anonymous visitor owns the empty id.
18
+ function userStorageKey({ basePath, name, slug, userId }) {
19
+ return `${userStoragePrefix({
20
+ basePath,
21
+ slug
22
+ })}${encodeURIComponent(userId ?? '')}:${name}`;
23
+ }
24
+ export default userStorageKey;
@@ -0,0 +1,25 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { appStorageKey } from '@lowdefy/helpers';
16
+ // A person's data sits under one name in the app's namespace, then the owner's user id. Keys set
17
+ // with SetLocalStorage can not start with "lowdefy_", so no app key reads as a user key.
18
+ function userStoragePrefix({ basePath, slug }) {
19
+ return appStorageKey({
20
+ basePath,
21
+ name: 'lowdefy_user:',
22
+ slug
23
+ });
24
+ }
25
+ export default userStoragePrefix;
@@ -14,6 +14,7 @@
14
14
  limitations under the License.
15
15
  */ import { useCallback, useEffect, useMemo, useState } from 'react';
16
16
  import { theme as antdTheme } from 'antd';
17
+ import readStorageItem from './storage/readStorageItem.js';
17
18
  const algorithmMap = {
18
19
  default: antdTheme.defaultAlgorithm,
19
20
  dark: antdTheme.darkAlgorithm,
@@ -67,11 +68,16 @@ function mergeComponents(shared, mode) {
67
68
  });
68
69
  return merged;
69
70
  }
70
- function useDarkMode({ antd, configDarkMode }) {
71
+ // storageKey is the app key the preference is kept under (see @lowdefy/helpers appStorageKey).
72
+ function useDarkMode({ antd, configDarkMode, storageKey }) {
71
73
  const baseAlgorithm = antd?.algorithm;
72
74
  const cleanAlgorithm = stripDarkFromAlgorithm(baseAlgorithm);
73
75
  const [userPreference, setUserPreference] = useState(()=>{
74
- return window.localStorage?.getItem('lowdefy_darkMode') ?? 'system';
76
+ return readStorageItem({
77
+ key: storageKey,
78
+ legacyKey: 'lowdefy_darkMode',
79
+ storage: window.localStorage
80
+ }) ?? 'system';
75
81
  });
76
82
  const [systemIsDark, setSystemIsDark] = useState(()=>{
77
83
  return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ?? false;
@@ -85,10 +91,17 @@ function useDarkMode({ antd, configDarkMode }) {
85
91
  return ()=>mql.removeEventListener('change', handler);
86
92
  }, []);
87
93
  const setPreference = useCallback((newPref)=>{
88
- window.localStorage?.setItem('lowdefy_darkMode', newPref);
94
+ window.localStorage?.setItem(storageKey, newPref);
95
+ // Set now, not on the next render: SetDarkMode updates the engine straight after, and
96
+ // _media: darkModePreference reads it then.
97
+ window.__lowdefy_darkModePreference = newPref;
89
98
  setUserPreference(newPref);
90
- }, []);
99
+ }, [
100
+ storageKey
101
+ ]);
91
102
  window.__lowdefy_setDarkMode = setPreference;
103
+ // SetDarkMode, _media and the header toggles read the preference here, not from storage.
104
+ window.__lowdefy_darkModePreference = userPreference;
92
105
  const isDark = resolveIsDark({
93
106
  configDarkMode,
94
107
  userPreference,