@rebasepro/firebase 0.13.0 → 0.13.1-canary.g06dbe5b

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.
@@ -117,7 +117,7 @@ export type RebaseFirebaseAppProps = {
117
117
  */
118
118
  plugins?: RebasePlugin[];
119
119
  /**
120
- * Open the drawer on hover. Defaults to `false`
120
+ * Open the drawer on hover. Defaults to `true`
121
121
  */
122
122
  autoOpenDrawer?: boolean;
123
123
  /**
@@ -35,6 +35,35 @@ export interface UserManagementDelegateParams<CONTROLLER extends AuthController<
35
35
  */
36
36
  allowDefaultRolesCreation?: boolean;
37
37
  }
38
+ /**
39
+ * Why the access gate answered the way it did.
40
+ *
41
+ * `users-unreadable` is a state of its own because it used to be
42
+ * indistinguishable from `bootstrap`: the users listener's `onError` empties
43
+ * the user list, so a `permission-denied` on the users path — a rules
44
+ * misconfiguration, a rules deploy that has not landed, a renamed path —
45
+ * reached the gate as "no users created yet" and every authenticated user was
46
+ * let in. An unreadable list is not an empty one, and only one of the two may
47
+ * open the door.
48
+ */
49
+ export type AccessDecision = "loading" | "no-user" | "users-unreadable" | "bootstrap" | "known-user" | "unknown-user";
50
+ /**
51
+ * Decide whether a user may access the CMS, given the state of the user
52
+ * management collection.
53
+ *
54
+ * A plain function rather than logic inside the gate callback so the states
55
+ * that must stay distinct can be asserted without a React render.
56
+ */
57
+ export declare function resolveAccessDecision({ loading, usersError, users, user }: {
58
+ loading: boolean;
59
+ usersError?: Error;
60
+ users: {
61
+ email?: string | null;
62
+ }[];
63
+ user: {
64
+ email?: string | null;
65
+ } | null;
66
+ }): AccessDecision;
38
67
  /**
39
68
  * This hook is used to build a user management object that can be used to
40
69
  * manage users and roles in a Firestore backend.
@@ -1,6 +1,20 @@
1
1
  import { FirebaseApp } from "firebase/app";
2
2
  import { FirebaseAuthController, FirebaseSignInOption, FirebaseSignInProvider, FirebaseUserWrapper } from "../types";
3
3
  import type { User } from "@rebasepro/types";
4
+ /**
5
+ * Resolve `user`'s roles through `defineRolesFor` and report whether they
6
+ * differ from the ones already applied.
7
+ *
8
+ * A plain function rather than a check inside the hook because the check that
9
+ * used to live there read `!equal(userRoles, userRoles)` — the local shadowed
10
+ * the state of the same name, so the fresh roles were compared to themselves,
11
+ * the guard was never true, and a `defineRolesFor` result arriving after the
12
+ * auth-state change never reached the controller.
13
+ */
14
+ export declare function resolveRoleRefresh(defineRolesFor: (user: User) => Promise<string[] | undefined> | string[] | undefined, user: User, currentRoles: string[] | undefined): Promise<{
15
+ changed: boolean;
16
+ roles: string[] | undefined;
17
+ }>;
4
18
  export interface FirebaseAuthControllerProps {
5
19
  loading?: boolean;
6
20
  firebaseApp?: FirebaseApp;
@@ -1,5 +1,43 @@
1
1
  import { FirebaseApp } from "firebase/app";
2
- import { DataDriver } from "@rebasepro/types";
2
+ import { DataDriver, FetchCollectionProps } from "@rebasepro/types";
3
+ /** The values the Realtime Database can order or bound a query by. */
4
+ type RTDBFilterValue = string | number | boolean | null;
5
+ /**
6
+ * A read expressed in the Realtime Database's own query model.
7
+ *
8
+ * @see planRTDBQuery
9
+ */
10
+ export type RTDBQueryPlan = {
11
+ /** Child key to order — and therefore to bound — by. Absent means order by key. */
12
+ orderByChild?: string;
13
+ equalTo?: RTDBFilterValue;
14
+ startAt?: RTDBFilterValue;
15
+ /** Key the window starts after, exclusive. Only valid in key order. */
16
+ startAfter?: RTDBFilterValue;
17
+ endAt?: RTDBFilterValue;
18
+ limitToFirst?: number;
19
+ /**
20
+ * Rows to drop from the front of the result — the caller's `offset`, which
21
+ * the database has no constraint for. Applied by the caller, not by
22
+ * {@link rtdbConstraints}.
23
+ */
24
+ skip?: number;
25
+ };
26
+ /**
27
+ * Translate a driver read into the Realtime Database's query model, or refuse it.
28
+ *
29
+ * The Realtime Database orders by a single child key per query and bounds that
30
+ * one key with `equalTo`/`startAt`/`endAt`. Nothing else is expressible: no
31
+ * second field, no descending order, no text search, no `or(...)` group.
32
+ *
33
+ * Everything beyond `limit` and `startAfter` used to be destructured out of the
34
+ * read and then never referenced, so a caller asking for `status == "draft"`
35
+ * was handed the entire collection, presented as the answer. A query this
36
+ * database cannot express is refused here instead — a caller that sees an error
37
+ * can fall back, a caller that sees the wrong rows cannot.
38
+ */
39
+ export declare function planRTDBQuery<M extends Record<string, any>>({ filter, orderBy, order, searchString, logical, limit, offset, startAfter: startAfterKey }: Pick<FetchCollectionProps<M>, "filter" | "orderBy" | "order" | "searchString" | "logical" | "limit" | "offset" | "startAfter">): RTDBQueryPlan;
3
40
  export declare function useFirebaseRTDBDelegate({ firebaseApp }: {
4
41
  firebaseApp?: FirebaseApp;
5
42
  }): DataDriver;
43
+ export {};
@@ -36,6 +36,26 @@ export type FirestoreDataDriver = DataDriver & {
36
36
  collection?: CollectionConfig;
37
37
  }) => Promise<boolean>;
