@aglyn/tenant-feature-instance 1.0.0-beta.143

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 (154) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +3 -0
  3. package/jest.config.d.ts +36 -0
  4. package/package.json +45 -0
  5. package/src/index.d.ts +17 -0
  6. package/src/index.js +18 -0
  7. package/src/index.js.map +1 -0
  8. package/src/lib/components/duplicate-resource-dialog.d.ts +77 -0
  9. package/src/lib/components/duplicate-resource-dialog.js +217 -0
  10. package/src/lib/components/duplicate-resource-dialog.js.map +1 -0
  11. package/src/lib/constants/firebase-config.d.ts +68 -0
  12. package/src/lib/constants/firebase-config.js +155 -0
  13. package/src/lib/constants/firebase-config.js.map +1 -0
  14. package/src/lib/constants/firebase-emulator-hosts.d.ts +60 -0
  15. package/src/lib/constants/firebase-emulator-hosts.js +81 -0
  16. package/src/lib/constants/firebase-emulator-hosts.js.map +1 -0
  17. package/src/lib/constants/mock-data.d.ts +24 -0
  18. package/src/lib/constants/mock-data.js +49 -0
  19. package/src/lib/constants/mock-data.js.map +1 -0
  20. package/src/lib/constants/site-paths.d.ts +47 -0
  21. package/src/lib/constants/site-paths.js +66 -0
  22. package/src/lib/constants/site-paths.js.map +1 -0
  23. package/src/lib/hooks/firebase/auth-persistence.d.ts +74 -0
  24. package/src/lib/hooks/firebase/auth-persistence.js +117 -0
  25. package/src/lib/hooks/firebase/auth-persistence.js.map +1 -0
  26. package/src/lib/hooks/firebase/firebase-services.d.ts +172 -0
  27. package/src/lib/hooks/firebase/firebase-services.js +522 -0
  28. package/src/lib/hooks/firebase/firebase-services.js.map +1 -0
  29. package/src/lib/hooks/firebase/firestore-cache.d.ts +125 -0
  30. package/src/lib/hooks/firebase/firestore-cache.js +152 -0
  31. package/src/lib/hooks/firebase/firestore-cache.js.map +1 -0
  32. package/src/lib/hooks/firebase/firestore-shared-client-state.d.ts +169 -0
  33. package/src/lib/hooks/firebase/firestore-shared-client-state.js +316 -0
  34. package/src/lib/hooks/firebase/firestore-shared-client-state.js.map +1 -0
  35. package/src/lib/hooks/firestore-denial-reporter.d.ts +192 -0
  36. package/src/lib/hooks/firestore-denial-reporter.js +293 -0
  37. package/src/lib/hooks/firestore-denial-reporter.js.map +1 -0
  38. package/src/lib/hooks/helpers/besigner-nodes-converter.d.ts +62 -0
  39. package/src/lib/hooks/helpers/besigner-nodes-converter.js +91 -0
  40. package/src/lib/hooks/helpers/besigner-nodes-converter.js.map +1 -0
  41. package/src/lib/hooks/helpers/guarded-nodes-save.d.ts +80 -0
  42. package/src/lib/hooks/helpers/guarded-nodes-save.js +89 -0
  43. package/src/lib/hooks/helpers/guarded-nodes-save.js.map +1 -0
  44. package/src/lib/hooks/helpers/guarded-seed-write.d.ts +125 -0
  45. package/src/lib/hooks/helpers/guarded-seed-write.js +193 -0
  46. package/src/lib/hooks/helpers/guarded-seed-write.js.map +1 -0
  47. package/src/lib/hooks/helpers/listen-options.d.ts +91 -0
  48. package/src/lib/hooks/helpers/listen-options.js +92 -0
  49. package/src/lib/hooks/helpers/listen-options.js.map +1 -0
  50. package/src/lib/hooks/helpers/use-doc.d.ts +38 -0
  51. package/src/lib/hooks/helpers/use-doc.js +213 -0
  52. package/src/lib/hooks/helpers/use-doc.js.map +1 -0
  53. package/src/lib/hooks/helpers/use-modify-doc-callback.d.ts +28 -0
  54. package/src/lib/hooks/helpers/use-modify-doc-callback.js +110 -0
  55. package/src/lib/hooks/helpers/use-modify-doc-callback.js.map +1 -0
  56. package/src/lib/hooks/host-collection-queries.d.ts +125 -0
  57. package/src/lib/hooks/host-collection-queries.js +126 -0
  58. package/src/lib/hooks/host-collection-queries.js.map +1 -0
  59. package/src/lib/hooks/list-filter-constraints.d.ts +64 -0
  60. package/src/lib/hooks/list-filter-constraints.js +288 -0
  61. package/src/lib/hooks/list-filter-constraints.js.map +1 -0
  62. package/src/lib/hooks/sorted-collection-window.d.ts +207 -0
  63. package/src/lib/hooks/sorted-collection-window.js +150 -0
  64. package/src/lib/hooks/sorted-collection-window.js.map +1 -0
  65. package/src/lib/hooks/use-component-version.d.ts +43 -0
  66. package/src/lib/hooks/use-component-version.js +73 -0
  67. package/src/lib/hooks/use-component-version.js.map +1 -0
  68. package/src/lib/hooks/use-component.d.ts +39 -0
  69. package/src/lib/hooks/use-component.js +55 -0
  70. package/src/lib/hooks/use-component.js.map +1 -0
  71. package/src/lib/hooks/use-console-host-route.d.ts +24 -0
  72. package/src/lib/hooks/use-console-host-route.js +77 -0
  73. package/src/lib/hooks/use-console-host-route.js.map +1 -0
  74. package/src/lib/hooks/use-duplicate-resource-api.d.ts +43 -0
  75. package/src/lib/hooks/use-duplicate-resource-api.js +61 -0
  76. package/src/lib/hooks/use-duplicate-resource-api.js.map +1 -0
  77. package/src/lib/hooks/use-firestore-collection.d.ts +62 -0
  78. package/src/lib/hooks/use-firestore-collection.js +307 -0
  79. package/src/lib/hooks/use-firestore-collection.js.map +1 -0
  80. package/src/lib/hooks/use-firestore-doc.d.ts +42 -0
  81. package/src/lib/hooks/use-firestore-doc.js +192 -0
  82. package/src/lib/hooks/use-firestore-doc.js.map +1 -0
  83. package/src/lib/hooks/use-form-version.d.ts +44 -0
  84. package/src/lib/hooks/use-form-version.js +73 -0
  85. package/src/lib/hooks/use-form-version.js.map +1 -0
  86. package/src/lib/hooks/use-host-activity-logger.d.ts +35 -0
  87. package/src/lib/hooks/use-host-activity-logger.js +96 -0
  88. package/src/lib/hooks/use-host-activity-logger.js.map +1 -0
  89. package/src/lib/hooks/use-host-campaigns.d.ts +53 -0
  90. package/src/lib/hooks/use-host-campaigns.js +93 -0
  91. package/src/lib/hooks/use-host-campaigns.js.map +1 -0
  92. package/src/lib/hooks/use-host-org-id.d.ts +96 -0
  93. package/src/lib/hooks/use-host-org-id.js +154 -0
  94. package/src/lib/hooks/use-host-org-id.js.map +1 -0
  95. package/src/lib/hooks/use-host-resource-api.d.ts +43 -0
  96. package/src/lib/hooks/use-host-resource-api.js +60 -0
  97. package/src/lib/hooks/use-host-resource-api.js.map +1 -0
  98. package/src/lib/hooks/use-host-template.d.ts +44 -0
  99. package/src/lib/hooks/use-host-template.js +82 -0
  100. package/src/lib/hooks/use-host-template.js.map +1 -0
  101. package/src/lib/hooks/use-host-version-api.d.ts +59 -0
  102. package/src/lib/hooks/use-host-version-api.js +69 -0
  103. package/src/lib/hooks/use-host-version-api.js.map +1 -0
  104. package/src/lib/hooks/use-host.d.ts +101 -0
  105. package/src/lib/hooks/use-host.js +45 -0
  106. package/src/lib/hooks/use-host.js.map +1 -0
  107. package/src/lib/hooks/use-layout-version.d.ts +33 -0
  108. package/src/lib/hooks/use-layout-version.js +64 -0
  109. package/src/lib/hooks/use-layout-version.js.map +1 -0
  110. package/src/lib/hooks/use-layout.d.ts +41 -0
  111. package/src/lib/hooks/use-layout.js +49 -0
  112. package/src/lib/hooks/use-layout.js.map +1 -0
  113. package/src/lib/hooks/use-live-artifact-count.d.ts +50 -0
  114. package/src/lib/hooks/use-live-artifact-count.js +102 -0
  115. package/src/lib/hooks/use-live-artifact-count.js.map +1 -0
  116. package/src/lib/hooks/use-org-member-options.d.ts +57 -0
  117. package/src/lib/hooks/use-org-member-options.js +112 -0
  118. package/src/lib/hooks/use-org-member-options.js.map +1 -0
  119. package/src/lib/hooks/use-org-plan.d.ts +46 -0
  120. package/src/lib/hooks/use-org-plan.js +86 -0
  121. package/src/lib/hooks/use-org-plan.js.map +1 -0
  122. package/src/lib/hooks/use-paged-collection.d.ts +95 -0
  123. package/src/lib/hooks/use-paged-collection.js +130 -0
  124. package/src/lib/hooks/use-paged-collection.js.map +1 -0
  125. package/src/lib/hooks/use-plugin-config.d.ts +38 -0
  126. package/src/lib/hooks/use-plugin-config.js +120 -0
  127. package/src/lib/hooks/use-plugin-config.js.map +1 -0
  128. package/src/lib/hooks/use-scope-tokens.d.ts +28 -0
  129. package/src/lib/hooks/use-scope-tokens.js +100 -0
  130. package/src/lib/hooks/use-scope-tokens.js.map +1 -0
  131. package/src/lib/hooks/use-screen-version.d.ts +33 -0
  132. package/src/lib/hooks/use-screen-version.js +65 -0
  133. package/src/lib/hooks/use-screen-version.js.map +1 -0
  134. package/src/lib/hooks/use-screen.d.ts +68 -0
  135. package/src/lib/hooks/use-screen.js +50 -0
  136. package/src/lib/hooks/use-screen.js.map +1 -0
  137. package/src/lib/hooks/use-sending-identity-api.d.ts +197 -0
  138. package/src/lib/hooks/use-sending-identity-api.js +95 -0
  139. package/src/lib/hooks/use-sending-identity-api.js.map +1 -0
  140. package/src/lib/hooks/use-sorted-paged-collection.d.ts +48 -0
  141. package/src/lib/hooks/use-sorted-paged-collection.js +213 -0
  142. package/src/lib/hooks/use-sorted-paged-collection.js.map +1 -0
  143. package/src/lib/hooks/use-switcher-collection.d.ts +105 -0
  144. package/src/lib/hooks/use-switcher-collection.js +275 -0
  145. package/src/lib/hooks/use-switcher-collection.js.map +1 -0
  146. package/src/lib/hooks/use-user-name.d.ts +51 -0
  147. package/src/lib/hooks/use-user-name.js +90 -0
  148. package/src/lib/hooks/use-user-name.js.map +1 -0
  149. package/src/lib/hooks/use-user-photo.d.ts +50 -0
  150. package/src/lib/hooks/use-user-photo.js +87 -0
  151. package/src/lib/hooks/use-user-photo.js.map +1 -0
  152. package/src/lib/tenant.d.ts +61 -0
  153. package/src/lib/tenant.js +62 -0
  154. package/src/lib/tenant.js.map +1 -0
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-sorted-paged-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport type { DocumentData, Query } from 'firebase/firestore'\nimport {\n useCallback,\n useEffect,\n useRef,\n useState,\n type DependencyList,\n} from 'react'\nimport { TABLE_PAGE_SIZE_DEFAULT } from '@aglyn/shared-ui-jsx/const/table-pagination'\nimport {\n planKeyedSegment,\n planSortedWindow,\n sortedKeyedQuery,\n sortedScanLimit,\n sortedUnkeyedQuery,\n sortFieldIsTotal,\n type CollectionSort,\n} from './sorted-collection-window'\nimport {\n useFirestoreCollection,\n type FirestoreCollectionStatus,\n type UseFirestoreCollectionOptions,\n} from './use-firestore-collection'\nimport type { UsePagedCollectionResult } from './use-paged-collection'\n\nexport interface UseSortedPagedCollectionOptions\n extends UseFirestoreCollectionOptions {\n /**\n * Required: the keyed and unkeyed segments are two listeners, and a\n * document moving between them is recognized by its id.\n */\n idField: string\n /** Rows per page. Defaults to the console-wide smallest option. */\n pageSize?: number\n /**\n * The fields `buildBase` pins by EQUALITY. A sort on one of them needs no\n * unkeyed segment — see `sortFieldIsTotal`.\n */\n equalityFields?: readonly string[]\n}\n\n/** `Object.is` over two dependency lists, the comparison React itself uses. */\nconst sameDeps = (a: DependencyList, b: DependencyList) =>\n a.length === b.length && a.every((value, index) => Object.is(value, b[index]))\n\n/**\n * The request a read's CURRENT rows were delivered for.\n *\n * `useFirestoreCollection` re-subscribes in an effect, so the render in which\n * its inputs change still returns the previous subscription's rows — rows for\n * another limit, sort or subject. Planning on them would judge a window by a\n * read that was never made for it: page two's window measured against page\n * one's eleven rows looks like a walk that ran out.\n *\n * So rows are labeled with the request that was current when they ARRIVED,\n * which is the render their array first appears in, and a render whose request\n * has since moved on treats them as unread. Held as state and adjusted during\n * render — the pattern React documents for state that follows a prop — so no\n * render ever pairs a request with rows read for another.\n */\nfunction useCurrentRows(rows: unknown, request: DependencyList): boolean {\n const [delivered, setDelivered] = useState<{\n rows: unknown\n request: DependencyList\n }>(() => ({ rows, request }))\n if (delivered.rows !== rows) {\n setDelivered({ rows, request })\n return true\n }\n return sameDeps(delivered.request, request)\n}\n\n/**\n * A paged window over a live collection SORTED BY A FIELD, which still drops\n * no document that lacks the field (AGL-2853).\n *\n * The same contract as `usePagedCollection` — the window is the query, one\n * page plus a probe row, `hasMore` a fact — over the two-segment walk in\n * `sorted-collection-window.ts`: the documents that carry the sort field in\n * its order, then the ones that do not, in name order. See that module for why\n * both segments are needed and what the second one costs.\n *\n * `buildBase` returns the collection with any EQUALITY predicates and nothing\n * else; the ordering and the limits are this hook's. `deps` identify the\n * subject as they do for `useFirestoreCollection`. A new subject, sort or\n * filter starts again from page one.\n *\n * ```ts\n * useSortedPagedCollection<Entry>(\n * () => query(\n * collection(firestore, 'hosts', hostId, 'collections', collectionId, 'entries'),\n * where('status', '==', status),\n * ),\n * { field: 'publishedAt', direction: 'desc' },\n * [firestore, hostId, collectionId, status],\n * { idField: '$id', equalityFields: ['status'] },\n * )\n * ```\n */\nexport function useSortedPagedCollection<T = DocumentData>(\n buildBase: () => Query<DocumentData> | null | undefined,\n sort: CollectionSort,\n deps: DependencyList,\n options: UseSortedPagedCollectionOptions,\n): UsePagedCollectionResult<T> {\n const {\n pageSize: initialPageSize = TABLE_PAGE_SIZE_DEFAULT,\n equalityFields = [],\n ...collectionOptions\n } = options\n const idField = options.idField\n const [pageSize, setPageSizeState] = useState(initialPageSize)\n const buildBaseRef = useRef(buildBase)\n buildBaseRef.current = buildBase\n\n const equalityKey = equalityFields.join('\\n')\n const subject: DependencyList = [\n ...deps,\n sort.field,\n sort.direction,\n equalityKey,\n ]\n\n /*\n * The reader's position belongs to ONE subject. It resets in the render the\n * subject changes in, not in an effect after it: an effect would let that\n * render subscribe the new sort at the old page's limit, and a sort change\n * on page four would bill forty-one documents to show ten.\n */\n const [cursor, setCursor] = useState(() => ({\n subject,\n page: 0,\n widenedTo: 0,\n }))\n const sameSubject = sameDeps(cursor.subject, subject)\n const position = sameSubject ? cursor : { subject, page: 0, widenedTo: 0 }\n if (!sameSubject) setCursor(position)\n const { page } = position\n\n const keyedIsTotal = sortFieldIsTotal(sort, equalityFields)\n const { keyedLimit } = planKeyedSegment({\n page,\n pageSize,\n keyed: undefined,\n keyedSettled: false,\n keyedIsTotal,\n })\n\n const keyedRequest: DependencyList = [...subject, keyedLimit]\n const keyedRead = useFirestoreCollection<T>(\n () => {\n const base = buildBaseRef.current()\n return base\n ? sortedKeyedQuery(base, sort, keyedLimit, equalityFields)\n : null\n },\n keyedRequest,\n collectionOptions,\n )\n const keyedCurrent = useCurrentRows(keyedRead.data, keyedRequest)\n const keyed =\n keyedCurrent && keyedRead.status !== 'loading' ? keyedRead.data : undefined\n const keyedSettled =\n keyedCurrent && keyedRead.status === 'success' && !keyedRead.fromCache\n\n const keyedPlan = planKeyedSegment({\n page,\n pageSize,\n keyed,\n keyedSettled,\n keyedIsTotal,\n })\n const scanLimit = sortedScanLimit(keyedPlan, position.widenedTo)\n\n const scanRequest: DependencyList = [...subject, scanLimit]\n const scanRead = useFirestoreCollection<T>(\n () => {\n if (scanLimit <= 0) return null\n const base = buildBaseRef.current()\n return base ? sortedUnkeyedQuery(base, scanLimit) : null\n },\n scanRequest,\n collectionOptions,\n )\n const scanCurrent = useCurrentRows(scanRead.data, scanRequest)\n const scanOpen = scanLimit > 0\n\n const sorted = planSortedWindow<T>({\n page,\n pageSize,\n sort,\n keyedIsTotal,\n keyed,\n keyedSettled,\n scan:\n scanOpen && scanCurrent && scanRead.status !== 'loading'\n ? scanRead.data\n : undefined,\n scanSettled:\n scanOpen &&\n scanCurrent &&\n scanRead.status === 'success' &&\n !scanRead.fromCache,\n scanLimit,\n idOf: (row) => String((row as Record<string, unknown>)[idField]),\n })\n\n /*\n * Widening is remembered for the subject, so paging back to where the scan\n * is not needed and forward again re-opens it at the width it reached\n * instead of doubling its way back up.\n */\n const widenTo = sorted.scanLimit\n useEffect(() => {\n if (widenTo <= position.widenedTo) return\n setCursor((previous) =>\n sameDeps(previous.subject, position.subject) &&\n widenTo > previous.widenedTo\n ? { ...previous, widenedTo: widenTo }\n : previous,\n )\n }, [widenTo, position.widenedTo, position.subject])\n\n const setPage = useCallback((next: number) => {\n setCursor((previous) => ({ ...previous, page: next }))\n }, [])\n const setPageSize = useCallback((next: number) => {\n setPageSizeState(next)\n // Page four of a ten-row list does not exist once the reader asks for\n // fifty at a time, and an out-of-range page renders as an empty list.\n setCursor((previous) => ({ ...previous, page: 0 }))\n }, [])\n\n /*\n * A short window is a claim that the list ENDS here, so it is `loading`\n * until the server has confirmed both segments. A window holding its probe\n * row claims only that more exists, which a cached read can say.\n */\n const errored =\n keyedRead.status === 'error' || (scanOpen && scanRead.status === 'error')\n const status: FirestoreCollectionStatus = errored\n ? 'error'\n : keyed === undefined\n ? 'loading'\n : sorted.hasMore || sorted.settled\n ? 'success'\n : 'loading'\n\n return {\n data: sorted.ordered,\n rows: sorted.rows,\n hasMore: sorted.hasMore,\n page,\n setPage,\n pageSize,\n setPageSize,\n status,\n error: keyedRead.error ?? (scanOpen ? scanRead.error : undefined),\n fromCache: keyedRead.fromCache || (scanOpen && scanRead.fromCache),\n serverDenied:\n keyedRead.serverDenied || (scanOpen && scanRead.serverDenied),\n }\n}\n\nexport default useSortedPagedCollection\n"],"names":["useCallback","useEffect","useRef","useState","TABLE_PAGE_SIZE_DEFAULT","planKeyedSegment","planSortedWindow","sortedKeyedQuery","sortedScanLimit","sortedUnkeyedQuery","sortFieldIsTotal","useFirestoreCollection","sameDeps","a","b","length","every","value","index","Object","is","useCurrentRows","rows","request","delivered","setDelivered","useSortedPagedCollection","buildBase","sort","deps","options","keyedRead","pageSize","initialPageSize","equalityFields","collectionOptions","idField","setPageSizeState","buildBaseRef","current","equalityKey","join","subject","field","direction","cursor","setCursor","page","widenedTo","sameSubject","position","keyedIsTotal","keyedLimit","keyed","undefined","keyedSettled","keyedRequest","base","keyedCurrent","data","status","fromCache","keyedPlan","scanLimit","scanRequest","scanRead","scanCurrent","scanOpen","sorted","scan","scanSettled","idOf","row","String","widenTo","previous","setPage","next","setPageSize","errored","hasMore","settled","ordered","error","serverDenied"],"mappings":"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"}
@@ -0,0 +1,105 @@
1
+ import { type DocumentData, type Firestore, type WhereFilterOp } from 'firebase/firestore';
2
+ import { type DependencyList } from 'react';
3
+ /**
4
+ * Collection KEY for the session-health verdict (AGL-2486).
5
+ *
6
+ * The even path segments only — `['users', uid, 'hostMemberships']` becomes
7
+ * `users/hostMemberships`. That is the convention `use-org-hosts` already
8
+ * uses for the same collection, so the two report the SAME key and still
9
+ * count as one collection rather than inflating each other toward the
10
+ * two-collection threshold. It also keeps document ids — a uid among them —
11
+ * out of a module-scope map.
12
+ */
13
+ export declare function switcherCollectionKey(path: string[]): string;
14
+ export interface UseSwitcherCollectionOptions<T> {
15
+ firestore: Firestore;
16
+ /** Collection path segments, e.g. `['hosts', hostId, 'screens']`. */
17
+ path: string[];
18
+ /** Optional single filter, e.g. `['orgId', '==', orgId]`. */
19
+ where?: readonly [string, WhereFilterOp, unknown];
20
+ /** Raw search text from the field; debounced and normalized internally. */
21
+ query: string;
22
+ /** Recent-first window size when the query is empty (default 10). */
23
+ idleLimit?: number;
24
+ /** Result cap for the name-prefix search (default 20). */
25
+ searchLimit?: number;
26
+ /**
27
+ * The field holding the human-readable name, matched client-side.
28
+ *
29
+ * Both switchers store it as `displayName`, which is the default; a caller
30
+ * whose rows name themselves differently passes its own.
31
+ */
32
+ nameField?: string;
33
+ /**
34
+ * How many documents the client-side pass reads (default 50).
35
+ *
36
+ * Fetched ONCE per scope and cached for the life of the mount, so typing
37
+ * costs nothing after the first character — see the search-mode note on the
38
+ * hook itself.
39
+ */
40
+ searchWindow?: number;
41
+ /** Field written with the doc id (default `$id`). */
42
+ idField?: string;
43
+ /** Client post-filter, e.g. drop soft-deleted or email screens. */
44
+ filter?: (item: T) => boolean;
45
+ /** Stable scope dependencies (firestore, ids) — like a useEffect dep array. */
46
+ deps: DependencyList;
47
+ /** Debounce for the search query in ms (default 200). */
48
+ debounceMs?: number;
49
+ /**
50
+ * Hold off entirely, because a value this listen must be SCOPED BY is not
51
+ * resolved yet (AGL-2350).
52
+ *
53
+ * The empty-path-segment hold-off below covers a scope that appears IN the
54
+ * path. A `where` filter is the other kind, and it fails the opposite way:
55
+ * an unresolved id makes `where` `undefined`, which does not error — it
56
+ * silently drops the filter and returns the UNSCOPED collection. For
57
+ * `users/{uid}/hostMemberships` that is every site the person holds in
58
+ * every org, which on an agency running one workspace per client puts two
59
+ * clients' site names in one dropdown.
60
+ *
61
+ * Defaults to `false`, so this is inert for any caller that does not scope
62
+ * by a filter.
63
+ */
64
+ skip?: boolean;
65
+ }
66
+ export interface UseSwitcherCollectionResult<T> {
67
+ items: T[];
68
+ /** A fetch is in flight and there are no prior rows to show yet. */
69
+ loading: boolean;
70
+ /** The (debounced) query is non-empty — results are name-search matches. */
71
+ hasQuery: boolean;
72
+ /**
73
+ * The most recent fetch FAILED (AGL-1066).
74
+ *
75
+ * Keeping the prior rows on a failure is right and stays (see the catch
76
+ * below), but the failure was previously erased along with `loading` — so
77
+ * the first fetch of a scope, which has no prior rows, settled on
78
+ * `{ items: [], loading: false }` and every switcher read that as "this
79
+ * person holds no sites". Consumers must render "couldn't load", never a
80
+ * zero-state, when this is true.
81
+ */
82
+ error: boolean;
83
+ }
84
+ /**
85
+ * Data source for the org/site/screen switchers that scales past a switcher's
86
+ * naive "load the whole collection and filter in memory" (AGL-838). Two modes:
87
+ *
88
+ * - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`
89
+ * capped at `idleLimit` — the handful of docs you actually cycle between, not
90
+ * a full read.
91
+ * - **search** (non-empty query): a true Firestore prefix range over the
92
+ * normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\uf8ff')`),
93
+ * capped at `searchLimit` — finds a match anywhere in the collection without
94
+ * loading it, where the old client-side filter could only match already-
95
+ * loaded rows.
96
+ *
97
+ * Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so
98
+ * there is no per-keystroke listener churn and no flash: prior rows stay
99
+ * visible while the next fetch is in flight, and only a scope change (a
100
+ * dependency in `deps`) clears them — the same hold-the-right-scope rule as
101
+ * `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.
102
+ * Out-of-order responses are dropped by request id.
103
+ */
104
+ export declare function useSwitcherCollection<T = DocumentData>(options: UseSwitcherCollectionOptions<T>): UseSwitcherCollectionResult<T>;
105
+ export default useSwitcherCollection;
@@ -0,0 +1,275 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { compareScored, nameSearchKey, scoreMatch } from "@aglyn/aglyn";
19
+ import { collection, documentId, endAt, getDocs, limit, orderBy, query, startAt, where } from "firebase/firestore";
20
+ import { useEffect, useRef, useState } from "react";
21
+ import { reportFirestoreDenial, reportFirestoreServerRead } from "./firestore-denial-reporter.js";
22
+ /**
23
+ * Collection KEY for the session-health verdict (AGL-2486).
24
+ *
25
+ * The even path segments only — `['users', uid, 'hostMemberships']` becomes
26
+ * `users/hostMemberships`. That is the convention `use-org-hosts` already
27
+ * uses for the same collection, so the two report the SAME key and still
28
+ * count as one collection rather than inflating each other toward the
29
+ * two-collection threshold. It also keeps document ids — a uid among them —
30
+ * out of a module-scope map.
31
+ */ export function switcherCollectionKey(path) {
32
+ return path.filter((_, index)=>index % 2 === 0).join('/');
33
+ }
34
+ function useDebouncedValue(value, ms) {
35
+ const [debounced, setDebounced] = useState(value);
36
+ useEffect(()=>{
37
+ const timer = setTimeout(()=>setDebounced(value), ms);
38
+ return ()=>clearTimeout(timer);
39
+ }, [
40
+ value,
41
+ ms
42
+ ]);
43
+ return debounced;
44
+ }
45
+ /**
46
+ * Data source for the org/site/screen switchers that scales past a switcher's
47
+ * naive "load the whole collection and filter in memory" (AGL-838). Two modes:
48
+ *
49
+ * - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`
50
+ * capped at `idleLimit` — the handful of docs you actually cycle between, not
51
+ * a full read.
52
+ * - **search** (non-empty query): a true Firestore prefix range over the
53
+ * normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\uf8ff')`),
54
+ * capped at `searchLimit` — finds a match anywhere in the collection without
55
+ * loading it, where the old client-side filter could only match already-
56
+ * loaded rows.
57
+ *
58
+ * Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so
59
+ * there is no per-keystroke listener churn and no flash: prior rows stay
60
+ * visible while the next fetch is in flight, and only a scope change (a
61
+ * dependency in `deps`) clears them — the same hold-the-right-scope rule as
62
+ * `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.
63
+ * Out-of-order responses are dropped by request id.
64
+ */ export function useSwitcherCollection(options) {
65
+ const { firestore, path, where: whereClause, query: rawQuery, idleLimit = 10, searchLimit = 20, nameField = 'displayName', searchWindow = 50, idField = '$id', filter, deps, debounceMs = 200, skip = false } = options;
66
+ const debounced = useDebouncedValue(rawQuery.trim(), debounceMs);
67
+ const key = nameSearchKey(debounced);
68
+ const hasQuery = key.length > 0;
69
+ const [items, setItems] = useState([]);
70
+ const [loading, setLoading] = useState(true);
71
+ const [error, setError] = useState(false);
72
+ const requestRef = useRef(0);
73
+ /**
74
+ * The client-side search window, cached for the life of this SCOPE.
75
+ *
76
+ * A ref rather than state: filling it must not itself cause a render, and
77
+ * the render that matters is the one `setItems` already causes. Cleared by
78
+ * the scope effect below, which is the same place the rows are cleared —
79
+ * keeping the two together is what stops site A's window being matched
80
+ * against while standing on site B.
81
+ */ const windowRef = useRef(null);
82
+ // Clear on a genuine scope change (host A → host B) so the previous scope's
83
+ // rows never bleed into the new one; a query change keeps the prior rows so
84
+ // the list doesn't flash (AGL-591 rule, applied per-scope not per-fetch).
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ useEffect(()=>{
87
+ setItems([]);
88
+ setLoading(true);
89
+ setError(false);
90
+ windowRef.current = null;
91
+ // eslint-disable-next-line react-hooks/exhaustive-deps
92
+ }, deps);
93
+ useEffect(()=>{
94
+ const requestId = ++requestRef.current;
95
+ // A path segment can be momentarily empty (e.g. the uid before auth
96
+ // resolves); hold rather than build an invalid collection ref. `skip` is
97
+ // the same hold for a scope that lives in `where` rather than the path,
98
+ // where an unresolved id would widen the query instead of breaking it.
99
+ if (skip || path.some((segment)=>!segment)) {
100
+ setItems([]);
101
+ setLoading(true);
102
+ setError(false);
103
+ // A hold means the scope is not trustworthy yet, so anything already
104
+ // read under it must not survive into the scope that unblocks.
105
+ windowRef.current = null;
106
+ return;
107
+ }
108
+ setLoading(true);
109
+ const ref = collection(firestore, path[0], ...path.slice(1));
110
+ const scopeFilter = whereClause ? [
111
+ where(whereClause[0], whereClause[1], whereClause[2])
112
+ ] : [];
113
+ const toRows = (snapshot)=>snapshot.docs.map((docSnap)=>{
114
+ const value = _extends({}, docSnap.data());
115
+ if (idField) value[idField] = docSnap.id;
116
+ return value;
117
+ });
118
+ /**
119
+ * SEARCH MODE (AGL-2486) — a client-side window first, the prefix range
120
+ * only when the window cannot have held everything.
121
+ *
122
+ * This used to be the prefix range alone, and that had two defects which
123
+ * look like one from the outside.
124
+ *
125
+ * **It hid documents.** `orderBy('nameLower')` makes Firestore OMIT every
126
+ * document that does not carry the field — and `nameLower` is optional,
127
+ * stamped by three write paths for one resource kind. A document written
128
+ * any other way stayed visible in the idle list and vanished the instant
129
+ * you typed. Measured against the seeded emulator: a screen named "Home"
130
+ * listed, and `home` returned nothing. Production happens to be clean
131
+ * today (68/68 live screens and 10/10 membership rows carry it, checked
132
+ * with the Admin SDK), so this is a LATENT defect rather than a firing
133
+ * one — but it is armed for the next write path that forgets, and nothing
134
+ * would fail when it does. Ordering the window by `documentId()`, which no
135
+ * document can be missing, disarms it permanently: correctness stops
136
+ * depending on a field staying in step across write paths.
137
+ *
138
+ * **And it could not match the way people type.** A prefix over the whole
139
+ * stored name means somebody looking for "Main Layout" who types `layout`
140
+ * gets nothing. That one IS firing, on every surface, today.
141
+ *
142
+ * The window is read ONCE per scope and cached for the life of the mount,
143
+ * so the common case — a site whose collection fits inside it — costs one
144
+ * read burst and then nothing at all per keystroke, which is CHEAPER than
145
+ * the query it replaces. The prefix range is kept and issued only when the
146
+ * window came back full, because that is the case AGL-838 built it for: a
147
+ * host with hundreds of screens must still find one that is not in the
148
+ * window. Dropping it to simplify would have regressed exactly the
149
+ * property the switcher exists to provide.
150
+ */ const searchRead = async ()=>{
151
+ let windowRows = windowRef.current;
152
+ if (!windowRows) {
153
+ var _windowSnapshot_metadata;
154
+ const windowSnapshot = await getDocs(query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)));
155
+ windowRows = {
156
+ rows: toRows(windowSnapshot),
157
+ fromCache: (_windowSnapshot_metadata = windowSnapshot.metadata) == null ? void 0 : _windowSnapshot_metadata.fromCache
158
+ };
159
+ windowRef.current = windowRows;
160
+ }
161
+ const scored = windowRows.rows.map((row)=>{
162
+ var _ref;
163
+ const label = String((_ref = row == null ? void 0 : row[nameField]) != null ? _ref : '');
164
+ const score = scoreMatch({
165
+ name: label
166
+ }, debounced);
167
+ return score === null ? null : {
168
+ row,
169
+ score,
170
+ label
171
+ };
172
+ }).filter(Boolean);
173
+ // Only reach past the window when the window could not have held the
174
+ // whole collection. A partial window is proof there is nothing beyond it.
175
+ let beyond = [];
176
+ let beyondFromCache;
177
+ if (windowRows.rows.length >= searchWindow) {
178
+ var _prefixSnapshot_metadata;
179
+ const prefixSnapshot = await getDocs(query(ref, ...scopeFilter, orderBy('nameLower'), startAt(key), endAt(key + '\uf8ff'), limit(searchLimit)));
180
+ beyondFromCache = (_prefixSnapshot_metadata = prefixSnapshot.metadata) == null ? void 0 : _prefixSnapshot_metadata.fromCache;
181
+ beyond = toRows(prefixSnapshot);
182
+ }
183
+ const seen = new Set(scored.map((hit)=>{
184
+ var _hit_row;
185
+ return (_hit_row = hit.row) == null ? void 0 : _hit_row[idField];
186
+ }));
187
+ const merged = [
188
+ ...scored.sort((a, b)=>compareScored(a, b)).map((hit)=>hit.row),
189
+ ...beyond.filter((row)=>!seen.has(row == null ? void 0 : row[idField]))
190
+ ];
191
+ return {
192
+ rows: merged.slice(0, searchLimit),
193
+ fromCache: windowRows.fromCache === false ? false : beyondFromCache
194
+ };
195
+ };
196
+ const read = hasQuery ? searchRead() : getDocs(query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit))).then((snapshot)=>{
197
+ var _snapshot_metadata;
198
+ return {
199
+ rows: toRows(snapshot),
200
+ fromCache: (_snapshot_metadata = snapshot.metadata) == null ? void 0 : _snapshot_metadata.fromCache
201
+ };
202
+ });
203
+ read.then((result)=>{
204
+ var _snapshot_metadata;
205
+ if (requestRef.current !== requestId) return; // superseded
206
+ const snapshot = {
207
+ metadata: {
208
+ fromCache: result.fromCache
209
+ }
210
+ };
211
+ const rows = result.rows;
212
+ setItems(filter ? rows.filter(filter) : rows);
213
+ setError(false);
214
+ setLoading(false);
215
+ // A SERVER answer is proof the session can read, and it clears the
216
+ // denial evidence outright (AGL-2486). Guarded on `fromCache`
217
+ // because `getDocs` falls back to the cache while offline, and the
218
+ // reporter contract is explicit that a cached snapshot proves
219
+ // nothing.
220
+ //
221
+ // Written as `=== false` rather than `!fromCache` on purpose: the
222
+ // claim being made is "the server answered", so absent metadata
223
+ // must report NOTHING rather than be read as a server read. It also
224
+ // keeps this branch from throwing on a snapshot shape it did not
225
+ // expect — a throw here lands in the `catch` below and would show
226
+ // the user a refusal for a fetch that actually succeeded.
227
+ if (((_snapshot_metadata = snapshot.metadata) == null ? void 0 : _snapshot_metadata.fromCache) === false) reportFirestoreServerRead();
228
+ }).catch((error)=>{
229
+ // A missing composite index or transient error leaves the prior rows
230
+ // in place rather than blanking the menu; the caller's "view all"
231
+ // escape hatch still reaches everything.
232
+ //
233
+ // But SAY SO (AGL-1066). Swallowing the failure into `loading: false`
234
+ // meant the no-prior-rows case — a cold load, or the first fetch
235
+ // after a scope change — was indistinguishable from a genuinely
236
+ // empty collection, and the site switcher printed "No sites yet."
237
+ // at people who hold sites.
238
+ if (requestRef.current !== requestId) return;
239
+ setError(true);
240
+ setLoading(false);
241
+ // …and tell the session detector, which this read used to keep to
242
+ // itself (AGL-2486). the production report was a Sites list that
243
+ // "could not be loaded" while the console had no idea the session
244
+ // was the reason: the switcher is one of the first reads on the
245
+ // page and it contributed ZERO evidence toward the stale verdict,
246
+ // so the very list that failed could never be what raised the
247
+ // prompt. Only `permission-denied` — a missing index
248
+ // (`failed-precondition`) or a dropped network (`unavailable`) is
249
+ // not a session problem and must never be counted as one.
250
+ if ((error == null ? void 0 : error.code) === 'permission-denied') {
251
+ reportFirestoreDenial(switcherCollectionKey(path));
252
+ }
253
+ });
254
+ // eslint-disable-next-line react-hooks/exhaustive-deps
255
+ // `skip` is listed even though it is derived from a value already in
256
+ // `deps` for every caller today — a future caller whose skip condition is
257
+ // independent of its scope would otherwise stay held after unblocking.
258
+ }, [
259
+ ...deps,
260
+ key,
261
+ hasQuery,
262
+ idleLimit,
263
+ searchLimit,
264
+ skip
265
+ ]);
266
+ return {
267
+ items,
268
+ loading,
269
+ hasQuery,
270
+ error
271
+ };
272
+ }
273
+ export default useSwitcherCollection;
274
+
275
+ //# sourceMappingURL=use-switcher-collection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-switcher-collection.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { compareScored, nameSearchKey, scoreMatch } from '@aglyn/aglyn'\nimport {\n collection,\n documentId,\n endAt,\n type DocumentData,\n type Firestore,\n getDocs,\n limit,\n orderBy,\n query,\n startAt,\n where,\n type WhereFilterOp,\n} from 'firebase/firestore'\nimport {\n type DependencyList,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport {\n reportFirestoreDenial,\n reportFirestoreServerRead,\n} from './firestore-denial-reporter'\n\n/**\n * Collection KEY for the session-health verdict (AGL-2486).\n *\n * The even path segments only — `['users', uid, 'hostMemberships']` becomes\n * `users/hostMemberships`. That is the convention `use-org-hosts` already\n * uses for the same collection, so the two report the SAME key and still\n * count as one collection rather than inflating each other toward the\n * two-collection threshold. It also keeps document ids — a uid among them —\n * out of a module-scope map.\n */\nexport function switcherCollectionKey(path: string[]): string {\n return path.filter((_, index) => index % 2 === 0).join('/')\n}\n\nexport interface UseSwitcherCollectionOptions<T> {\n firestore: Firestore\n /** Collection path segments, e.g. `['hosts', hostId, 'screens']`. */\n path: string[]\n /** Optional single filter, e.g. `['orgId', '==', orgId]`. */\n where?: readonly [string, WhereFilterOp, unknown]\n /** Raw search text from the field; debounced and normalized internally. */\n query: string\n /** Recent-first window size when the query is empty (default 10). */\n idleLimit?: number\n /** Result cap for the name-prefix search (default 20). */\n searchLimit?: number\n /**\n * The field holding the human-readable name, matched client-side.\n *\n * Both switchers store it as `displayName`, which is the default; a caller\n * whose rows name themselves differently passes its own.\n */\n nameField?: string\n /**\n * How many documents the client-side pass reads (default 50).\n *\n * Fetched ONCE per scope and cached for the life of the mount, so typing\n * costs nothing after the first character — see the search-mode note on the\n * hook itself.\n */\n searchWindow?: number\n /** Field written with the doc id (default `$id`). */\n idField?: string\n /** Client post-filter, e.g. drop soft-deleted or email screens. */\n filter?: (item: T) => boolean\n /** Stable scope dependencies (firestore, ids) — like a useEffect dep array. */\n deps: DependencyList\n /** Debounce for the search query in ms (default 200). */\n debounceMs?: number\n /**\n * Hold off entirely, because a value this listen must be SCOPED BY is not\n * resolved yet (AGL-2350).\n *\n * The empty-path-segment hold-off below covers a scope that appears IN the\n * path. A `where` filter is the other kind, and it fails the opposite way:\n * an unresolved id makes `where` `undefined`, which does not error — it\n * silently drops the filter and returns the UNSCOPED collection. For\n * `users/{uid}/hostMemberships` that is every site the person holds in\n * every org, which on an agency running one workspace per client puts two\n * clients' site names in one dropdown.\n *\n * Defaults to `false`, so this is inert for any caller that does not scope\n * by a filter.\n */\n skip?: boolean\n}\n\nexport interface UseSwitcherCollectionResult<T> {\n items: T[]\n /** A fetch is in flight and there are no prior rows to show yet. */\n loading: boolean\n /** The (debounced) query is non-empty — results are name-search matches. */\n hasQuery: boolean\n /**\n * The most recent fetch FAILED (AGL-1066).\n *\n * Keeping the prior rows on a failure is right and stays (see the catch\n * below), but the failure was previously erased along with `loading` — so\n * the first fetch of a scope, which has no prior rows, settled on\n * `{ items: [], loading: false }` and every switcher read that as \"this\n * person holds no sites\". Consumers must render \"couldn't load\", never a\n * zero-state, when this is true.\n */\n error: boolean\n}\n\nfunction useDebouncedValue<V>(value: V, ms: number): V {\n const [debounced, setDebounced] = useState(value)\n useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), ms)\n return () => clearTimeout(timer)\n }, [value, ms])\n return debounced\n}\n\n/**\n * Data source for the org/site/screen switchers that scales past a switcher's\n * naive \"load the whole collection and filter in memory\" (AGL-838). Two modes:\n *\n * - **idle** (empty query): a recent-first window, `orderBy(updatedAt desc)`\n * capped at `idleLimit` — the handful of docs you actually cycle between, not\n * a full read.\n * - **search** (non-empty query): a true Firestore prefix range over the\n * normalized `nameLower` (`orderBy(nameLower) startAt(key)..endAt(key+'\\uf8ff')`),\n * capped at `searchLimit` — finds a match anywhere in the collection without\n * loading it, where the old client-side filter could only match already-\n * loaded rows.\n *\n * Reads are one-shot `getDocs` (a switcher list doesn't need live updates), so\n * there is no per-keystroke listener churn and no flash: prior rows stay\n * visible while the next fetch is in flight, and only a scope change (a\n * dependency in `deps`) clears them — the same hold-the-right-scope rule as\n * `useFirestoreCollection` (AGL-591), without its clear-to-empty-then-refill.\n * Out-of-order responses are dropped by request id.\n */\nexport function useSwitcherCollection<T = DocumentData>(\n options: UseSwitcherCollectionOptions<T>,\n): UseSwitcherCollectionResult<T> {\n const {\n firestore,\n path,\n where: whereClause,\n query: rawQuery,\n idleLimit = 10,\n searchLimit = 20,\n nameField = 'displayName',\n searchWindow = 50,\n idField = '$id',\n filter,\n deps,\n debounceMs = 200,\n skip = false,\n } = options\n\n const debounced = useDebouncedValue(rawQuery.trim(), debounceMs)\n const key = nameSearchKey(debounced)\n const hasQuery = key.length > 0\n\n const [items, setItems] = useState<T[]>([])\n const [loading, setLoading] = useState(true)\n const [error, setError] = useState(false)\n const requestRef = useRef(0)\n /**\n * The client-side search window, cached for the life of this SCOPE.\n *\n * A ref rather than state: filling it must not itself cause a render, and\n * the render that matters is the one `setItems` already causes. Cleared by\n * the scope effect below, which is the same place the rows are cleared —\n * keeping the two together is what stops site A's window being matched\n * against while standing on site B.\n */\n const windowRef = useRef<{ rows: any[]; fromCache?: boolean } | null>(null)\n\n // Clear on a genuine scope change (host A → host B) so the previous scope's\n // rows never bleed into the new one; a query change keeps the prior rows so\n // the list doesn't flash (AGL-591 rule, applied per-scope not per-fetch).\n // eslint-disable-next-line react-hooks/exhaustive-deps\n useEffect(() => {\n setItems([])\n setLoading(true)\n setError(false)\n windowRef.current = null\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps)\n\n useEffect(() => {\n const requestId = ++requestRef.current\n // A path segment can be momentarily empty (e.g. the uid before auth\n // resolves); hold rather than build an invalid collection ref. `skip` is\n // the same hold for a scope that lives in `where` rather than the path,\n // where an unresolved id would widen the query instead of breaking it.\n if (skip || path.some((segment) => !segment)) {\n setItems([])\n setLoading(true)\n setError(false)\n // A hold means the scope is not trustworthy yet, so anything already\n // read under it must not survive into the scope that unblocks.\n windowRef.current = null\n return\n }\n setLoading(true)\n const ref = collection(firestore, path[0], ...path.slice(1))\n const scopeFilter = whereClause\n ? [where(whereClause[0], whereClause[1], whereClause[2])]\n : []\n const toRows = (snapshot: { docs: any[] }) =>\n snapshot.docs.map((docSnap) => {\n const value = { ...docSnap.data() } as Record<string, unknown>\n if (idField) value[idField] = docSnap.id\n return value as T\n })\n\n /**\n * SEARCH MODE (AGL-2486) — a client-side window first, the prefix range\n * only when the window cannot have held everything.\n *\n * This used to be the prefix range alone, and that had two defects which\n * look like one from the outside.\n *\n * **It hid documents.** `orderBy('nameLower')` makes Firestore OMIT every\n * document that does not carry the field — and `nameLower` is optional,\n * stamped by three write paths for one resource kind. A document written\n * any other way stayed visible in the idle list and vanished the instant\n * you typed. Measured against the seeded emulator: a screen named \"Home\"\n * listed, and `home` returned nothing. Production happens to be clean\n * today (68/68 live screens and 10/10 membership rows carry it, checked\n * with the Admin SDK), so this is a LATENT defect rather than a firing\n * one — but it is armed for the next write path that forgets, and nothing\n * would fail when it does. Ordering the window by `documentId()`, which no\n * document can be missing, disarms it permanently: correctness stops\n * depending on a field staying in step across write paths.\n *\n * **And it could not match the way people type.** A prefix over the whole\n * stored name means somebody looking for \"Main Layout\" who types `layout`\n * gets nothing. That one IS firing, on every surface, today.\n *\n * The window is read ONCE per scope and cached for the life of the mount,\n * so the common case — a site whose collection fits inside it — costs one\n * read burst and then nothing at all per keystroke, which is CHEAPER than\n * the query it replaces. The prefix range is kept and issued only when the\n * window came back full, because that is the case AGL-838 built it for: a\n * host with hundreds of screens must still find one that is not in the\n * window. Dropping it to simplify would have regressed exactly the\n * property the switcher exists to provide.\n */\n const searchRead = async () => {\n let windowRows = windowRef.current\n if (!windowRows) {\n const windowSnapshot = await getDocs(\n query(ref, ...scopeFilter, orderBy(documentId()), limit(searchWindow)),\n )\n windowRows = {\n rows: toRows(windowSnapshot),\n fromCache: windowSnapshot.metadata?.fromCache,\n }\n windowRef.current = windowRows\n }\n const scored = windowRows.rows\n .map((row: any) => {\n const label = String(row?.[nameField] ?? '')\n const score = scoreMatch({ name: label }, debounced)\n return score === null ? null : { row, score, label }\n })\n .filter(Boolean) as Array<{ row: T; score: number; label: string }>\n\n // Only reach past the window when the window could not have held the\n // whole collection. A partial window is proof there is nothing beyond it.\n let beyond: T[] = []\n let beyondFromCache: boolean | undefined\n if (windowRows.rows.length >= searchWindow) {\n const prefixSnapshot = await getDocs(\n query(\n ref,\n ...scopeFilter,\n orderBy('nameLower'),\n startAt(key),\n endAt(key + '\\uf8ff'),\n limit(searchLimit),\n ),\n )\n beyondFromCache = prefixSnapshot.metadata?.fromCache\n beyond = toRows(prefixSnapshot)\n }\n\n const seen = new Set(scored.map((hit) => (hit.row as any)?.[idField]))\n const merged = [\n ...scored\n .sort((a, b) => compareScored(a, b))\n .map((hit) => hit.row),\n ...beyond.filter((row: any) => !seen.has(row?.[idField])),\n ]\n return {\n rows: merged.slice(0, searchLimit),\n fromCache: windowRows.fromCache === false ? false : beyondFromCache,\n }\n }\n\n const read = hasQuery\n ? searchRead()\n : getDocs(\n query(ref, ...scopeFilter, orderBy('updatedAt', 'desc'), limit(idleLimit)),\n ).then((snapshot) => ({\n rows: toRows(snapshot),\n fromCache: snapshot.metadata?.fromCache,\n }))\n\n read\n .then((result) => {\n if (requestRef.current !== requestId) return // superseded\n const snapshot = { metadata: { fromCache: result.fromCache } }\n const rows = result.rows\n setItems(filter ? rows.filter(filter) : rows)\n setError(false)\n setLoading(false)\n // A SERVER answer is proof the session can read, and it clears the\n // denial evidence outright (AGL-2486). Guarded on `fromCache`\n // because `getDocs` falls back to the cache while offline, and the\n // reporter contract is explicit that a cached snapshot proves\n // nothing.\n //\n // Written as `=== false` rather than `!fromCache` on purpose: the\n // claim being made is \"the server answered\", so absent metadata\n // must report NOTHING rather than be read as a server read. It also\n // keeps this branch from throwing on a snapshot shape it did not\n // expect — a throw here lands in the `catch` below and would show\n // the user a refusal for a fetch that actually succeeded.\n if (snapshot.metadata?.fromCache === false) reportFirestoreServerRead()\n })\n .catch((error: unknown) => {\n // A missing composite index or transient error leaves the prior rows\n // in place rather than blanking the menu; the caller's \"view all\"\n // escape hatch still reaches everything.\n //\n // But SAY SO (AGL-1066). Swallowing the failure into `loading: false`\n // meant the no-prior-rows case — a cold load, or the first fetch\n // after a scope change — was indistinguishable from a genuinely\n // empty collection, and the site switcher printed \"No sites yet.\"\n // at people who hold sites.\n if (requestRef.current !== requestId) return\n setError(true)\n setLoading(false)\n // …and tell the session detector, which this read used to keep to\n // itself (AGL-2486). the production report was a Sites list that\n // \"could not be loaded\" while the console had no idea the session\n // was the reason: the switcher is one of the first reads on the\n // page and it contributed ZERO evidence toward the stale verdict,\n // so the very list that failed could never be what raised the\n // prompt. Only `permission-denied` — a missing index\n // (`failed-precondition`) or a dropped network (`unavailable`) is\n // not a session problem and must never be counted as one.\n if ((error as { code?: string })?.code === 'permission-denied') {\n reportFirestoreDenial(switcherCollectionKey(path))\n }\n })\n // eslint-disable-next-line react-hooks/exhaustive-deps\n // `skip` is listed even though it is derived from a value already in\n // `deps` for every caller today — a future caller whose skip condition is\n // independent of its scope would otherwise stay held after unblocking.\n }, [...deps, key, hasQuery, idleLimit, searchLimit, skip])\n\n return { items, loading, hasQuery, error }\n}\n\nexport default useSwitcherCollection\n"],"names":["compareScored","nameSearchKey","scoreMatch","collection","documentId","endAt","getDocs","limit","orderBy","query","startAt","where","useEffect","useRef","useState","reportFirestoreDenial","reportFirestoreServerRead","switcherCollectionKey","path","filter","_","index","join","useDebouncedValue","value","ms","debounced","setDebounced","timer","setTimeout","clearTimeout","useSwitcherCollection","options","firestore","whereClause","rawQuery","idleLimit","searchLimit","nameField","searchWindow","idField","deps","debounceMs","skip","trim","key","hasQuery","length","items","setItems","loading","setLoading","error","setError","requestRef","windowRef","current","requestId","some","segment","ref","slice","scopeFilter","toRows","snapshot","docs","map","docSnap","data","id","searchRead","windowRows","windowSnapshot","rows","fromCache","metadata","scored","row","label","String","score","name","Boolean","beyond","beyondFromCache","prefixSnapshot","seen","Set","hit","merged","sort","a","b","has","read","then","result","catch","code"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;;AAEA,SAASA,aAAa,EAAEC,aAAa,EAAEC,UAAU,QAAQ,eAAc;AACvE,SACEC,UAAU,EACVC,UAAU,EACVC,KAAK,EAGLC,OAAO,EACPC,KAAK,EACLC,OAAO,EACPC,KAAK,EACLC,OAAO,EACPC,KAAK,QAEA,qBAAoB;AAC3B,SAEEC,SAAS,EACTC,MAAM,EACNC,QAAQ,QACH,QAAO;AACd,SACEC,qBAAqB,EACrBC,yBAAyB,QACpB,iCAA6B;AAEpC;;;;;;;;;CASC,GACD,OAAO,SAASC,sBAAsBC,IAAc;IAClD,OAAOA,KAAKC,MAAM,CAAC,CAACC,GAAGC,QAAUA,QAAQ,MAAM,GAAGC,IAAI,CAAC;AACzD;AA0EA,SAASC,kBAAqBC,KAAQ,EAAEC,EAAU;IAChD,MAAM,CAACC,WAAWC,aAAa,GAAGb,SAASU;IAC3CZ,UAAU;QACR,MAAMgB,QAAQC,WAAW,IAAMF,aAAaH,QAAQC;QACpD,OAAO,IAAMK,aAAaF;IAC5B,GAAG;QAACJ;QAAOC;KAAG;IACd,OAAOC;AACT;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASK,sBACdC,OAAwC;IAExC,MAAM,EACJC,SAAS,EACTf,IAAI,EACJP,OAAOuB,WAAW,EAClBzB,OAAO0B,QAAQ,EACfC,YAAY,EAAE,EACdC,cAAc,EAAE,EAChBC,YAAY,aAAa,EACzBC,eAAe,EAAE,EACjBC,UAAU,KAAK,EACfrB,MAAM,EACNsB,IAAI,EACJC,aAAa,GAAG,EAChBC,OAAO,KAAK,EACb,GAAGX;IAEJ,MAAMN,YAAYH,kBAAkBY,SAASS,IAAI,IAAIF;IACrD,MAAMG,MAAM5C,cAAcyB;IAC1B,MAAMoB,WAAWD,IAAIE,MAAM,GAAG;IAE9B,MAAM,CAACC,OAAOC,SAAS,GAAGnC,SAAc,EAAE;IAC1C,MAAM,CAACoC,SAASC,WAAW,GAAGrC,SAAS;IACvC,MAAM,CAACsC,OAAOC,SAAS,GAAGvC,SAAS;IACnC,MAAMwC,aAAazC,OAAO;IAC1B;;;;;;;;GAQC,GACD,MAAM0C,YAAY1C,OAAoD;IAEtE,4EAA4E;IAC5E,4EAA4E;IAC5E,0EAA0E;IAC1E,uDAAuD;IACvDD,UAAU;QACRqC,SAAS,EAAE;QACXE,WAAW;QACXE,SAAS;QACTE,UAAUC,OAAO,GAAG;IACpB,uDAAuD;IACzD,GAAGf;IAEH7B,UAAU;QACR,MAAM6C,YAAY,EAAEH,WAAWE,OAAO;QACtC,oEAAoE;QACpE,yEAAyE;QACzE,wEAAwE;QACxE,uEAAuE;QACvE,IAAIb,QAAQzB,KAAKwC,IAAI,CAAC,CAACC,UAAY,CAACA,UAAU;YAC5CV,SAAS,EAAE;YACXE,WAAW;YACXE,SAAS;YACT,qEAAqE;YACrE,+DAA+D;YAC/DE,UAAUC,OAAO,GAAG;YACpB;QACF;QACAL,WAAW;QACX,MAAMS,MAAMzD,WAAW8B,WAAWf,IAAI,CAAC,EAAE,KAAKA,KAAK2C,KAAK,CAAC;QACzD,MAAMC,cAAc5B,cAChB;YAACvB,MAAMuB,WAAW,CAAC,EAAE,EAAEA,WAAW,CAAC,EAAE,EAAEA,WAAW,CAAC,EAAE;SAAE,GACvD,EAAE;QACN,MAAM6B,SAAS,CAACC,WACdA,SAASC,IAAI,CAACC,GAAG,CAAC,CAACC;gBACjB,MAAM3C,QAAQ,aAAK2C,QAAQC,IAAI;gBAC/B,IAAI5B,SAAShB,KAAK,CAACgB,QAAQ,GAAG2B,QAAQE,EAAE;gBACxC,OAAO7C;YACT;QAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KAgCC,GACD,MAAM8C,aAAa;YACjB,IAAIC,aAAahB,UAAUC,OAAO;YAClC,IAAI,CAACe,YAAY;oBAMFC;gBALb,MAAMA,iBAAiB,MAAMlE,QAC3BG,MAAMmD,QAAQE,aAAatD,QAAQJ,eAAeG,MAAMgC;gBAE1DgC,aAAa;oBACXE,MAAMV,OAAOS;oBACbE,SAAS,GAAEF,2BAAAA,eAAeG,QAAQ,qBAAvBH,yBAAyBE,SAAS;gBAC/C;gBACAnB,UAAUC,OAAO,GAAGe;YACtB;YACA,MAAMK,SAASL,WAAWE,IAAI,CAC3BP,GAAG,CAAC,CAACW;;gBACJ,MAAMC,QAAQC,eAAOF,uBAAAA,GAAK,CAACvC,UAAU,mBAAI;gBACzC,MAAM0C,QAAQ9E,WAAW;oBAAE+E,MAAMH;gBAAM,GAAGpD;gBAC1C,OAAOsD,UAAU,OAAO,OAAO;oBAAEH;oBAAKG;oBAAOF;gBAAM;YACrD,GACC3D,MAAM,CAAC+D;YAEV,qEAAqE;YACrE,0EAA0E;YAC1E,IAAIC,SAAc,EAAE;YACpB,IAAIC;YACJ,IAAIb,WAAWE,IAAI,CAAC1B,MAAM,IAAIR,cAAc;oBAWxB8C;gBAVlB,MAAMA,iBAAiB,MAAM/E,QAC3BG,MACEmD,QACGE,aACHtD,QAAQ,cACRE,QAAQmC,MACRxC,MAAMwC,MAAM,WACZtC,MAAM8B;gBAGV+C,mBAAkBC,2BAAAA,eAAeV,QAAQ,qBAAvBU,yBAAyBX,SAAS;gBACpDS,SAASpB,OAAOsB;YAClB;YAEA,MAAMC,OAAO,IAAIC,IAAIX,OAAOV,GAAG,CAAC,CAACsB;oBAASA;wBAAAA,WAAAA,IAAIX,GAAG,qBAAR,AAACW,QAAiB,CAAChD,QAAQ;;YACpE,MAAMiD,SAAS;mBACVb,OACAc,IAAI,CAAC,CAACC,GAAGC,IAAM5F,cAAc2F,GAAGC,IAChC1B,GAAG,CAAC,CAACsB,MAAQA,IAAIX,GAAG;mBACpBM,OAAOhE,MAAM,CAAC,CAAC0D,MAAa,CAACS,KAAKO,GAAG,CAAChB,uBAAAA,GAAK,CAACrC,QAAQ;aACxD;YACD,OAAO;gBACLiC,MAAMgB,OAAO5B,KAAK,CAAC,GAAGxB;gBACtBqC,WAAWH,WAAWG,SAAS,KAAK,QAAQ,QAAQU;YACtD;QACF;QAEA,MAAMU,OAAOhD,WACTwB,eACAhE,QACEG,MAAMmD,QAAQE,aAAatD,QAAQ,aAAa,SAASD,MAAM6B,aAC/D2D,IAAI,CAAC,CAAC/B;gBAEKA;mBAFS;gBACpBS,MAAMV,OAAOC;gBACbU,SAAS,GAAEV,qBAAAA,SAASW,QAAQ,qBAAjBX,mBAAmBU,SAAS;YACzC;;QAEJoB,KACGC,IAAI,CAAC,CAACC;gBAmBDhC;YAlBJ,IAAIV,WAAWE,OAAO,KAAKC,WAAW,QAAO,aAAa;YAC1D,MAAMO,WAAW;gBAAEW,UAAU;oBAAED,WAAWsB,OAAOtB,SAAS;gBAAC;YAAE;YAC7D,MAAMD,OAAOuB,OAAOvB,IAAI;YACxBxB,SAAS9B,SAASsD,KAAKtD,MAAM,CAACA,UAAUsD;YACxCpB,SAAS;YACTF,WAAW;YACX,mEAAmE;YACnE,8DAA8D;YAC9D,mEAAmE;YACnE,8DAA8D;YAC9D,WAAW;YACX,EAAE;YACF,kEAAkE;YAClE,gEAAgE;YAChE,oEAAoE;YACpE,iEAAiE;YACjE,kEAAkE;YAClE,0DAA0D;YAC1D,IAAIa,EAAAA,qBAAAA,SAASW,QAAQ,qBAAjBX,mBAAmBU,SAAS,MAAK,OAAO1D;QAC9C,GACCiF,KAAK,CAAC,CAAC7C;YACN,qEAAqE;YACrE,kEAAkE;YAClE,yCAAyC;YACzC,EAAE;YACF,sEAAsE;YACtE,iEAAiE;YACjE,gEAAgE;YAChE,kEAAkE;YAClE,4BAA4B;YAC5B,IAAIE,WAAWE,OAAO,KAAKC,WAAW;YACtCJ,SAAS;YACTF,WAAW;YACX,kEAAkE;YAClE,iEAAiE;YACjE,kEAAkE;YAClE,gEAAgE;YAChE,kEAAkE;YAClE,8DAA8D;YAC9D,qDAAqD;YACrD,kEAAkE;YAClE,0DAA0D;YAC1D,IAAI,CAACC,yBAAD,AAACA,MAA6B8C,IAAI,MAAK,qBAAqB;gBAC9DnF,sBAAsBE,sBAAsBC;YAC9C;QACF;IACF,uDAAuD;IACvD,qEAAqE;IACrE,0EAA0E;IAC1E,uEAAuE;IACzE,GAAG;WAAIuB;QAAMI;QAAKC;QAAUV;QAAWC;QAAaM;KAAK;IAEzD,OAAO;QAAEK;QAAOE;QAASJ;QAAUM;IAAM;AAC3C;AAEA,eAAerB,sBAAqB"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The signed-in user's NAME, resolved the way their photo already is
3
+ * (AGL-2486).
4
+ *
5
+ * ## The defect this closes
6
+ *
7
+ * `user.displayName` is empty for every SSO account: a SAML identity arrives
8
+ * with `displayName: undefined`, `photoURL: undefined` and a single provider
9
+ * entry for the SAML provider, because GCIP leaves the assertion's mapped
10
+ * attributes under `firebase.sign_in_attributes`. A surface that reads that
11
+ * field alone falls through to the email, so it renders the address as the
12
+ * person's NAME and again underneath as their address — and `memberInitials`,
13
+ * given an address with no space in it, yields a single letter where the
14
+ * presence stack beside it shows two.
15
+ *
16
+ * The name is not actually missing. `users/{uid}` holds `firstName` and
17
+ * `lastName`, the org member doc holds a `displayName`, and presence reads the
18
+ * ID token's IdP claims. Three surfaces, three answers to "what is this person
19
+ * called", and the auth record is the only one that is blank for the
20
+ * enterprise tier.
21
+ *
22
+ * ## Why here, and why this order
23
+ *
24
+ * `users/{uid}` is the profile the user themselves edits, so it outranks the
25
+ * provider's copy — exactly the precedence `useUserProfilePhotoUrl` already
26
+ * establishes for the picture, on the same document, through the same
27
+ * listener. This is that hook's sibling and is deliberately shaped like it;
28
+ * two hooks answering "who is signed in" should not disagree about where to
29
+ * look.
30
+ *
31
+ * The auth record is the fallback rather than the lead because it is the one
32
+ * an IdP may never populate. The email is last and is not really a name — it
33
+ * is what the surface shows when nothing else exists, and callers that want
34
+ * to show an address separately should keep doing so.
35
+ */
36
+ /**
37
+ * The resolution itself, without React (AGL-2486).
38
+ *
39
+ * Pure and exported so the ORDER can be pinned in a spec. The order is the
40
+ * entire content of this hook and the only part that can be wrong — and the
41
+ * case that matters is one no unit test can reach through the hook, because
42
+ * it needs a real SSO session in a GCIP tenant.
43
+ */
44
+ export declare function resolveUserName(sources: {
45
+ profileFirstName?: string | null;
46
+ profileLastName?: string | null;
47
+ authDisplayName?: string | null;
48
+ email?: string | null;
49
+ }): string;
50
+ export declare function useUserName(): string;
51
+ export default useUserName;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { doc } from "firebase/firestore";
17
+ import { useFirestore, useUser } from "./firebase/firebase-services.js";
18
+ import { useFirestoreDoc } from "./use-firestore-doc.js";
19
+ /**
20
+ * The signed-in user's NAME, resolved the way their photo already is
21
+ * (AGL-2486).
22
+ *
23
+ * ## The defect this closes
24
+ *
25
+ * `user.displayName` is empty for every SSO account: a SAML identity arrives
26
+ * with `displayName: undefined`, `photoURL: undefined` and a single provider
27
+ * entry for the SAML provider, because GCIP leaves the assertion's mapped
28
+ * attributes under `firebase.sign_in_attributes`. A surface that reads that
29
+ * field alone falls through to the email, so it renders the address as the
30
+ * person's NAME and again underneath as their address — and `memberInitials`,
31
+ * given an address with no space in it, yields a single letter where the
32
+ * presence stack beside it shows two.
33
+ *
34
+ * The name is not actually missing. `users/{uid}` holds `firstName` and
35
+ * `lastName`, the org member doc holds a `displayName`, and presence reads the
36
+ * ID token's IdP claims. Three surfaces, three answers to "what is this person
37
+ * called", and the auth record is the only one that is blank for the
38
+ * enterprise tier.
39
+ *
40
+ * ## Why here, and why this order
41
+ *
42
+ * `users/{uid}` is the profile the user themselves edits, so it outranks the
43
+ * provider's copy — exactly the precedence `useUserProfilePhotoUrl` already
44
+ * establishes for the picture, on the same document, through the same
45
+ * listener. This is that hook's sibling and is deliberately shaped like it;
46
+ * two hooks answering "who is signed in" should not disagree about where to
47
+ * look.
48
+ *
49
+ * The auth record is the fallback rather than the lead because it is the one
50
+ * an IdP may never populate. The email is last and is not really a name — it
51
+ * is what the surface shows when nothing else exists, and callers that want
52
+ * to show an address separately should keep doing so.
53
+ */ /**
54
+ * The resolution itself, without React (AGL-2486).
55
+ *
56
+ * Pure and exported so the ORDER can be pinned in a spec. The order is the
57
+ * entire content of this hook and the only part that can be wrong — and the
58
+ * case that matters is one no unit test can reach through the hook, because
59
+ * it needs a real SSO session in a GCIP tenant.
60
+ */ export function resolveUserName(sources) {
61
+ var _sources_authDisplayName, _sources_email;
62
+ const fromProfile = [
63
+ sources.profileFirstName,
64
+ sources.profileLastName
65
+ ].map((part)=>String(part != null ? part : '').trim()).filter(Boolean).join(' ');
66
+ if (fromProfile) return fromProfile;
67
+ const fromAuth = String((_sources_authDisplayName = sources.authDisplayName) != null ? _sources_authDisplayName : '').trim();
68
+ if (fromAuth) return fromAuth;
69
+ return String((_sources_email = sources.email) != null ? _sources_email : '').trim();
70
+ }
71
+ export function useUserName() {
72
+ const firestore = useFirestore();
73
+ const { data: user } = useUser();
74
+ const uid = user == null ? void 0 : user.uid;
75
+ const { data } = useFirestoreDoc(// Null ref until a uid exists, so a signed-out mount opens no listen at
76
+ // all rather than one the rules refuse — same shape as the photo hook.
77
+ ()=>uid ? doc(firestore, 'users', uid) : null, [
78
+ firestore,
79
+ uid
80
+ ]);
81
+ return resolveUserName({
82
+ profileFirstName: data == null ? void 0 : data.firstName,
83
+ profileLastName: data == null ? void 0 : data.lastName,
84
+ authDisplayName: user == null ? void 0 : user.displayName,
85
+ email: user == null ? void 0 : user.email
86
+ });
87
+ }
88
+ export default useUserName;
89
+
90
+ //# sourceMappingURL=use-user-name.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/use-user-name.tsx"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nimport { doc } from 'firebase/firestore'\nimport { useFirestore, useUser } from './firebase/firebase-services'\nimport { useFirestoreDoc } from './use-firestore-doc'\n\n/**\n * The signed-in user's NAME, resolved the way their photo already is\n * (AGL-2486).\n *\n * ## The defect this closes\n *\n * `user.displayName` is empty for every SSO account: a SAML identity arrives\n * with `displayName: undefined`, `photoURL: undefined` and a single provider\n * entry for the SAML provider, because GCIP leaves the assertion's mapped\n * attributes under `firebase.sign_in_attributes`. A surface that reads that\n * field alone falls through to the email, so it renders the address as the\n * person's NAME and again underneath as their address — and `memberInitials`,\n * given an address with no space in it, yields a single letter where the\n * presence stack beside it shows two.\n *\n * The name is not actually missing. `users/{uid}` holds `firstName` and\n * `lastName`, the org member doc holds a `displayName`, and presence reads the\n * ID token's IdP claims. Three surfaces, three answers to \"what is this person\n * called\", and the auth record is the only one that is blank for the\n * enterprise tier.\n *\n * ## Why here, and why this order\n *\n * `users/{uid}` is the profile the user themselves edits, so it outranks the\n * provider's copy — exactly the precedence `useUserProfilePhotoUrl` already\n * establishes for the picture, on the same document, through the same\n * listener. This is that hook's sibling and is deliberately shaped like it;\n * two hooks answering \"who is signed in\" should not disagree about where to\n * look.\n *\n * The auth record is the fallback rather than the lead because it is the one\n * an IdP may never populate. The email is last and is not really a name — it\n * is what the surface shows when nothing else exists, and callers that want\n * to show an address separately should keep doing so.\n */\n/**\n * The resolution itself, without React (AGL-2486).\n *\n * Pure and exported so the ORDER can be pinned in a spec. The order is the\n * entire content of this hook and the only part that can be wrong — and the\n * case that matters is one no unit test can reach through the hook, because\n * it needs a real SSO session in a GCIP tenant.\n */\nexport function resolveUserName(sources: {\n profileFirstName?: string | null\n profileLastName?: string | null\n authDisplayName?: string | null\n email?: string | null\n}): string {\n const fromProfile = [sources.profileFirstName, sources.profileLastName]\n .map((part) => String(part ?? '').trim())\n .filter(Boolean)\n .join(' ')\n if (fromProfile) return fromProfile\n const fromAuth = String(sources.authDisplayName ?? '').trim()\n if (fromAuth) return fromAuth\n return String(sources.email ?? '').trim()\n}\n\nexport function useUserName(): string {\n const firestore = useFirestore()\n const { data: user } = useUser()\n const uid = user?.uid\n const { data } = useFirestoreDoc<{\n firstName?: string\n lastName?: string\n }>(\n // Null ref until a uid exists, so a signed-out mount opens no listen at\n // all rather than one the rules refuse — same shape as the photo hook.\n () => (uid ? doc(firestore, 'users', uid) : null),\n [firestore, uid],\n )\n return resolveUserName({\n profileFirstName: data?.firstName,\n profileLastName: data?.lastName,\n authDisplayName: user?.displayName,\n email: user?.email,\n })\n}\n\nexport default useUserName\n"],"names":["doc","useFirestore","useUser","useFirestoreDoc","resolveUserName","sources","fromProfile","profileFirstName","profileLastName","map","part","String","trim","filter","Boolean","join","fromAuth","authDisplayName","email","useUserName","firestore","data","user","uid","firstName","lastName","displayName"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD,SAASA,GAAG,QAAQ,qBAAoB;AACxC,SAASC,YAAY,EAAEC,OAAO,QAAQ,kCAA8B;AACpE,SAASC,eAAe,QAAQ,yBAAqB;AAErD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD;;;;;;;CAOC,GACD,OAAO,SAASC,gBAAgBC,OAK/B;QAMyBA,0BAEVA;IAPd,MAAMC,cAAc;QAACD,QAAQE,gBAAgB;QAAEF,QAAQG,eAAe;KAAC,CACpEC,GAAG,CAAC,CAACC,OAASC,OAAOD,eAAAA,OAAQ,IAAIE,IAAI,IACrCC,MAAM,CAACC,SACPC,IAAI,CAAC;IACR,IAAIT,aAAa,OAAOA;IACxB,MAAMU,WAAWL,QAAON,2BAAAA,QAAQY,eAAe,YAAvBZ,2BAA2B,IAAIO,IAAI;IAC3D,IAAII,UAAU,OAAOA;IACrB,OAAOL,QAAON,iBAAAA,QAAQa,KAAK,YAAbb,iBAAiB,IAAIO,IAAI;AACzC;AAEA,OAAO,SAASO;IACd,MAAMC,YAAYnB;IAClB,MAAM,EAAEoB,MAAMC,IAAI,EAAE,GAAGpB;IACvB,MAAMqB,MAAMD,wBAAAA,KAAMC,GAAG;IACrB,MAAM,EAAEF,IAAI,EAAE,GAAGlB,gBAIf,wEAAwE;IACxE,uEAAuE;IACvE,IAAOoB,MAAMvB,IAAIoB,WAAW,SAASG,OAAO,MAC5C;QAACH;QAAWG;KAAI;IAElB,OAAOnB,gBAAgB;QACrBG,gBAAgB,EAAEc,wBAAAA,KAAMG,SAAS;QACjChB,eAAe,EAAEa,wBAAAA,KAAMI,QAAQ;QAC/BR,eAAe,EAAEK,wBAAAA,KAAMI,WAAW;QAClCR,KAAK,EAAEI,wBAAAA,KAAMJ,KAAK;IACpB;AACF;AAEA,eAAeC,YAAW"}