38
38
  };
39
+ /**
40
+ * The window a read has to ask Firestore for in order to honour `offset`.
41
+ *
42
+ * Firestore's web SDK has no `offset()` — it pages by cursor (`startAfter`)
43
+ * only. Callers that page by offset (`buildRebaseData`, and through it every
44
+ * `findAll()` and `iterate()`) were therefore served page one every time:
45
+ * `count` is a real server count, so `hasMore` never went false, the walk
46
+ * accumulated the same rows over and over, and it ended by tripping its row
47
+ * cap and reporting "matched more than N rows" — a condition that had not
48
+ * occurred.
49
+ *
50
+ * So the read asks for `offset + limit` documents and drops the first
51
+ * `offset`. Those documents are billed either way: Firestore charges for every
52
+ * document a cursor walks past, which is why `startAfter` is the cheap way to
53
+ * page and offset paging over a large collection is not.
54
+ */
55
+ export declare function resolveOffsetWindow(limit: number | undefined, offset: number | undefined): {
56
+ fetchLimit: number | undefined;
57
+ skip: number;
58
+ };
39
59
  /**
40
60
  * Use this hook to build a {@link DataDriver} based on Firestore
41
61
  * @param firebaseApp
package/dist/index.es.js CHANGED
@@ -9,7 +9,7 @@ import { DocumentReference, GeoPoint as GeoPoint$1, Timestamp, VectorValue, coll
9
9
  import { COLLECTION_PATH_SEPARATOR, buildRebaseData, sortProperties, stripCollectionPath } from "@rebasepro/common";
10
10
  import Fuse from "fuse.js";
11
11
  import { getFunctions, httpsCallable } from "firebase/functions";
12
- import { get, getDatabase, limitToFirst, onValue, orderByChild, orderByKey, push, query as query$1, ref as ref$1, remove, set, startAt } from "firebase/database";
12
+ import { endAt, equalTo, get, getDatabase, limitToFirst, onValue, orderByChild, orderByKey, push, query as query$1, ref as ref$1, remove, set, startAfter as startAfter$1, startAt } from "firebase/database";
13
13
  import { removeUndefined } from "@rebasepro/utils";
14
14
  import { AdminModeControllerProvider, ErrorView, ModeControllerProvider, Rebase, RebaseLogo, RebaseRoutes, SnackbarProvider, useBrowserTitleAndIcon, useBuildAdminModeController, useBuildLocalConfigurationPersistence, useBuildModeController, useModeController, useSnackbarController } from "@rebasepro/app";
15
15
  import { AppBar, CollectionRegistryContext, Drawer, NavigationStateContext, RebaseRoute, Scaffold, SideDialogs, SidePanelProvider, UrlContext, useBuildCollectionRegistryController, useBuildNavigationStateController, useBuildUrlController } from "@rebasepro/admin";
@@ -18,6 +18,23 @@ import { Navigate, Outlet, Route } from "react-router";
18
18
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
19
19
  //#region src/hooks/useFirebaseAuthController.ts
20
20
  /**
21
+ * Resolve `user`'s roles through `defineRolesFor` and report whether they
22
+ * differ from the ones already applied.
23
+ *
24
+ * A plain function rather than a check inside the hook because the check that
25
+ * used to live there read `!equal(userRoles, userRoles)` — the local shadowed
26
+ * the state of the same name, so the fresh roles were compared to themselves,
27
+ * the guard was never true, and a `defineRolesFor` result arriving after the
28
+ * auth-state change never reached the controller.
29
+ */
30
+ async function resolveRoleRefresh(defineRolesFor, user, currentRoles) {
31
+ const roles = await defineRolesFor(user);
32
+ return {
33
+ changed: !deepEqual(currentRoles, roles),
34
+ roles
35
+ };
36
+ }
37
+ /**
21
38
  * Use this hook to build an {@link AuthController} based on Firebase Auth
22
39
  * @group Firebase
23
40
  */
@@ -44,8 +61,8 @@ var useFirebaseAuthController = ({ loading, firebaseApp, signInOptions, onSignOu
44
61
  }, [loading]);
45
62
  const updateRoles = useCallback(async (user) => {
46
63
  if (defineRolesFor && user) {
47
- const userRoles = await defineRolesFor(user);
48
- if (!deepEqual(userRoles, userRoles)) setUserRoles(userRoles);
64
+ const { changed, roles } = await resolveRoleRefresh(defineRolesFor, user, userRoles);
65
+ if (changed) setUserRoles(roles);
49
66
  }
50
67
  }, [defineRolesFor, userRoles]);
51
68
  useEffect(() => {
@@ -944,6 +961,29 @@ function buildRebaseSearchController(options) {
944
961
  //#endregion
945
962
  //#region src/hooks/useFirestoreDriver.ts
946
963
  /**
964
+ * The window a read has to ask Firestore for in order to honour `offset`.
965
+ *
966
+ * Firestore's web SDK has no `offset()` — it pages by cursor (`startAfter`)
967
+ * only. Callers that page by offset (`buildRebaseData`, and through it every
968
+ * `findAll()` and `iterate()`) were therefore served page one every time:
969
+ * `count` is a real server count, so `hasMore` never went false, the walk
970
+ * accumulated the same rows over and over, and it ended by tripping its row
971
+ * cap and reporting "matched more than N rows" — a condition that had not
972
+ * occurred.
973
+ *
974
+ * So the read asks for `offset + limit` documents and drops the first
975
+ * `offset`. Those documents are billed either way: Firestore charges for every
976
+ * document a cursor walks past, which is why `startAfter` is the cheap way to
977
+ * page and offset paging over a large collection is not.
978
+ */
979
+ function resolveOffsetWindow(limit, offset) {
980
+ const skip = offset !== void 0 && Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
981
+ return {
982
+ fetchLimit: limit === void 0 ? void 0 : limit + skip,
983
+ skip
984
+ };
985
+ }
986
+ /**
947
987
  * Use this hook to build a {@link DataDriver} based on Firestore
948
988
  * @param firebaseApp
949
989
  * @param textSearchControllerBuilder
@@ -964,7 +1004,7 @@ function useFirestoreDriver({ firebaseApp, textSearchControllerBuilder, firestor
964
1004
  localTextSearchEnabled,
965
1005
  textSearchControllerBuilder
966
1006
  ]);
967
- const buildQuery = useCallback((path, filter, orderBy$1, order, startAfter$1, limit$1, databaseId) => {
1007
+ const buildQuery = useCallback((path, filter, orderBy$1, order, startAfter$2, limit$1, databaseId) => {
968
1008
  if (!firebaseApp) throw Error("useFirestoreDriver Firebase not initialised");
969
1009
  const firestore = databaseId ? getFirestore(firebaseApp, databaseId) : getFirestore(firebaseApp);
970
1010
  const collectionReference = collection(firestore, path);
@@ -983,7 +1023,7 @@ function useFirestoreDriver({ firebaseApp, textSearchControllerBuilder, firestor
983
1023
  queryParams.push(where(key, op, rebaseToFirestoreModel(value, firestore)));
984
1024
  });
985
1025
  if (orderBy$1 && order) queryParams.push(orderBy(orderBy$1, order));
986
- if (startAfter$1) queryParams.push(startAfter(startAfter$1));
1026
+ if (startAfter$2) queryParams.push(startAfter(startAfter$2));
987
1027
  if (limit$1) queryParams.push(limit(limit$1));
988
1028
  return query(collectionReference, ...queryParams);
989
1029
  }, [firebaseApp]);
@@ -1067,26 +1107,29 @@ function useFirestoreDriver({ firebaseApp, textSearchControllerBuilder, firestor
1067
1107
  * @param collection
1068
1108
  * @param filter
1069
1109
  * @param limit
1110
+ * @param offset
1070
1111
  * @param startAfter
1071
1112
  * @param searchString
1072
1113
  * @param orderBy
1073
1114
  * @param order
1074
- * @return Function to cancel subscription
1115
+ * @return The rows in the requested window
1075
1116
  * @see useCollection if you need this functionality implemented as a hook
1076
1117
  * @group Firestore
1077
1118
  */
1078
- const fetchCollection = useCallback(async ({ path, filter, limit, startAfter, searchString, orderBy, order, collection }) => {
1119
+ const fetchCollection = useCallback(async ({ path, filter, limit, offset, startAfter, searchString, orderBy, order, collection }) => {
1079
1120
  const databaseId = collection?.databaseId;
1080
1121
  const resolvedPath = path;
1081
1122
  console.debug("Fetching collection", {
1082
1123
  path,
1083
1124
  limit,
1125
+ offset,
1084
1126
  filter,
1085
1127
  startAfter,
1086
1128
  orderBy,
1087
1129
  order
1088
1130
  });
1089
- return (await getDocs(buildQuery(resolvedPath, filter, orderBy, order, startAfter, limit, databaseId))).docs.map((doc) => createRowFromDocument(doc));
1131
+ const { fetchLimit, skip } = resolveOffsetWindow(limit, offset);
1132
+ return (await getDocs(buildQuery(resolvedPath, filter, orderBy, order, startAfter, fetchLimit, databaseId))).docs.slice(skip).map((doc) => createRowFromDocument(doc));
1090
1133
  }, [buildQuery]);
1091
1134
  /**
1092
1135
  * Listen to a entities in a given path
@@ -1384,30 +1427,99 @@ function buildTextSearchControllerWithLocalSearch({ textSearchControllerBuilder,
1384
1427
  }
1385
1428
  //#endregion
1386
1429
  //#region src/hooks/useFirebaseRealTimeDBDelegate.ts
1430
+ var RTDB = "useFirebaseRTDBDelegate";
1431
+ var isRTDBValue = (value) => value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
1432
+ /**
1433
+ * Translate a driver read into the Realtime Database's query model, or refuse it.
1434
+ *
1435
+ * The Realtime Database orders by a single child key per query and bounds that
1436
+ * one key with `equalTo`/`startAt`/`endAt`. Nothing else is expressible: no
1437
+ * second field, no descending order, no text search, no `or(...)` group.
1438
+ *
1439
+ * Everything beyond `limit` and `startAfter` used to be destructured out of the
1440
+ * read and then never referenced, so a caller asking for `status == "draft"`
1441
+ * was handed the entire collection, presented as the answer. A query this
1442
+ * database cannot express is refused here instead — a caller that sees an error
1443
+ * can fall back, a caller that sees the wrong rows cannot.
1444
+ */
1445
+ function planRTDBQuery({ filter, orderBy, order, searchString, logical, limit, offset, startAfter: startAfterKey }) {
1446
+ if (searchString) throw new Error(`${RTDB}: the Realtime Database has no text search, so \`searchString\` cannot be applied. Index the data in a search service instead.`);
1447
+ if (logical) throw new Error(`${RTDB}: the Realtime Database cannot evaluate \`or(...)\`/\`and(...)\` groups.`);
1448
+ if (order === "desc") throw new Error(`${RTDB}: the Realtime Database only orders ascending, so \`order: "desc"\` cannot be applied.`);
1449
+ const conditions = [];
1450
+ Object.entries(filter ?? {}).forEach(([key, entry]) => {
1451
+ if (!entry) return;
1452
+ (Array.isArray(entry[0]) ? entry : [entry]).forEach(([op, value]) => conditions.push([
1453
+ key,
1454
+ op,
1455
+ value
1456
+ ]));
1457
+ });
1458
+ const fields = Array.from(new Set(conditions.map(([key]) => key)));
1459
+ if (fields.length > 1) throw new Error(`${RTDB}: the Realtime Database filters on one child key per query; this read asked for ${fields.join(", ")}.`);
1460
+ const [field] = fields;
1461
+ if (field && orderBy && orderBy !== field) throw new Error(`${RTDB}: a query is ordered by the key it filters on; cannot filter \`${field}\` while ordering by \`${orderBy}\`.`);
1462
+ const orderChild = field ?? orderBy;
1463
+ const plan = orderChild ? { orderByChild: orderChild } : {};
1464
+ for (const [key, op, value] of conditions) {
1465
+ if (!isRTDBValue(value)) throw new Error(`${RTDB}: cannot bound \`${key}\` by a ${Array.isArray(value) ? "array" : typeof value} value; the Realtime Database compares strings, numbers, booleans and null.`);
1466
+ if (op === "==") {
1467
+ if (conditions.length > 1) throw new Error(`${RTDB}: \`==\` bounds a query on its own; it cannot be combined with another condition on \`${key}\`.`);
1468
+ plan.equalTo = value;
1469
+ } else if (op === ">=") plan.startAt = value;
1470
+ else if (op === "<=") plan.endAt = value;
1471
+ else throw new Error(`${RTDB}: the Realtime Database does not support the "${op}" operator (on \`${key}\`). It bounds a single child key with ==, >= and <=.`);
1472
+ }
1473
+ if (startAfterKey !== void 0) {
1474
+ if (orderChild) throw new Error(`${RTDB}: \`startAfter\` pages in key order and cannot be combined with a filter or \`orderBy\`.`);
1475
+ plan.startAfter = String(startAfterKey);
1476
+ }
1477
+ const skip = offset !== void 0 && Number.isFinite(offset) && offset > 0 ? Math.floor(offset) : 0;
1478
+ if (skip > 0) plan.skip = skip;
1479
+ if (limit !== void 0) plan.limitToFirst = limit + skip;
1480
+ return plan;
1481
+ }
1482
+ /** The plan as Realtime Database query constraints. */
1483
+ function rtdbConstraints(plan) {
1484
+ const constraints = [];
1485
+ const bounded = plan.equalTo !== void 0 || plan.startAt !== void 0 || plan.startAfter !== void 0 || plan.endAt !== void 0;
1486
+ if (plan.orderByChild !== void 0) constraints.push(orderByChild(plan.orderByChild));
1487
+ else if (bounded) constraints.push(orderByKey());
1488
+ if (plan.equalTo !== void 0) constraints.push(equalTo(plan.equalTo));
1489
+ if (plan.startAt !== void 0) constraints.push(startAt(plan.startAt));
1490
+ if (plan.startAfter !== void 0) constraints.push(startAfter$1(plan.startAfter));
1491
+ if (plan.endAt !== void 0) constraints.push(endAt(plan.endAt));
1492
+ if (plan.limitToFirst !== void 0) constraints.push(limitToFirst(plan.limitToFirst));
1493
+ return constraints;
1494
+ }
1387
1495
  function useFirebaseRTDBDelegate({ firebaseApp }) {
1388
1496
  return {
1389
1497
  key: "firebase_rtdb",
1390
- fetchCollection: useCallback(async ({ path, filter, limit, startAfter, orderBy, order, searchString }) => {
1498
+ fetchCollection: useCallback(async (props) => {
1391
1499
  if (!firebaseApp) throw new Error("Firebase app not provided");
1392
- let dbQuery = query$1(ref$1(getDatabase(firebaseApp), path));
1393
- if (startAfter !== void 0) dbQuery = query$1(dbQuery, orderByKey(), startAt(String(startAfter)));
1394
- if (limit !== void 0) dbQuery = query$1(dbQuery, limitToFirst(limit));
1395
- const entity = await get(dbQuery);
1396
- if (entity.exists()) return Object.entries(entity.val()).map(([id, values]) => ({
1500
+ const database = getDatabase(firebaseApp);
1501
+ const plan = planRTDBQuery(props);
1502
+ const entity = await get(query$1(ref$1(database, props.path), ...rtdbConstraints(plan)));
1503
+ if (entity.exists()) return Object.entries(entity.val()).slice(plan.skip ?? 0).map(([id, values]) => ({
1397
1504
  ...delegateToCMSModel(values),
1398
1505
  id
1399
1506
  }));
1400
1507
  return [];
1401
1508
  }, [firebaseApp]),
1402
- listenCollection: useCallback(({ path, onUpdate }) => {
1509
+ listenCollection: useCallback((props) => {
1403
1510
  if (!firebaseApp) throw new Error("Firebase app not provided");
1404
- const unsubscribe = onValue(ref$1(getDatabase(firebaseApp), path), (entity) => {
1405
- if (entity.exists()) onUpdate(Object.entries(entity.val()).map(([id, values]) => ({
1406
- ...delegateToCMSModel(values),
1407
- id
1408
- })));
1409
- else onUpdate([]);
1410
- });
1511
+ const database = getDatabase(firebaseApp);
1512
+ const { onUpdate, onError } = props;
1513
+ const plan = planRTDBQuery(props);
1514
+ const unsubscribe = onValue(query$1(ref$1(database, props.path), ...rtdbConstraints(plan)), (entity) => {
1515
+ if (entity.exists()) {
1516
+ const result = Object.entries(entity.val()).slice(plan.skip ?? 0).map(([id, values]) => ({
1517
+ ...delegateToCMSModel(values),
1518
+ id
1519
+ }));
1520
+ onUpdate(result);
1521
+ } else onUpdate([]);
1522
+ }, (error) => onError?.(error));
1411
1523
  return () => unsubscribe();
1412
1524
  }, [firebaseApp]),
1413
1525
  fetchOne: useCallback(async ({ path, id }) => {
@@ -1512,6 +1624,20 @@ function useRecaptcha() {
1512
1624
  //#endregion
1513
1625
  //#region src/hooks/useBuildUserManagement.tsx
1514
1626
  /**
1627
+ * Decide whether a user may access the CMS, given the state of the user
1628
+ * management collection.
1629
+ *
1630
+ * A plain function rather than logic inside the gate callback so the states
1631
+ * that must stay distinct can be asserted without a React render.
1632
+ */
1633
+ function resolveAccessDecision({ loading, usersError, users, user }) {
1634
+ if (loading) return "loading";
1635
+ if (!user) return "no-user";
1636
+ if (usersError) return "users-unreadable";
1637
+ if (users.length === 0) return "bootstrap";
1638
+ return users.some((u) => u.email?.toLowerCase() === user.email?.toLowerCase()) ? "known-user" : "unknown-user";
1639
+ }
1640
+ /**
1515
1641
  * This hook is used to build a user management object that can be used to
1516
1642
  * manage users and roles in a Firestore backend.
1517
1643
  * @param authController
@@ -1675,17 +1801,27 @@ function useBuildUserManagement({ authController, dataSourceDelegate, roles: rol
1675
1801
  return mgmtUser.roles;
1676
1802
  }, [usersWithRoleIds]);
1677
1803
  const accessGate = useCallback(({ user }) => {
1678
- if (loading) return false;
1679
- if (user === null) {
1804
+ const decision = resolveAccessDecision({
1805
+ loading,
1806
+ usersError,
1807
+ users,
1808
+ user
1809
+ });
1810
+ if (decision === "loading") return false;
1811
+ if (decision === "no-user") {
1680
1812
  console.warn("User is null, returning");
1681
1813
  return false;
1682
1814
  }
1683
- if (users.length === 0) {
1815
+ if (decision === "users-unreadable") {
1816
+ console.error("Denying access: the user management collection could not be read", usersError);
1817
+ return false;
1818
+ }
1819
+ if (decision === "bootstrap") {
1684
1820
  console.warn("No users created yet");
1685
1821
  return true;
1686
1822
  }
1687
1823
  const mgmtUser = users.find((u) => u.email?.toLowerCase() === user?.email?.toLowerCase());
1688
- if (mgmtUser) {
1824
+ if (decision === "known-user" && mgmtUser && user) {
1689
1825
  const needsUidUpdate = mgmtUser.uid !== user.uid;
1690
1826
  const needsPhotoUpdate = user.photoURL && mgmtUser.photoURL !== user.photoURL;
1691
1827
  if (needsUidUpdate || needsPhotoUpdate) {
@@ -1704,7 +1840,11 @@ function useBuildUserManagement({ authController, dataSourceDelegate, roles: rol
1704
1840
  return true;
1705
1841
  }
1706
1842
  throw Error("Could not find a user with the provided email in the user management system.");
1707
- }, [loading, users]);
1843
+ }, [
1844
+ loading,
1845
+ users,
1846
+ usersError
1847
+ ]);
1708
1848
  const userRoles = authController.user ? defineRolesFor(authController.user) : void 0;
1709
1849
  const isAdmin = (userRoles ?? []).some((r) => r === "admin");
1710
1850
  useEffect(() => {
@@ -2649,6 +2789,6 @@ function RebaseFirebaseApp({ name, logo, logoDark, accessGate, collections, view
2649
2789
  }) });
2650
2790
  }
2651
2791
  //#endregion
2652
- export { FirebaseLoginView, LoginButton, RECAPTCHA_CONTAINER_ID, RebaseFirebaseApp, buildCollectionId, buildExternalSearchController, buildPineconeSearchController, buildRebaseSearchController, docToCollection, docsToCollectionTree, firestoreToRebaseModel, getFirestoreDataInPath, localSearchControllerBuilder, performAlgoliaTextSearch, performPineconeTextSearch, rebaseToFirestoreModel, useAppCheck, useBuildUserManagement, useFirebaseAccessGate, useFirebaseAuthController, useFirebaseRTDBDelegate, useFirebaseStorageSource, useFirestoreDriver, useInitialiseFirebase, useRecaptcha };
2792
+ export { FirebaseLoginView, LoginButton, RECAPTCHA_CONTAINER_ID, RebaseFirebaseApp, buildCollectionId, buildExternalSearchController, buildPineconeSearchController, buildRebaseSearchController, docToCollection, docsToCollectionTree, firestoreToRebaseModel, getFirestoreDataInPath, localSearchControllerBuilder, performAlgoliaTextSearch, performPineconeTextSearch, planRTDBQuery, rebaseToFirestoreModel, resolveAccessDecision, resolveOffsetWindow, resolveRoleRefresh, useAppCheck, useBuildUserManagement, useFirebaseAccessGate, useFirebaseAuthController, useFirebaseRTDBDelegate, useFirebaseStorageSource, useFirestoreDriver, useInitialiseFirebase, useRecaptcha };
2653
2793
 
2654
2794
  //# sourceMappingURL=index.es.js.map