your-ai-workflow-firebase-os 2.0.2 → 2.0.3

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 (180) hide show
  1. package/dist/AccessNotice-BZ5Gt1nP.js +40 -0
  2. package/dist/AccessNotice-CN7kc70o.cjs +1 -0
  3. package/dist/{AddressField-B2YBRXcx.js → AddressField-9Sqn8TvC.js} +3 -3
  4. package/dist/{AddressField-BBv8NGX8.cjs → AddressField-B7wtV8yA.cjs} +1 -1
  5. package/dist/Assets-DBMwZ3bN.cjs +1 -0
  6. package/dist/Assets-DT-NU_WO.js +296 -0
  7. package/dist/Attachments-Bl9_dfsu.js +46 -0
  8. package/dist/Attachments-UVK9SRXf.cjs +1 -0
  9. package/dist/Billing-0RIHayHy.cjs +1 -0
  10. package/dist/Billing-FNE2r3_d.js +379 -0
  11. package/dist/BookingCalendar-BP5kiv3d.cjs +1 -0
  12. package/dist/BookingCalendar-DZ2fEFja.js +309 -0
  13. package/dist/Button-4lzOE31p.js +48 -0
  14. package/dist/Button-5DlB2MCi.cjs +1 -0
  15. package/dist/CalendarView-BimoXy_k.cjs +1 -0
  16. package/dist/CalendarView-D-ykjpvI.js +664 -0
  17. package/dist/ConfirmModal-Di9GR-vV.cjs +1 -0
  18. package/dist/ConfirmModal-gKQEpd4d.js +113 -0
  19. package/dist/ContactPopup-BXnHrRwl.js +1114 -0
  20. package/dist/ContactPopup-lxmRMoIa.cjs +1 -0
  21. package/dist/CopyLink-BpEQWmfI.cjs +1 -0
  22. package/dist/CopyLink-CBAVfcm1.js +43 -0
  23. package/dist/DashboardNav-CXa_ci-_.cjs +1 -0
  24. package/dist/DashboardNav-Dmn7PVSp.js +886 -0
  25. package/dist/EmailAdmin-CjN_rvxT.js +318 -0
  26. package/dist/EmailAdmin-lAEBdY1s.cjs +1 -0
  27. package/dist/EventCard-46EquygT.cjs +1 -0
  28. package/dist/EventCard-B6xD-mk3.js +97 -0
  29. package/dist/FileViewerPopup-CX-J75TM.js +1283 -0
  30. package/dist/FileViewerPopup-D8-MmIoS.cjs +3 -0
  31. package/dist/FormNote-BivRNA3J.js +50 -0
  32. package/dist/FormNote-BtdTGpml.cjs +1 -0
  33. package/dist/FormsAdmin-B5PBCG2W.js +812 -0
  34. package/dist/FormsAdmin-BQCULvi5.cjs +1 -0
  35. package/dist/Inbox-BPk2ZkE5.js +779 -0
  36. package/dist/Inbox-DNrcBx0o.cjs +3 -0
  37. package/dist/Loader-Bu89fsbB.cjs +1 -0
  38. package/dist/Loader-D4gwgBOq.js +32 -0
  39. package/dist/ManagePlanButton-B0dwqZBd.cjs +1 -0
  40. package/dist/ManagePlanButton-BSrOHYze.js +29 -0
  41. package/dist/MyBookings-6qr0GgzO.cjs +1 -0
  42. package/dist/MyBookings-BQjGM2LQ.js +113 -0
  43. package/dist/PageTitle-CZhgmmDT.cjs +3 -0
  44. package/dist/PageTitle-D93YCiN8.js +1606 -0
  45. package/dist/PagesAdmin-BcHIcqM5.cjs +34 -0
  46. package/dist/PagesAdmin-D69P9nRb.js +5802 -0
  47. package/dist/PagesAdmin-DNwZOKjA.cjs +1 -0
  48. package/dist/PagesAdmin-HUITEcHx.js +3 -0
  49. package/dist/PaymentsAdmin-C2iCI9qC.js +1291 -0
  50. package/dist/PaymentsAdmin-CoXi2vCm.cjs +2 -0
  51. package/dist/{PhoneInputField-B3P_vUDW.cjs → PhoneInputField-BEwYZMB7.cjs} +1 -1
  52. package/dist/{PhoneInputField-D3wWs-M8.js → PhoneInputField-BG0L2djE.js} +2 -2
  53. package/dist/PhoneInputField-DfOtCIH_.js +2 -0
  54. package/dist/{PhoneInputField-CdNTvWXQ.cjs → PhoneInputField-jhWvto8C.cjs} +1 -1
  55. package/dist/Popup-DHYGFl2-.cjs +1 -0
  56. package/dist/Popup-xROLytgr.js +142 -0
  57. package/dist/ProviderMark-BBfaM5Yl.js +52 -0
  58. package/dist/ProviderMark-xLkUN3Fx.cjs +1 -0
  59. package/dist/PublishApp-Bm-6r3Td.js +595 -0
  60. package/dist/PublishApp-D4XJUkJa.cjs +2 -0
  61. package/dist/Said-CoC15awL.js +94 -0
  62. package/dist/Said-IqCNofdh.cjs +1 -0
  63. package/dist/ScheduleAdmin-BKfQkrR8.js +1853 -0
  64. package/dist/ScheduleAdmin-DwK36GAZ.cjs +1 -0
  65. package/dist/SchedulePicker--urIH2Eb.js +112 -0
  66. package/dist/SchedulePicker-nmqzUnUH.cjs +1 -0
  67. package/dist/Setup-CHLgd-ag.js +849 -0
  68. package/dist/Setup-CaDranTm.cjs +1374 -0
  69. package/dist/SetupPayments-CVIwh1eQ.js +411 -0
  70. package/dist/SetupPayments-HNXWA25X.cjs +1 -0
  71. package/dist/SignInAdmin-C94-A-0w.cjs +4 -0
  72. package/dist/SignInAdmin-Yxgv_8U4.js +766 -0
  73. package/dist/SupportBar-CDT0HwdP.cjs +1 -0
  74. package/dist/SupportBar-CN_JGC-9.js +117 -0
  75. package/dist/SystemTabContent-L3vGGF3P.cjs +1 -0
  76. package/dist/SystemTabContent-QdxxLILE.js +35 -0
  77. package/dist/TemplateDrive-DV8zNjYe.cjs +1 -0
  78. package/dist/TemplateDrive-NJY_AAvV.js +1197 -0
  79. package/dist/ThemeAdmin-BzFjJkLg.js +269 -0
  80. package/dist/ThemeAdmin-DVnPNxe7.cjs +1 -0
  81. package/dist/ThemeAdmin-DY7Vc4Sj.cjs +3 -0
  82. package/dist/ThemeAdmin-DnC-D2N3.js +2 -0
  83. package/dist/{ContactPopup-Dsg2ntq3.js → TimeZoneField-DkI1Bkcz.js} +3191 -5322
  84. package/dist/TimeZoneField-qs7NPR30.cjs +12 -0
  85. package/dist/TransactionsAdmin-BsZ-oQWi.js +582 -0
  86. package/dist/TransactionsAdmin-CTSTiPld.cjs +1 -0
  87. package/dist/Trouble-9es_AI4F.cjs +1 -0
  88. package/dist/Trouble-oWt9fMsm.js +164 -0
  89. package/dist/Users-CTitWXyd.cjs +1 -0
  90. package/dist/Users-DJjQTWJF.js +1207 -0
  91. package/dist/aiModels-DIP9fn8T.cjs +1 -0
  92. package/dist/{aiModels-BHlEfuRJ.js → aiModels-DnDhNGup.js} +29 -61
  93. package/dist/arrivalRoads-BJ-35vkk.js +228 -0
  94. package/dist/arrivalRoads-qoU-B4jt.cjs +4 -0
  95. package/dist/{bake-BWG3y-15.cjs → bake-n2wyFw0B.cjs} +1 -1
  96. package/dist/{bake-BtYOG1Du.js → bake-naZeMu98.js} +1 -1
  97. package/dist/bookingPages-C7a3srPG.js +102 -0
  98. package/dist/bookingPages-DvbFbD9X.cjs +1 -0
  99. package/dist/components/CustomSelect.d.ts +7 -1
  100. package/dist/components/RegistrationFields.d.ts +6 -1
  101. package/dist/components/Reveal.d.ts +12 -0
  102. package/dist/components/ScrollToTop.d.ts +4 -0
  103. package/dist/contentFallback-C9MZjdq7.cjs +1 -0
  104. package/dist/contentFallback-CEDY0Pwp.js +96 -0
  105. package/dist/contentKey-Bwry4lDa.js +13 -0
  106. package/dist/contentKey-DJGYYnAE.cjs +1 -0
  107. package/dist/email-BfnZnMwD.cjs +1 -0
  108. package/dist/email-Ddj8Dlu6.js +58 -0
  109. package/dist/{fileBytes-BdwaFKRy.cjs → fileBytes-BJPaiX9Y.cjs} +1 -1
  110. package/dist/{fileBytes-DuW2K-H_.js → fileBytes-bjgvCfWu.js} +2 -2
  111. package/dist/forms-Bvx9HTM6.js +75 -0
  112. package/dist/forms-CmNeTETW.cjs +1 -0
  113. package/dist/lib/ThemeContext.d.ts +0 -14
  114. package/dist/lib/bareImgWatch.d.ts +6 -1
  115. package/dist/lib/localEdits.d.ts +2 -0
  116. package/dist/lib/paymentProducts.d.ts +8 -0
  117. package/dist/lib/payments.d.ts +11 -1
  118. package/dist/notices-B7iIrIEg.js +29 -0
  119. package/dist/notices-upMg8UVh.cjs +1 -0
  120. package/dist/openOnArrival-Buwqb86D.cjs +1 -0
  121. package/dist/openOnArrival-S2K6v-lB.js +35 -0
  122. package/dist/pageFiles--Ih60ITP.js +45 -0
  123. package/dist/pageFiles-UtBmlOcD.cjs +1 -0
  124. package/dist/pages/PagesAdmin.d.ts +69 -0
  125. package/dist/people-2jZE_pdI.js +102 -0
  126. package/dist/people-BpVvIr9-.cjs +1 -0
  127. package/dist/personName-CFw3pxuu.js +18 -0
  128. package/dist/personName-CbzN_i57.cjs +1 -0
  129. package/dist/publishSteps-C1cDFzzB.js +88 -0
  130. package/dist/publishSteps-SSm8afDw.cjs +1 -0
  131. package/dist/recordLock-DHVvI9bP.js +61 -0
  132. package/dist/recordLock-DWeIxiwh.cjs +1 -0
  133. package/dist/recordsKey-B7QrxdeU.cjs +1 -0
  134. package/dist/recordsKey-D5-wC0Aw.js +141 -0
  135. package/dist/registrationFields-CLeR2lQJ.js +181 -0
  136. package/dist/registrationFields-v4pOoRdn.cjs +1 -0
  137. package/dist/renderer.cjs.js +1 -1
  138. package/dist/renderer.es.js +1 -1
  139. package/dist/roles-D6VaHWfV.js +147 -0
  140. package/dist/roles-DQUFFBQe.cjs +1 -0
  141. package/dist/scheduleData-ATN8w5hI.js +727 -0
  142. package/dist/scheduleData-Cltj0ABZ.cjs +1 -0
  143. package/dist/server.cjs.js +6 -6
  144. package/dist/server.es.js +716 -715
  145. package/dist/serviceStatus-TQyZO_Y_.cjs +1 -0
  146. package/dist/serviceStatus-vS8AI7SZ.js +125 -0
  147. package/dist/tabAudience-BqpN6dsR.cjs +1 -0
  148. package/dist/tabAudience-CRyMvv0v.js +34 -0
  149. package/dist/transactions-BqMQDRx3.js +343 -0
  150. package/dist/transactions-CufFrdAM.cjs +1 -0
  151. package/dist/useIsPhone-BVGx_X3e.js +13 -0
  152. package/dist/useIsPhone-o63tNneA.cjs +1 -0
  153. package/dist/{validate-DzSJWuyL.js → validate-CmC_tLK-.js} +433 -351
  154. package/dist/validate-DZMwqSrb.cjs +103 -0
  155. package/dist/vite/validate.d.ts +14 -0
  156. package/dist/vite.cjs.js +1 -1
  157. package/dist/vite.es.js +1 -1
  158. package/dist/your-ai-workflow-firebase-os.cjs.js +15 -1431
  159. package/dist/your-ai-workflow-firebase-os.css +1 -1
  160. package/dist/your-ai-workflow-firebase-os.es.js +8200 -32421
  161. package/package.json +1 -1
  162. package/src/index.css +122 -2
  163. package/dist/ContactPopup-IwBm-eqz.cjs +0 -12
  164. package/dist/PhoneInputField-CVamkTJg.js +0 -2
  165. package/dist/aiModels-BIhoTNH0.cjs +0 -1
  166. package/dist/validate-e48WXJdP.cjs +0 -103
  167. /package/dist/{address-DI1YaCI4.cjs → address-Bsb8COq2.cjs} +0 -0
  168. /package/dist/{address-Ca1h_UZX.js → address-C2bHNxJU.js} +0 -0
  169. /package/dist/{dist-BkUiTtIb.js → dist-DUPvBnOA.js} +0 -0
  170. /package/dist/{dist-BLGlDv1e.cjs → dist-E4jTFPs7.cjs} +0 -0
  171. /package/dist/{lucide-react-BYniZ3lQ.cjs → lucide-react-BwV6L6Is.cjs} +0 -0
  172. /package/dist/{lucide-react-ehRy1vqS.js → lucide-react-CA7GD7Nv.js} +0 -0
  173. /package/dist/{mediaKind-dk396a03.cjs → mediaKind-D0MWyv6b.cjs} +0 -0
  174. /package/dist/{mediaKind-De2Cx_kB.js → mediaKind-DmKMsjeY.js} +0 -0
  175. /package/dist/{pdf-u73U7-6s.js → pdf-DRXcPGkr.js} +0 -0
  176. /package/dist/{pdf-B24jYmSf.cjs → pdf-jLjjhvjp.cjs} +0 -0
  177. /package/dist/{pdf.worker.min-BPOWx2_9.js → pdf.worker.min-CfKZezeM.js} +0 -0
  178. /package/dist/{pdf.worker.min-BvhEiZFe.cjs → pdf.worker.min-bLgNWd7w.cjs} +0 -0
  179. /package/dist/{verifiedAccount-C1iCxopN.js → verifiedAccount-DRbRFJ6H.js} +0 -0
  180. /package/dist/{verifiedAccount-D3OeaOBo.cjs → verifiedAccount-DScyKk96.cjs} +0 -0
@@ -0,0 +1,1374 @@
1
+ const e=require(`./chunk-CiTBaPhY.cjs`),t=require(`./PageTitle-CZhgmmDT.cjs`),n=require(`./TimeZoneField-qs7NPR30.cjs`),r=require(`./lucide-react-BwV6L6Is.cjs`),i=require(`./SupportBar-CDT0HwdP.cjs`);let a=require(`react`);a=e.a(a,1);let o=require(`react/jsx-runtime`);require(`firebase/firestore`);var s=`aistudio`;function c(){return s}function l(){let[e,t]=(0,a.useState)(c);return[e,(0,a.useCallback)(e=>{t(e)},[])]}var u=`Set this project up for Your AI Firebase, and do only what is listed here.
2
+
3
+ 1. If there is no app in this folder yet, create a Vite + React + TypeScript app in it.
4
+ 2. Delete the starter demo — the counter, the logos, App.css, the sample markup. None of the template's own content stays.
5
+ 3. Run: npm install your-ai-workflow-firebase-os
6
+ Its install step writes src/App.tsx and src/main.tsx, patches vite.config.ts, and creates src/your-ai-workflow-firebase-os/ with the theme and configs. Never edit App.tsx or main.tsx afterwards — the build rewrites them from the package.
7
+ 4. Run npm install once more: the step above adds Tailwind to package.json.
8
+ 5. Create .env in the project root — replace it if the install already made one — with exactly these lines and nothing else, every value left empty:
9
+
10
+ # Firebase Configuration
11
+ VITE_FIREBASE_API_KEY=""
12
+ VITE_FIREBASE_AUTH_DOMAIN=""
13
+ VITE_FIREBASE_PROJECT_ID=""
14
+ VITE_FIREBASE_STORAGE_BUCKET=""
15
+ VITE_FIREBASE_MESSAGING_SENDER_ID=""
16
+ VITE_FIREBASE_APP_ID=""
17
+ STRIPE_SECRET_KEY=""
18
+ STRIPE_WEBHOOK_SECRET=""
19
+ RESEND_API_KEY=""
20
+ APP_NAME=""
21
+ VITE_ADMIN_EMAILS=""
22
+ EMAIL_FROM=""
23
+
24
+ Make sure .gitignore lists .env.
25
+ 6. Give it the backend the package expects:
26
+ - npm install express dotenv stripe
27
+ - npm install -D tsx @types/express
28
+ - Copy node_modules/your-ai-workflow-firebase-os/server.template.ts to server.ts in the project root, unchanged.
29
+ - Set the dev script in package.json to: tsx server.ts
30
+ Do not add routes to server.ts. Every route lives inside the package, so new features arrive with a version bump and this file never changes again.
31
+ 7. Start it with npm run dev and tell me the address it prints.
32
+
33
+ Then stop. Do not build pages, write content files or change the theme — the app opens on its own Setup screen and carries on from there.`,d=`fbos-setup-done`;function f(){try{localStorage.setItem(d,`yes`)}catch{}}var p=`rules_version = '2';
34
+ service cloud.firestore {
35
+ match /databases/{database}/documents {
36
+
37
+ // ── Who is asking ─────────────────────────────────────────────────────
38
+ function isAuth() { return request.auth != null; }
39
+
40
+ // The profile document is the whole access model: it carries \`role\` and
41
+ // \`inactive\`. Everything below reads it through these two helpers, so the
42
+ // rules engine fetches it once per request no matter how many checks run.
43
+ function profileExists() {
44
+ return exists(/databases/$(database)/documents/user_profiles/$(request.auth.uid));
45
+ }
46
+
47
+ function profile() {
48
+ return get(/databases/$(database)/documents/user_profiles/$(request.auth.uid)).data;
49
+ }
50
+
51
+ // Turning someone off under /users used to be decoration: \`inactive\` was
52
+ // protected from being written (see grantsUntouched) but never read, so the
53
+ // only thing stopping a deactivated person was the Access Denied screen in
54
+ // the app. Anyone who opened devtools and drove the Firebase SDK straight
55
+ // kept every read and write they had before. Now the flag is checked here,
56
+ // where it cannot be walked around.
57
+ // An account with no profile document yet is NOT deactivated — registration
58
+ // seeds the profile a moment after the account exists, and that gap must
59
+ // not lock a brand new person out of their own sign-up.
60
+ function notDeactivated() {
61
+ return isAuth() &&
62
+ (!profileExists() || profile().get('inactive', false) != true);
63
+ }
64
+
65
+ // Firebase flips email_verified once the link in the verification mail is
66
+ // clicked; Google sign-ins arrive already verified. Every rule below
67
+ // spends this rather than isAuth(), so a half-registered account cannot
68
+ // walk past the /verify screen by driving the SDK from a browser console —
69
+ // and, since the deactivation check lives here too, neither can a
70
+ // deactivated one.
71
+ //
72
+ // Somebody who signed in with a PHONE NUMBER has no address to verify, so
73
+ // there is nothing here for them to fail and nothing for them to skip:
74
+ // the code texted to their phone is the check, and Firebase has already
75
+ // made it before the token exists. Without this line such a person is
76
+ // refused every read and write in the app — see hasNoEmailToVerify.
77
+ function isVerified() {
78
+ return isAuth() &&
79
+ (request.auth.token.email_verified == true || hasNoEmailToVerify()) &&
80
+ notDeactivated();
81
+ }
82
+
83
+ // A sign-in that carries no email address at all. Phone sign-in is the one
84
+ // Firebase offers; the check is on the PROVIDER, never on the address
85
+ // being blank, so an email account cannot reach this by clearing its own
86
+ // address.
87
+ function hasNoEmailToVerify() {
88
+ return request.auth.token.firebase.sign_in_provider == 'phone';
89
+ }
90
+
91
+ function role() {
92
+ return profile().role;
93
+ }
94
+
95
+ function hasProfile() {
96
+ return isVerified() && profileExists();
97
+ }
98
+
99
+ function isAdmin() {
100
+ return hasProfile() && role() == 'admin';
101
+ }
102
+
103
+ // Internal members and admins. Registering is NOT enough: an admin has to
104
+ // set the level under /users. Everything a free user must never see —
105
+ // every mem_ collection, which is what internal pages read and write, and
106
+ // the list of people under user_profiles — hangs off this one function.
107
+ function isInternal() {
108
+ return hasProfile() && role() in ['internal', 'admin'];
109
+ }
110
+
111
+ // ── Who may delete ────────────────────────────────────────────────────
112
+ // "My team can move a job along but never delete one." Until 1.14.66 that
113
+ // was a button the page did not draw, and these rules let every internal
114
+ // member delete every mem_ record all the same. Now a page row has a
115
+ // switch — WHO MAY DELETE — and the app stamps every record saved through
116
+ // such a page with \`lock: { delete: ['admin'] }\`: a list of the roles that
117
+ // may delete it. These two read it. A record with no lock is deleted by
118
+ // whoever may change it, as before; a locked one only by a role on the
119
+ // list — and admins, who pass everything through the wildcard above.
120
+ //
121
+ // The lock has to ride on the record: a page's records live under a name
122
+ // the assistant chose, which no rule here could look up a switch for.
123
+ function deleteLocked() {
124
+ return 'lock' in resource.data
125
+ && resource.data.lock is map
126
+ && 'delete' in resource.data.lock
127
+ && resource.data.lock.delete is list;
128
+ }
129
+
130
+ function mayDelete() {
131
+ return !deleteLocked() || role() in resource.data.lock.delete;
132
+ }
133
+
134
+ // And nobody but an admin may TOUCH the lock on an update — or a team
135
+ // member would unlock a record in order to delete it. Rewriting the same
136
+ // lock back is not a change, so a page that saves a whole record is fine.
137
+ function keepsLock() {
138
+ return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['lock']);
139
+ }
140
+
141
+ // ── Roles you invented ────────────────────────────────────────────────
142
+ // A role you make on the Pages screen — Client, Supplier — is stored in
143
+ // the same \`role\` field these three words live in, and it grants nothing
144
+ // by itself: to every rule above it, somebody marked Client is a free
145
+ // user. What it opens is the collections of the pages you kept for it,
146
+ // which are named after the role: role_client_projects, role_client_notes.
147
+ //
148
+ // The role is read straight out of the collection name — everything
149
+ // between the first underscore and the second — so a role you invent
150
+ // tomorrow works without these rules ever being pasted again. That is only
151
+ // safe because a role's id is squeezed to lowercase letters and digits
152
+ // before it is ever stored (roleIdFrom, in src/lib/access.ts): it can
153
+ // never contain the underscore this splits on, and never be one of the
154
+ // words listed below, which the app already means something by. A
155
+ // collection whose name does not pass both tests matches no rule here at
156
+ // all, so it is the admins' alone, through the wildcard above.
157
+ function collectionRole(name) {
158
+ return name.split('_')[1];
159
+ }
160
+
161
+ // A word the owner made up, rather than one this app already means
162
+ // something by. The list is the same one src/lib/access.ts refuses when
163
+ // the role is first named, and it is what makes reading a role straight
164
+ // out of a name safe.
165
+ function isInventedRole(id) {
166
+ return id.matches('^[a-z][a-z0-9]*$') &&
167
+ !(id in
168
+ ['user', 'users', 'free', 'guest', 'guests', 'internal', 'admin',
169
+ 'admins', 'public', 'owner', 'team', 'member', 'members', 'none',
170
+ 'role', 'roles']);
171
+ }
172
+
173
+ function isRoleData(name) {
174
+ return name.matches('^role_[a-z][a-z0-9]*_.*') &&
175
+ isInventedRole(collectionRole(name));
176
+ }
177
+
178
+ // Everybody marked with the role reads and writes the SAME records. That
179
+ // is the whole difference between this space and user_, where each person
180
+ // sees only their own: a client portal can hold one shared price list
181
+ // without it being public and without every client getting a copy.
182
+ function hasRoleFor(name) {
183
+ return hasProfile() && role() == collectionRole(name) && planStillPaid();
184
+ }
185
+
186
+ // ── Entries that are sent to people ───────────────────────────────────
187
+ // A collection whose name begins case_ holds records that each say for
188
+ // themselves who they are for. Two lists on the record do it:
189
+ //
190
+ // access who may SEE it
191
+ // editors who may CHANGE it, delete it, or send it on
192
+ //
193
+ // A key on either list is one person — 'uid:abc123' — or everybody marked
194
+ // with one role — 'role:manager'. Every request below is answered by
195
+ // reading the caller's own two keys and looking for them on the list, so
196
+ // an entry is invisible to anyone it was not sent to. That is what makes a
197
+ // client portal possible: a manager hands a client's card to somebody in
198
+ // customer satisfaction, that member fills it in, the client reads their
199
+ // own card and nothing else, and none of them can reach a card that was
200
+ // never sent to them — not from the page, and not from a browser console.
201
+ //
202
+ // The names are read out of the collection exactly as role_ names are, so
203
+ // a collection invented tomorrow is guarded without this file being pasted
204
+ // again.
205
+ function caseKey() { return 'uid:' + request.auth.uid; }
206
+
207
+ // A member who registered has no role WRITTEN on their profile — nobody
208
+ // may write their own (grantsUntouched) — and the app calls them 'user'.
209
+ // Read bare, role() is an error for them, so an entry sent to Members
210
+ // never reached them and their own list by role was refused: every buyer
211
+ // on an Orders page saw "could not be read" (2026-09-22).
212
+ function roleKey() { return 'role:' + profile().get('role', 'user'); }
213
+
214
+ function isCaseData(name) { return name.matches('^case_.*'); }
215
+
216
+ // On one of these lists, by name or by role. A role a plan sold lasts
217
+ // only as long as the plan does, the same way it does everywhere else.
218
+ // The third key: whoever PROVES an address is theirs. An order made on a
219
+ // page anybody can open is very often made signed out — the buyer typed
220
+ // their email, paid, and registered a day later, or never opened the
221
+ // account the checkout made for them and started another. A 'uid:' key
222
+ // names an account, so that order could never reach them; this names the
223
+ // person, the way a guest's booking already does (mem_bookings below).
224
+ //
225
+ // email_verified is asked for HERE, by name, and not left to isVerified():
226
+ // a phone sign-in passes that with no verified address at all, and an
227
+ // address nobody proved is anybody's to type. Folded to lower case because
228
+ // the app folds the one it writes (forAddress, src/lib/cases.ts).
229
+ function holdsAddress(keys) {
230
+ return request.auth.token.email != null &&
231
+ request.auth.token.email_verified == true &&
232
+ ('email:' + request.auth.token.email.lower()) in keys;
233
+ }
234
+
235
+ // The fourth key, and the only one that names nobody: 'everyone' means
236
+ // everybody signed in. It is how a person PUBLISHES something they made —
237
+ // a property listing, a piece of work, a page they want found — and it is
238
+ // the one thing the three keys above could never say. uid: names an
239
+ // account, role: names a group the owner marks people into, email: names
240
+ // an address; all three are lists somebody else keeps. A listing is seen
241
+ // by people nobody listed.
242
+ //
243
+ // Signed in, not the open internet: pub_ is that, and this is deliberately
244
+ // not it. Somebody with no account sees nothing of this, which is what
245
+ // separates "share it inside the app" from "put it on the website".
246
+ //
247
+ // No profile is asked for and no plan: a person who registered a minute
248
+ // ago browses what other people published, the way they browse a public
249
+ // page. The wall is on the other side — 'everyone' is refused on \`editors\`
250
+ // below, so a published record is still only its author's to change.
251
+ function published(keys) { return 'everyone' in keys; }
252
+
253
+ // Somebody NAMED on one of the lists. This is what changing an entry is
254
+ // proved against, and 'everyone' is deliberately not in it: publishing is
255
+ // a key for READING, and a record whose \`editors\` somehow held that word
256
+ // — an admin writing through the wildcard above, an install older than
257
+ // the rule that refuses it — must still not be everybody's to delete.
258
+ function named(keys) {
259
+ return isVerified() &&
260
+ (caseKey() in keys ||
261
+ holdsAddress(keys) ||
262
+ (hasProfile() && roleKey() in keys && planStillPaid()));
263
+ }
264
+
265
+ // Somebody who may SEE it: named on the list, or the thing was published.
266
+ function listed(keys) { return named(keys) || (isVerified() && published(keys)); }
267
+
268
+ // The lists are what every read of this collection is proved against, so
269
+ // they are the one thing worth a size cap: fifty is far past a card handed
270
+ // round a company and cheap to check. Nothing else about the record is
271
+ // capped — an admin writing through the wildcard above never reaches here.
272
+ //
273
+ // And 'everyone' is an \`access\` key and never an \`editors\` one. On editors
274
+ // it would mean anybody signed in may rewrite or delete anybody's listing
275
+ // — the whole shape undone by one word in one array, written by an
276
+ // assistant that read the other list and matched it. It is refused here,
277
+ // where no page can talk its way past it.
278
+ function caseListsSane(data) {
279
+ return data.access.size() <= 50 && data.editors.size() <= 50 &&
280
+ !('everyone' in data.editors);
281
+ }
282
+
283
+ // ── The people one of your roles may pick from ────────────────────────
284
+ // An entry that is sent has to be sent TO somebody, and picking them
285
+ // means reading names. Only your team reads user_profiles, so a manager —
286
+ // a role you invented, a free user to every rule above — opened an empty
287
+ // picker and could hand nothing to anybody.
288
+ //
289
+ // Being on the same sent-entries page is what opens it. The Pages screen
290
+ // works out, from your own pages, which roles need to see which others and
291
+ // writes the answer on the role itself (sys_roles/<id>.sees); this reads
292
+ // it. A role never sees its OWN people, so two clients on one portal can
293
+ // never list each other, and it is pinned by the query — a list has to ask
294
+ // for one role at a time (where('role', '==', 'customersatisfaction')).
295
+ //
296
+ // Worth saying plainly: a profile is one document, so whoever may read it
297
+ // reads every field on it, including whatever people answered when they
298
+ // registered.
299
+ function seesRole(target) {
300
+ return isInventedRole(role()) && planStillPaid() && target != role() &&
301
+ target in get(/databases/$(database)/documents/sys_roles/$(role())).data.get('sees', []);
302
+ }
303
+
304
+ // ── The Inbox, Users and Transactions tabs ────────────────────────────
305
+ // These three used to be the admins' alone. On the Pages screen an admin
306
+ // can now tick who else opens each one — your team, or a role you
307
+ // invented — and the ticks are kept in sys_configs/tab_access, which only
308
+ // an admin can write.
309
+ //
310
+ // Never everybody signed in. These are every message anybody sent you,
311
+ // everybody who signed up, and every payment with the buyer's details on
312
+ // it, and "everybody signed in" is whoever registers. A list that still
313
+ // says 'user' from when that was on offer opens nothing here: a plain
314
+ // member is refused whatever the list holds.
315
+ //
316
+ // Opening one of these tabs is READING what it shows: the messages people
317
+ // sent, the people who signed up, the payments' orders. Nothing here lets
318
+ // anybody but an admin archive, delete or change any of it — those stay
319
+ // with the wildcard below. Admins are not named here either: they read
320
+ // everything through that same wildcard.
321
+ //
322
+ // An app where nothing was ever ticked has no such document, and a get()
323
+ // of a document that is not there is a refusal, so the tabs stay the
324
+ // admins'. A role a plan gave counts only while the plan is paid, the
325
+ // same as everywhere else.
326
+ function tabAudience(tab) {
327
+ return get(/databases/$(database)/documents/sys_configs/tab_access).data.get(tab, []);
328
+ }
329
+
330
+ function opensTab(tab) {
331
+ return hasProfile() &&
332
+ profile().get('role', '') != 'user' &&
333
+ profile().get('role', '') in tabAudience(tab) &&
334
+ planStillPaid();
335
+ }
336
+
337
+ function isDocOwner() {
338
+ return isVerified() &&
339
+ (resource == null || resource.data.uid == request.auth.uid) &&
340
+ (request.resource == null || request.resource.data.uid == request.auth.uid);
341
+ }
342
+
343
+ // ── Chat ──────────────────────────────────────────────────────────────
344
+ // One collection holds every conversation in the app, and \`kind\` says
345
+ // which of three it is:
346
+ //
347
+ // support the conversation between ONE member and your whole team.
348
+ // Its id is that member's own uid, which is what makes it
349
+ // *the* conversation with them: the side panel, a card on a
350
+ // team page and the box on the member's own page all open the
351
+ // same address and see the same messages, and nobody can make
352
+ // a second one — the create rule below insists the id and the
353
+ // single participant are the same person.
354
+ // direct two people, id \`d_<uid>_<uid>\` with the pair sorted. The id
355
+ // is worked out from the two names rather than invented, so
356
+ // two "New chat" clicks racing each other cannot leave two
357
+ // conversations behind where the browser meant one.
358
+ // group 2 to 50 people, a random id, opened by your team.
359
+ //
360
+ // Notes your team writes to each other about a member are NOT here. In
361
+ // the app this was taken from they were messages in the very list the
362
+ // member reads, hidden on screen and readable from a browser console.
363
+ // Here they live in mem_chat_notes and mem_chat_state, which the mem_
364
+ // lines below already give the team and give a member nothing — so a
365
+ // member's attempt to list the notes on their own conversation is
366
+ // refused by the database rather than filtered by a screen.
367
+ function chatThread(id) {
368
+ return get(/databases/$(database)/documents/chat_threads/$(id)).data;
369
+ }
370
+
371
+ // In this conversation, by name.
372
+ function inThread(data) {
373
+ return isVerified() && request.auth.uid in data.participants;
374
+ }
375
+
376
+ // Every support thread is your whole team's: a member writes to "the
377
+ // team", and whoever is free answers. Which of them it is MEANT for is
378
+ // \`handlerUid\` on the thread, and that changes who is told first and how
379
+ // the list is sorted — never who may read it.
380
+ function teamOnSupport(data) {
381
+ return data.kind == 'support' && isInternal();
382
+ }
383
+
384
+ function mayReadThread(data) {
385
+ return inThread(data) || teamOnSupport(data);
386
+ }
387
+
388
+ // A conversation nobody has written in yet has no document, and the panel
389
+ // still has to be able to listen to it — a member opening a brand new
390
+ // chat, or a colleague opening a member's card before either has said
391
+ // anything. Without this the very first thing a member sees is a refusal.
392
+ // It opens nothing: there is no document to read.
393
+ function mayOpenMissingThread(id) {
394
+ return isInternal() || (isVerified() && request.auth.uid == id);
395
+ }
396
+
397
+ // What a thread document may hold, whoever writes it. The participant cap
398
+ // is the one number worth holding: every message rule reads this list.
399
+ function threadShape(data) {
400
+ return data.kind in ['support', 'direct', 'group']
401
+ && data.participants is list
402
+ && data.participants.size() >= 1 && data.participants.size() <= 50
403
+ && data.keys().hasOnly(['kind', 'participants', 'participantNames',
404
+ 'createdBy', 'createdAt', 'updatedAt',
405
+ 'lastMessage', 'lastMessageSender', 'lastMessageAt',
406
+ 'lastReadAt', 'lastReadBy', 'reads',
407
+ 'groupName', 'groupIconUrl',
408
+ 'handlerUid', 'handlerName']);
409
+ }
410
+
411
+ // A member moves the conversation on and marks it read — and touches
412
+ // nothing else about it. Without this fence somebody outside your team
413
+ // could add themselves to a group, rename it, or hand their own support
414
+ // thread to a colleague who never took it.
415
+ function memberThreadKeys() {
416
+ return request.resource.data.diff(resource.data).affectedKeys()
417
+ .hasOnly(['lastMessage', 'lastMessageSender', 'lastMessageAt',
418
+ 'updatedAt', 'lastReadAt', 'lastReadBy', 'reads']);
419
+ }
420
+
421
+ // ── The first admin ───────────────────────────────────────────────────
422
+ // The Setup page writes your own address in here before you copy the rules,
423
+ // so this list already names you by the time you paste it into Firebase.
424
+ // It is the one and only way a person can hand themselves a role, it can
425
+ // only ever grant 'admin', and it only works from a verified address —
426
+ // otherwise anyone could register under your email and claim the app
427
+ // without ever opening your inbox.
428
+ function adminEmails() {
429
+ return ['your@email.com'];
430
+ }
431
+
432
+ function isSeedingOwnAdmin() {
433
+ return isVerified() &&
434
+ request.auth.token.email.lower() in adminEmails() &&
435
+ request.resource.data.role == 'admin' &&
436
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role']);
437
+ }
438
+
439
+ // \`role\` and \`inactive\` are the entire access model, so they are read-only
440
+ // to the person they describe: admins set them from /users, and the seed
441
+ // above and a paid plan below are the only exceptions. \`planUntil\` and
442
+ // \`planPass\` say how long a plan's role lasts, so they are guarded the
443
+ // same way. Name, avatar and the rest stay the owner's to write.
444
+ function grantsUntouched() {
445
+ return resource == null
446
+ ? !request.resource.data.keys().hasAny(['role', 'inactive', 'planUntil', 'planPass'])
447
+ : !request.resource.data.diff(resource.data).affectedKeys().hasAny(['role', 'inactive', 'planUntil', 'planPass']);
448
+ }
449
+
450
+ // ── Starting afresh after being deleted ──────────────────────────────
451
+ // Deleting somebody on /users leaves a tombstone on their profile —
452
+ // { removed, inactive, removedAt, removedMs } — which shuts them out at
453
+ // once. Their SIGN-IN cannot be deleted from a browser (that takes a
454
+ // server key), so it is still there, and the next time it is used it is
455
+ // somebody starting afresh, who must work like anybody else. So the
456
+ // tombstone may be REPLACED with a new profile, the way a first one is
457
+ // made: only by a sign-in made AFTER the deletion (a session already open
458
+ // when they were deleted stays shut out until they sign in again), and
459
+ // never with a role, a lock, a plan or the tombstone's own marks in it.
460
+ // A tombstone written before removedMs existed counts as the oldest.
461
+ function isStartingAfresh() {
462
+ return resource.data.get('removed', false) == true &&
463
+ request.auth.token.auth_time * 1000 > resource.data.get('removedMs', 0) &&
464
+ !request.resource.data.keys().hasAny(['role', 'inactive', 'planUntil', 'planPass', 'removed', 'removedAt', 'removedMs']);
465
+ }
466
+
467
+ // ── A role bought with a plan ─────────────────────────────────────────
468
+ // A subscription plan on the Payments screen can make its buyer one of
469
+ // your own roles — Premium, Gold. The server has no key to this database,
470
+ // so it never writes the role: it hands the buyer's own browser a PASS
471
+ // for exactly that role, that person and that date, and this checks it.
472
+ // The pass is a hash of the three with the plan key only your admins can
473
+ // read (admin_keys/plans — the Payments screen keeps it in step with
474
+ // Stripe), so a pass can never be made up, handed to somebody else,
475
+ // turned into another role or stretched past the date it names.
476
+ function planKey() {
477
+ return get(/databases/$(database)/documents/admin_keys/plans).data.key;
478
+ }
479
+
480
+ function planPassFor(data) {
481
+ return hashing.sha256(planKey() + ':' + request.auth.uid + ':' + data.role + ':' +
482
+ string(data.planUntil)).toHexString().lower();
483
+ }
484
+
485
+ // A plan only ever changes a member, or a role an earlier plan gave. It
486
+ // never touches your team, your admins or a role you gave by hand.
487
+ function planMayReplace() {
488
+ return resource.data.get('role', 'user') in ['user', 'free', null] ||
489
+ resource.data.get('planUntil', 0) != 0;
490
+ }
491
+
492
+ function isTakingPlanRole() {
493
+ return isVerified() && planMayReplace() &&
494
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'planUntil', 'planPass']) &&
495
+ request.resource.data.role.matches('^[a-z][a-z0-9]*$') &&
496
+ !(request.resource.data.role in ['user', 'free', 'internal', 'admin']) &&
497
+ request.time.toMillis() < request.resource.data.planUntil &&
498
+ request.resource.data.planPass == planPassFor(request.resource.data);
499
+ }
500
+
501
+ // When the plan ends, the same browser hands the role back — and only a
502
+ // role a plan gave can be handed back this way.
503
+ function isLeavingPlanRole() {
504
+ return resource.data.get('planUntil', 0) != 0 &&
505
+ request.resource.data.role == 'user' &&
506
+ request.resource.data.get('planUntil', 0) == 0 &&
507
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'planUntil', 'planPass']);
508
+ }
509
+
510
+ // A plan's role lasts until the date on its pass, whether or not the
511
+ // browser ever hands it back. A role you gave by hand has no date.
512
+ function planStillPaid() {
513
+ return profile().get('planUntil', 0) == 0 ||
514
+ request.time.toMillis() < profile().planUntil;
515
+ }
516
+
517
+ // user_ collections an ordinary page reads and writes. Profiles and
518
+ // submissions are carved out below — they each have their own rules.
519
+ function isUserData(collectionName) {
520
+ return collectionName.matches('^user_.*') &&
521
+ collectionName != 'user_profiles' &&
522
+ collectionName != 'user_submissions';
523
+ }
524
+
525
+ function ownsUserDoc(docId) {
526
+ return isVerified() && (isDocOwner() || request.auth.uid == docId);
527
+ }
528
+
529
+ // A form entry sent by somebody signed out is the one document the whole
530
+ // internet may write, which makes it the one thing a script can aim at.
531
+ // The rules cannot count how OFTEN somebody writes — that is App Check, a
532
+ // setting in the Firebase console, and setup never opens the console — but
533
+ // they can say how BIG one entry may be, and a document of thousands of
534
+ // invented fields is the cheapest way to run up somebody else's bill.
535
+ // Sixty is far past any form an owner builds: an entry carries one key per
536
+ // question plus a handful about the sender, and a long intake form is
537
+ // twenty questions. An admin writes through the wildcard above and is
538
+ // never held to this.
539
+ function isFormSized() {
540
+ return request.resource.data.keys().size() < 60;
541
+ }
542
+
543
+ // ── Counting what visitors do ─────────────────────────────────────────
544
+ // How many people opened a product, how many put it in their basket: a
545
+ // count of presses made by people who are mostly signed OUT, which the
546
+ // team reads beside the thing itself. It is the second thing the whole
547
+ // internet may write, so it is held tighter than a form entry: a tally is
548
+ // a handful of named counters and nothing else, a new one starts every
549
+ // counter it carries at exactly 1, and a change moves each counter it
550
+ // touches up by exactly 1 — so nobody can write a word into one, set a
551
+ // number, take one back down or jump it by a thousand. As with a form
552
+ // entry, the rules cannot say how OFTEN (that is App Check), so a count is
553
+ // something to steer by and never something to bill by.
554
+ function tallyStartsAtOne(k) {
555
+ return request.resource.data.get(k, 1) == 1;
556
+ }
557
+ function tallyUpByOne(k) {
558
+ return request.resource.data.get(k, 0) == resource.data.get(k, 0)
559
+ || request.resource.data.get(k, 0) == resource.data.get(k, 0) + 1;
560
+ }
561
+
562
+ // Admins are trusted with everything in this database — that is what the
563
+ // role is. There used to be a per-document owner test bolted onto update
564
+ // and delete for admin_ collections, but the collection rule below grants
565
+ // admins read+write on admin_ outright and rules OR together, so it never
566
+ // denied anything. It has been removed rather than left to read as a
567
+ // protection that is not there. If you ever want one admin's records to be
568
+ // off limits to another, it has to be built in both places at once: here
569
+ // AND in the admin_ collection rule below.
570
+ match /{document=**} {
571
+ allow read, write: if isAdmin();
572
+ }
573
+
574
+ // ── Profiles ──────────────────────────────────────────────────────────
575
+ // Unverified people reach this one document on purpose: registration seeds
576
+ // it before the email goes out, and /verify needs to read it back. What
577
+ // they cannot do is touch role or inactive.
578
+ // Reading stays open to the owner even when deactivated — the app has to
579
+ // be able to see the flag to show the Access Denied screen — but writing
580
+ // does not: a deactivated account changes nothing about itself.
581
+ // The team reads every profile: a page for the team offers a person to
582
+ // assign a card to, to book, to hand a lead to, and the people it offers
583
+ // are this collection. Free users and guests never see it — a page for
584
+ // signed-in users renders the name saved on the record instead. Writing
585
+ // somebody else's profile stays the admin's, through the wildcard above.
586
+ match /user_profiles/{uid} {
587
+ allow get: if isAuth() && request.auth.uid == uid;
588
+ allow read: if isInternal();
589
+ allow read: if hasProfile() && seesRole(resource.data.role);
590
+ // Whoever an admin let open the Users tab reads the people on it.
591
+ allow read: if opensTab('users');
592
+ allow create: if isAuth() && request.auth.uid == uid && grantsUntouched();
593
+ allow update: if isAuth() && request.auth.uid == uid && notDeactivated() &&
594
+ (grantsUntouched() || isSeedingOwnAdmin() ||
595
+ isTakingPlanRole() || isLeavingPlanRole());
596
+ // Somebody an admin deleted, signed in again since: a new profile over
597
+ // the tombstone (isStartingAfresh).
598
+ allow update: if isAuth() && request.auth.uid == uid && isStartingAfresh();
599
+ }
600
+
601
+ // ── Chat ──────────────────────────────────────────────────────────────
602
+ // See the functions above for what the three kinds are and why notes are
603
+ // not in here. Three things about this block are worth saying plainly,
604
+ // because the app is built on them:
605
+ //
606
+ // A LIST HAS TO BE PINNED. Firestore proves a query against its own
607
+ // filters, never against the documents it would return, so
608
+ // where('participants', 'array-contains', me) is what proves \`inThread\`
609
+ // and where('kind', '==', 'support') is what proves \`teamOnSupport\`. A
610
+ // plain read of the whole collection is refused for everybody but an
611
+ // admin. Every query the app makes lives in src/lib/chat.ts.
612
+ //
613
+ // THE THREAD EXISTS BEFORE ITS FIRST MESSAGE. The message rule reads the
614
+ // thread with get(), which sees the database as it stood BEFORE the
615
+ // write — so a batch that makes the thread and its first message together
616
+ // is refused. sendMessage() makes the thread first and batches the
617
+ // message with the thread's preview after it: two round trips on the very
618
+ // first message of a conversation, one on every message after.
619
+ //
620
+ // A MEMBER CANNOT FORGE, WIDEN OR MOVE A CONVERSATION. Their support
621
+ // thread has to be at their own uid with themselves as the only
622
+ // participant; opening a direct or a group conversation is your team's;
623
+ // every message has to name its sender; and an update from outside the
624
+ // team may touch the preview and the read marks and nothing else.
625
+ match /chat_threads/{threadId} {
626
+ allow get: if resource == null ? mayOpenMissingThread(threadId)
627
+ : mayReadThread(resource.data);
628
+ allow list: if mayReadThread(resource.data);
629
+
630
+ allow create: if threadShape(request.resource.data) && (
631
+ // One support thread per member, at the member's own uid —
632
+ // started by them, or by whoever on your team writes first.
633
+ (request.resource.data.kind == 'support'
634
+ && request.resource.data.participants == [threadId]
635
+ && ((isVerified() && request.auth.uid == threadId) || isInternal()))
636
+ // A direct or a group conversation is your team's to open, and
637
+ // they are in it. Two members never talk to each other here:
638
+ // they cannot see each other anywhere else in the app, and chat
639
+ // does not change that.
640
+ || (request.resource.data.kind != 'support' && isInternal()
641
+ && request.auth.uid in request.resource.data.participants));
642
+
643
+ allow update: if threadShape(request.resource.data) && (
644
+ (isInternal() && mayReadThread(resource.data))
645
+ || (inThread(resource.data) && memberThreadKeys()));
646
+
647
+ // Only the colleague who made a group can take it away, and only a
648
+ // group: a support thread is a person's whole history with you, and a
649
+ // direct conversation is two people's.
650
+ allow delete: if isInternal() && resource.data.kind == 'group'
651
+ && resource.data.createdBy == request.auth.uid;
652
+
653
+ match /messages/{messageId} {
654
+ allow read: if mayReadThread(chatThread(threadId));
655
+ allow create: if mayReadThread(chatThread(threadId))
656
+ && request.resource.data.uid == request.auth.uid
657
+ && request.resource.data.keys().hasOnly(['uid', 'senderName', 'senderPhoto',
658
+ 'text', 'file', 'createdAt'])
659
+ && request.resource.data.text is string
660
+ && request.resource.data.text.size() <= 4000;
661
+ // Taking back what you said. Editing is not offered at all — a
662
+ // message somebody has already read should not be able to change
663
+ // under them.
664
+ allow delete: if isVerified() && resource.data.uid == request.auth.uid;
665
+ }
666
+ }
667
+
668
+ match /{collectionName}/{docId} {
669
+
670
+ allow read, write: if collectionName.matches('^admin_.*') && isAdmin();
671
+
672
+ // sys_ holds theme, page and form config. It is read openly because the
673
+ // login screen has to paint itself before anyone has signed in.
674
+ allow read: if collectionName.matches('^sys_.*');
675
+ allow write: if collectionName.matches('^sys_.*') && isAdmin();
676
+
677
+ // ── A team member's own schedule ──────────────────────────────────────
678
+ // sys_schedules is the one sys_ collection the whole team writes, and
679
+ // each of them writes exactly one row of it: their own. \`memberId\` is
680
+ // whose time the schedule offers, so requiring it to equal the caller's
681
+ // own uid — on the document as it stands AND on the document as it is
682
+ // being written — means a team member can set their hours, their length,
683
+ // their questions and their link without an admin, and cannot touch a
684
+ // colleague's or hand their schedule to somebody else. Everything else
685
+ // under sys_ (the theme, the pages, the forms, the payments) stays the
686
+ // admin's through the line above; that is why this names the collection
687
+ // rather than matching a prefix.
688
+ allow create: if collectionName == 'sys_schedules' && isInternal()
689
+ && request.resource.data.memberId == request.auth.uid;
690
+ allow update: if collectionName == 'sys_schedules' && isInternal()
691
+ && resource.data.memberId == request.auth.uid
692
+ && request.resource.data.memberId == request.auth.uid;
693
+ allow delete: if collectionName == 'sys_schedules' && isInternal()
694
+ && resource.data.memberId == request.auth.uid;
695
+
696
+ // ── A team member's own booking page ─────────────────────────────────
697
+ // A booking page is one address with several of somebody's meetings on
698
+ // it — the link that goes in a signature. It is made by whoever it is
699
+ // for, so it is written the same way their schedule is: the document
700
+ // says whose it is in \`memberId\`, and a team member may write the rows
701
+ // carrying their own uid and no others. A page made before pages had an
702
+ // owner carries none, so it stays the admins' through the sys_ line
703
+ // above, which is where it already was. Renaming a page is a write at
704
+ // the new address and a delete at the old, so both are needed for the
705
+ // owner to move their own link.
706
+ allow create: if collectionName == 'sys_bookingpages' && isInternal()
707
+ && request.resource.data.memberId == request.auth.uid;
708
+ allow update: if collectionName == 'sys_bookingpages' && isInternal()
709
+ && resource.data.memberId == request.auth.uid
710
+ && request.resource.data.memberId == request.auth.uid;
711
+ allow delete: if collectionName == 'sys_bookingpages' && isInternal()
712
+ && resource.data.memberId == request.auth.uid;
713
+
714
+ // A page kept for one of your own roles. Admins reach it through the
715
+ // wildcard above, the way they reach everything else.
716
+ // Update and delete are two lines rather than one \`write\`, so the
717
+ // delete lock (mayDelete, above) can hold on a page kept for a role.
718
+ allow read, create: if isRoleData(collectionName) && hasRoleFor(collectionName);
719
+ allow update: if isRoleData(collectionName) && hasRoleFor(collectionName) && keepsLock();
720
+ allow delete: if isRoleData(collectionName) && hasRoleFor(collectionName) && mayDelete();
721
+
722
+ // ── An entry that was sent to you ────────────────────────────────────
723
+ // Reading is proved against the record's own \`access\` list. A LIST has
724
+ // to name a key it is looking for — where('access', 'array-contains',
725
+ // 'uid:me') — because Firestore proves a query against its filters and
726
+ // not against the documents it would return; a bare read of the whole
727
+ // collection is refused for everybody but an admin, which is exactly
728
+ // what stops one client fetching every other client's card.
729
+ allow get, list: if isCaseData(collectionName) && listed(resource.data.access);
730
+
731
+ // Adding one is open to any verified person, as sending a form entry
732
+ // is — and the two checks are what keep it honest. The record names its
733
+ // author, so an entry cannot be forged as somebody else's; and the
734
+ // author's own key is on \`access\`, so nobody writes something they then
735
+ // cannot read.
736
+ allow create: if isCaseData(collectionName) && isVerified()
737
+ && request.resource.data.uid == request.auth.uid
738
+ && caseKey() in request.resource.data.access
739
+ && caseListsSane(request.resource.data);
740
+
741
+ // Changing one, deleting one and SENDING IT ON are the same permission,
742
+ // because sending it on is a change to the lists. Being on \`access\`
743
+ // alone is therefore read-only, enforced here rather than drawn: a
744
+ // client looking at their own card, a team member seeing a buyer's name.
745
+ // What this cannot do is tell two editors apart — which of them may add
746
+ // an invoice is a button, not a wall.
747
+ // ...and where the row says only admins delete, an editor may not
748
+ // (mayDelete), nor may they touch the lock that says so.
749
+ allow update: if isCaseData(collectionName) && named(resource.data.editors)
750
+ && caseListsSane(request.resource.data) && keepsLock();
751
+ allow delete: if isCaseData(collectionName) && named(resource.data.editors) && mayDelete();
752
+
753
+ // ── Telling somebody outside the team something ──────────────────────
754
+ // The bell's own records live in mem_, which only the team reads, so a
755
+ // notification could only ever be sent TO the team or to the admins.
756
+ // An entry that travels needs more: the dev lead has to hear that a
757
+ // ticket reached them, a client that their invoice is up. These are the
758
+ // same notification with an \`access\` list instead of a \`toRole\`, and
759
+ // they are read back through the case_ lines above.
760
+ //
761
+ // Unlike an ordinary case_ record, the sender is NOT required to be on
762
+ // the list — the whole point is to tell somebody else — so the list is
763
+ // held short, and creating is still all this opens: the sender cannot
764
+ // read, edit or delete a bell they wrote into.
765
+ // 'everyone' is refused here as well, and for a different reason than on
766
+ // a listing: a bell sent to everybody signed in is a broadcast, and this
767
+ // create is open to any verified person. Somebody may publish a listing
768
+ // for the whole app to find; nobody may ring the whole app's bell.
769
+ allow create: if collectionName == 'case_notifications' && isVerified()
770
+ && request.resource.data.uid == request.auth.uid
771
+ && !('everyone' in request.resource.data.access)
772
+ && request.resource.data.access.size() <= 5
773
+ && request.resource.data.keys().hasOnly(['access', 'editors', 'title', 'body', 'link', 'uid', 'fromName', 'createdAt', 'read']);
774
+
775
+ allow get: if isUserData(collectionName) && ownsUserDoc(docId);
776
+ allow list: if isUserData(collectionName) && isDocOwner();
777
+ // Somebody's own private records — and on a page whose row says only
778
+ // admins delete, not even their own (mayDelete).
779
+ allow create: if isUserData(collectionName) && ownsUserDoc(docId);
780
+ allow update: if isUserData(collectionName) && ownsUserDoc(docId) && keepsLock();
781
+ allow delete: if isUserData(collectionName) && ownsUserDoc(docId) && mayDelete();
782
+
783
+ // Public forms post here from pages a guest can open, so creating stays
784
+ // open to everyone — held to the size of a form, and nothing else is:
785
+ // editing and deleting are the admin's, via the wildcard above. Without
786
+ // that split anyone could quietly overwrite or wipe every submission you
787
+ // have received.
788
+ allow read: if collectionName == 'user_submissions' && (isDocOwner() || isAdmin());
789
+ allow create: if collectionName == 'user_submissions' && isFormSized();
790
+
791
+ // Whoever an admin let open the Inbox reads what came in — both
792
+ // collections, because the Inbox is both. Reading only: archiving and
793
+ // deleting stay the admin's.
794
+ allow read: if collectionName in ['user_submissions', 'user_requests'] && opensTab('inbox');
795
+
796
+ // Whoever an admin let open Transactions reads the entries that are a
797
+ // payment's and no others. A list has to ask for exactly those —
798
+ // where('transaction', '==', true) — or it is refused, so the Inbox's
799
+ // messages never reach somebody who was only given the orders.
800
+ allow read: if collectionName in ['user_submissions', 'user_requests']
801
+ && resource.data.transaction == true
802
+ && opensTab('transactions');
803
+
804
+ // A team page is the team's. Every internal member reads, adds, edits
805
+ // and deletes every record on it — moving a card a colleague made, or
806
+ // one an admin imported from the Inbox, is the ordinary work of a shared
807
+ // board. Until 1.14.6 update and delete were the author's alone, so a
808
+ // team member who dragged somebody else's quote to the next stage was
809
+ // refused with "Missing or insufficient permissions".
810
+ //
811
+ // Update and delete are two lines since 1.14.66: rules OR together, so
812
+ // a delete lock could not be added on top of \`update, delete\` — the old
813
+ // line would have gone on allowing the delete. The lock is the row's
814
+ // WHO MAY DELETE switch, stamped on each record (mayDelete, above).
815
+ allow read, create: if collectionName.matches('^mem_.*') && isInternal();
816
+ allow update: if collectionName.matches('^mem_.*') && isInternal() && keepsLock();
817
+ allow delete: if collectionName.matches('^mem_.*') && isInternal() && mayDelete();
818
+
819
+ // ── Telling the team something from outside the team ──────────────────
820
+ // notify() writes the bell's notifications into mem_, so until this line
821
+ // only the team could send one. On a page kept for a role the owner
822
+ // invented — a client portal — the client IS the person with something
823
+ // to say, and their notify() was refused with "Missing or insufficient
824
+ // permissions" and swallowed into a console warning: the team was never
825
+ // told, and nothing on screen said why.
826
+ //
827
+ // Creating is the whole of what this opens. The key list has no \`toUid\`
828
+ // in it, so this can only ever address a whole audience and never one
829
+ // named person; \`uid\` has to be the sender's own, so a notification
830
+ // cannot be forged as somebody else; and reading, editing and deleting
831
+ // stay the team's through the two lines above — whoever sends one
832
+ // cannot read the bell they sent it to.
833
+ allow create: if collectionName == 'mem_notifications' && isVerified()
834
+ && request.resource.data.uid == request.auth.uid
835
+ && request.resource.data.toRole in ['admin', 'internal']
836
+ && request.resource.data.keys().hasOnly(['toRole', 'title', 'body', 'link', 'uid', 'fromName', 'createdAt']);
837
+
838
+ // ── A tally: counting what visitors do ───────────────────────────────
839
+ // mem_tallies holds one document per counted thing — \`products_<id>\` —
840
+ // and nothing about who pressed. The team reads, resets and deletes them
841
+ // through the mem_ lines above; this is what lets everybody else, guests
842
+ // included, add one to a counter and do nothing more. The id has to be
843
+ // the thing and what it is a count of, so there is one tally per thing
844
+ // and nobody can start a second under a name of their own. It is kept in
845
+ // mem_ rather than pub_ on purpose: a pub_ collection is read by the
846
+ // whole internet, and how well something sells is the owner's business.
847
+ allow create: if collectionName == 'mem_tallies'
848
+ && request.resource.data.keys().hasOnly(['thing', 'of', 'views', 'added', 'clicks', 'saves', 'shares', 'downloads'])
849
+ && request.resource.data.thing is string
850
+ && request.resource.data.thing.matches('[a-z0-9_-]{1,40}')
851
+ && request.resource.data.of is string
852
+ && request.resource.data.of.size() <= 120
853
+ && docId == request.resource.data.thing + '_' + request.resource.data.of
854
+ && tallyStartsAtOne('views') && tallyStartsAtOne('added')
855
+ && tallyStartsAtOne('clicks') && tallyStartsAtOne('saves')
856
+ && tallyStartsAtOne('shares') && tallyStartsAtOne('downloads');
857
+ allow update: if collectionName == 'mem_tallies'
858
+ && request.resource.data.diff(resource.data).affectedKeys()
859
+ .hasOnly(['views', 'added', 'clicks', 'saves', 'shares', 'downloads'])
860
+ && tallyUpByOne('views') && tallyUpByOne('added')
861
+ && tallyUpByOne('clicks') && tallyUpByOne('saves')
862
+ && tallyUpByOne('shares') && tallyUpByOne('downloads');
863
+
864
+ // ── Booked times (the Schedule screen) ────────────────────────────────
865
+ // pub_bookings holds one document per TAKEN time and nothing about the
866
+ // person — the team member, the start, the end. Its id is the time
867
+ // itself, so a second booking of the same slot cannot be created: that
868
+ // is what stops a double booking. Anyone may take a time, guests
869
+ // included, exactly as anyone may send a public form — and the key list
870
+ // keeps a name or an email from ever landing in a public document.
871
+ allow create: if collectionName == 'pub_bookings'
872
+ && request.resource.data.keys().hasOnly(['memberId', 'start', 'end', 'createdAt']);
873
+ // mem_bookings is who booked and what they answered — the team's copy,
874
+ // written in the same breath as the form entry. Anyone may create one;
875
+ // the team reads and cancels through the mem_ lines above; the person
876
+ // who booked, when they were signed in, may read their own.
877
+ // \`pending\` has to be here AND has to be true: a booking waits for the
878
+ // person it was booked with to accept it, and a booking that arrived
879
+ // already accepted would be one nobody agreed to. A document with no
880
+ // \`pending\` key at all is one written before acceptance existed, and
881
+ // the app reads those as accepted — which is why a NEW one may not
882
+ // leave the key out to get the same effect.
883
+ allow create: if collectionName == 'mem_bookings'
884
+ && request.resource.data.get('pending', false) == true
885
+ && request.resource.data.keys().hasOnly(['memberId', 'memberName', 'scheduleId', 'scheduleName', 'paymentId', 'paymentSession', 'start', 'end', 'timeZone', 'name', 'email', 'answers', 'formId', 'entryId', 'uid', 'createdAt', 'pending']);
886
+ // Read by the person it belongs to, and belonging is decided TWICE. By
887
+ // uid, for a booking taken while signed in. And by ADDRESS, for one
888
+ // taken as a guest: the booking carries the email it was made with,
889
+ // folded to lower case, and a verified account with that same address is
890
+ // the same person — so somebody who booked before they ever registered
891
+ // still finds every one of their bookings the day they sign up, free
892
+ // account and all. The team reads them all through the mem_ lines above.
893
+ allow read: if collectionName == 'mem_bookings'
894
+ && (isDocOwner()
895
+ || (isVerified()
896
+ && request.auth.token.email != null
897
+ && resource.data.email == request.auth.token.email.lower()));
898
+
899
+ // ── Calling off your own booking ──────────────────────────────────────
900
+ // The person who booked may cancel it, from /my-bookings, without being
901
+ // on the team — that is the whole of what they may write here. Belonging
902
+ // is decided the same two ways the read above decides it: by uid for a
903
+ // booking made while signed in, by address for one made as a guest.
904
+ //
905
+ // The key list is the fence. Cancelling may set only the five fields
906
+ // that say a booking was called off, and \`cancelled\` must come out TRUE,
907
+ // so this can never be used to un-cancel a meeting, move it, rewrite the
908
+ // answers or change whose diary it is in. Everything else about the
909
+ // document stays the team's, through the mem_ lines above.
910
+ allow update: if collectionName == 'mem_bookings'
911
+ && (isDocOwner()
912
+ || (isVerified()
913
+ && request.auth.token.email != null
914
+ && resource.data.email == request.auth.token.email.lower()))
915
+ && request.resource.data.cancelled == true
916
+ && request.resource.data.diff(resource.data).affectedKeys()
917
+ .hasOnly(['cancelled', 'cancelledAt', 'cancelReason', 'cancelledBy', 'cancelledSide']);
918
+
919
+ // Public pages are readable by guests — that is what makes them public —
920
+ // but writing is not anonymous. Left open, anyone on the internet could
921
+ // empty every public collection you have. Guest input goes through
922
+ // user_submissions instead.
923
+ allow read: if collectionName.matches('^pub_.*');
924
+ allow write: if collectionName.matches('^pub_.*') && isVerified();
925
+
926
+ match /{allPaths=**} {
927
+ allow read: if (collectionName.matches('^admin_.*') && isAdmin())
928
+ || collectionName.matches('^sys_.*')
929
+ || collectionName.matches('^pub_.*')
930
+ || (collectionName.matches('^mem_.*') && isInternal())
931
+ || (isRoleData(collectionName) && hasRoleFor(collectionName))
932
+ || (collectionName.matches('^user_.*') && isDocOwner());
933
+
934
+ allow create: if (collectionName.matches('^admin_.*') && isAdmin())
935
+ || (collectionName.matches('^sys_.*') && isAdmin())
936
+ || (isUserData(collectionName) && isDocOwner())
937
+ || (collectionName == 'user_submissions' && isFormSized())
938
+ || (collectionName.matches('^pub_.*') && isVerified())
939
+ || (collectionName.matches('^mem_.*') && isInternal())
940
+ || (isRoleData(collectionName) && hasRoleFor(collectionName));
941
+
942
+ // Split the same way as the top-level lines, for the same reason: a
943
+ // document nested under a locked record's collection is locked too.
944
+ allow update: if (collectionName.matches('^pub_.*') && isVerified())
945
+ || (collectionName.matches('^mem_.*') && isInternal() && keepsLock())
946
+ || (isRoleData(collectionName) && hasRoleFor(collectionName) && keepsLock());
947
+
948
+ allow delete: if (collectionName.matches('^pub_.*') && isVerified())
949
+ || (collectionName.matches('^mem_.*') && isInternal() && mayDelete())
950
+ || (isRoleData(collectionName) && hasRoleFor(collectionName) && mayDelete());
951
+ }
952
+ }
953
+ }
954
+ }
955
+ `,m=`rules_version = '2';
956
+ service firebase.storage {
957
+ match /b/{bucket}/o {
958
+
959
+ // --- Helper Functions ---
960
+ function isAuth() {
961
+ return request.auth != null;
962
+ }
963
+
964
+ function hasProfile(uid) {
965
+ return firestore.exists(/databases/(default)/documents/user_profiles/$(uid));
966
+ }
967
+
968
+ function profileRole(uid) {
969
+ return firestore.get(/databases/(default)/documents/user_profiles/$(uid)).data.role;
970
+ }
971
+
972
+ // Matches the Firestore rules: an admin who switches someone off under
973
+ // /users expects the files to close too. Until this was checked, only the
974
+ // app's own Access Denied screen stood in the way, and a deactivated person
975
+ // driving the SDK from a console kept every file they had. An account with
976
+ // no profile document yet counts as active — registration seeds the profile
977
+ // a moment after the account exists.
978
+ function notDeactivated() {
979
+ return isAuth() &&
980
+ (!hasProfile(request.auth.uid) ||
981
+ firestore.get(/databases/(default)/documents/user_profiles/$(request.auth.uid))
982
+ .data.get('inactive', false) != true);
983
+ }
984
+
985
+ // Matches the Firestore rules: a person who never opened the verification
986
+ // email has a signed-in token but no verified one, and nothing here opens
987
+ // for them. Google sign-ins arrive verified, and a phone sign-in has no
988
+ // address to verify at all — the texted code is that check, so the
989
+ // provider is accepted here exactly as it is in the Firestore rules.
990
+ function isVerified() {
991
+ return isAuth() &&
992
+ (request.auth.token.email_verified == true || hasNoEmailToVerify()) &&
993
+ notDeactivated();
994
+ }
995
+
996
+ // A sign-in that carries no email address: phone, and only phone.
997
+ function hasNoEmailToVerify() {
998
+ return request.auth.token.firebase.sign_in_provider == 'phone';
999
+ }
1000
+
1001
+ function isMemberDocAdmin(uid) {
1002
+ return hasProfile(uid) && profileRole(uid) == 'admin';
1003
+ }
1004
+
1005
+ function isAdmin() {
1006
+ return isVerified() && isMemberDocAdmin(request.auth.uid);
1007
+ }
1008
+
1009
+ // Files in mem_ folders belong to internal pages and to Drive's internal
1010
+ // folder. Signing up does not open them — an admin marks the account
1011
+ // internal under /users first.
1012
+ function isInternal() {
1013
+ return isVerified() && hasProfile(request.auth.uid) &&
1014
+ profileRole(request.auth.uid) in ['internal', 'admin'];
1015
+ }
1016
+
1017
+ // Files belonging to a page kept for a role the owner invented. The folder
1018
+ // is named after the role exactly as its collections are —
1019
+ // role_client_files — and the role is read straight out of the name, so a
1020
+ // role invented tomorrow works without these rules being pasted again.
1021
+ // Safe only because a role's id is squeezed to lowercase letters and
1022
+ // digits before it is stored (roleIdFrom, src/lib/access.ts): it can never
1023
+ // hold the underscore this splits on, nor be one of the words below. A
1024
+ // folder that fails either test matches nothing here and is the admins'.
1025
+ function folderRole(folder) {
1026
+ return folder.split('_')[1];
1027
+ }
1028
+
1029
+ function isRoleFolder(folder) {
1030
+ return folder.matches('^role_[a-z][a-z0-9]*_.*') &&
1031
+ !(folderRole(folder) in
1032
+ ['user', 'users', 'free', 'guest', 'guests', 'internal', 'admin',
1033
+ 'admins', 'public', 'owner', 'team', 'member', 'members', 'none',
1034
+ 'role', 'roles']);
1035
+ }
1036
+
1037
+ function hasFolderRole(folder) {
1038
+ return isVerified() && hasProfile(request.auth.uid) &&
1039
+ profileRole(request.auth.uid) == folderRole(folder) &&
1040
+ planStillPaid();
1041
+ }
1042
+
1043
+ // A role a paid plan gave lasts until the date on its pass (see
1044
+ // isTakingPlanRole in firestore.rules) — here too, so the files close
1045
+ // with the records. A role given by hand has no date.
1046
+ function planStillPaid() {
1047
+ return firestore.get(/databases/(default)/documents/user_profiles/$(request.auth.uid))
1048
+ .data.get('planUntil', 0) == 0 ||
1049
+ request.time.toMillis() <
1050
+ firestore.get(/databases/(default)/documents/user_profiles/$(request.auth.uid)).data.planUntil;
1051
+ }
1052
+
1053
+ // ── Files on an entry that was sent to people ─────────────────────────
1054
+ // A file here belongs to ONE record — its path carries the record's
1055
+ // collection and its id — so the record is what says who may open it,
1056
+ // exactly as the record says who may read the record. The two lists on it
1057
+ // are read the same way firestore.rules reads them: 'uid:<me>' for the
1058
+ // person, 'role:<my role>' for everybody marked with it.
1059
+ function caseRecordExists(coll, recordId) {
1060
+ return firestore.exists(/databases/(default)/documents/$(coll)/$(recordId));
1061
+ }
1062
+
1063
+ function caseRecord(coll, recordId) {
1064
+ return firestore.get(/databases/(default)/documents/$(coll)/$(recordId)).data;
1065
+ }
1066
+
1067
+ // Whoever may SEE the record. The same list, plus the one key that names
1068
+ // nobody: a record carrying 'everyone' on \`access\` has been published to
1069
+ // everybody signed in (firestore.rules), and its pictures are most of what
1070
+ // was published — a listing whose photographs answer 403 to everybody but
1071
+ // its author is not a listing.
1072
+ //
1073
+ // Kept apart from onCaseList rather than folded into it, because the two
1074
+ // lists are asked different questions: this one is read against \`access\`,
1075
+ // and onCaseList below stays what it was, so 'everyone' can never grant an
1076
+ // upload or a delete even on a record written before the rule that refuses
1077
+ // it on \`editors\`.
1078
+ function onCaseListOrPublished(keys) {
1079
+ return 'everyone' in keys || onCaseList(keys);
1080
+ }
1081
+
1082
+ function onCaseList(keys) {
1083
+ return ('uid:' + request.auth.uid) in keys ||
1084
+ // Whoever proves the address an entry was made with — a buyer who
1085
+ // ordered signed out (holdsAddress in firestore.rules).
1086
+ (request.auth.token.email != null &&
1087
+ request.auth.token.email_verified == true &&
1088
+ ('email:' + request.auth.token.email.lower()) in keys) ||
1089
+ (hasProfile(request.auth.uid) &&
1090
+ ('role:' + profileRole(request.auth.uid)) in keys &&
1091
+ planStillPaid());
1092
+ }
1093
+
1094
+ // Whoever put the file there. It is what lets an upload happen BEFORE the
1095
+ // record it belongs to is saved, and what lets the person who made that
1096
+ // upload take it down again if they change their mind.
1097
+ function isFileOwner() {
1098
+ return resource != null && hasOwnerMeta(resource) &&
1099
+ resource.metadata.ownerId == request.auth.uid;
1100
+ }
1101
+
1102
+ function hasOwnerMeta(reqRes) {
1103
+ return reqRes != null && 'metadata' in reqRes && reqRes.metadata != null && 'ownerId' in reqRes.metadata;
1104
+ }
1105
+
1106
+ // ── Files in a conversation ──────────────────────────────────────────
1107
+ // A file here belongs to ONE conversation — its path carries the
1108
+ // conversation's id — so the conversation is what says who may open it,
1109
+ // exactly as it says who may read the messages. Two Firestore reads per
1110
+ // upload and per delete (the caller's profile, the conversation); none
1111
+ // per view, because a download link carries its own token.
1112
+ function chatThreadData(threadId) {
1113
+ return firestore.get(/databases/(default)/documents/chat_threads/$(threadId)).data;
1114
+ }
1115
+
1116
+ // In the conversation by name, or on the team and it is a member's
1117
+ // support thread — the same two answers firestore.rules gives.
1118
+ function onChatThread(threadId) {
1119
+ return isVerified() &&
1120
+ (request.auth.uid in chatThreadData(threadId).participants ||
1121
+ (chatThreadData(threadId).kind == 'support' && isInternal()));
1122
+ }
1123
+
1124
+ // --- Upload limits ---
1125
+ // Nothing capped uploads before this: any verified account could push a
1126
+ // file of any size and any kind into user_ or mem_, and the bill is the
1127
+ // owner's. 10 MB covers the documents, photos and short clips these pages
1128
+ // are for. Admins are exempt on purpose — the Assets screen is where the
1129
+ // owner puts their own logo, fonts and anything else the app needs.
1130
+ function underSizeCap(bytes) {
1131
+ return request.resource != null && request.resource.size < bytes;
1132
+ }
1133
+
1134
+ // An allowlist rather than a blocklist, so a new dangerous type does not
1135
+ // arrive open. SVG is excluded from images because it can carry script and
1136
+ // Firebase serves it inline from the storage domain; the admin Assets path
1137
+ // can still take one.
1138
+ function allowedFileType() {
1139
+ return request.resource != null && request.resource.contentType != null && (
1140
+ (request.resource.contentType.matches('image/.*') &&
1141
+ !request.resource.contentType.matches('image/svg.*')) ||
1142
+ request.resource.contentType.matches('audio/.*') ||
1143
+ request.resource.contentType.matches('video/.*') ||
1144
+ request.resource.contentType in [
1145
+ 'text/plain', 'text/csv', 'text/markdown', 'text/calendar',
1146
+ 'application/pdf', 'application/json', 'application/rtf',
1147
+ 'application/zip', 'application/x-zip-compressed',
1148
+ 'application/msword',
1149
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1150
+ 'application/vnd.ms-excel',
1151
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1152
+ 'application/vnd.ms-powerpoint',
1153
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1154
+ 'application/vnd.oasis.opendocument.text',
1155
+ 'application/vnd.oasis.opendocument.spreadsheet',
1156
+ 'application/vnd.oasis.opendocument.presentation'
1157
+ ]
1158
+ );
1159
+ }
1160
+
1161
+ function isUploadableImage() {
1162
+ return request.resource != null &&
1163
+ request.resource.contentType != null &&
1164
+ request.resource.contentType.matches('image/.*') &&
1165
+ !request.resource.contentType.matches('image/svg.*');
1166
+ }
1167
+
1168
+ // --- Special Match: User Avatars ---
1169
+ // A picture, and a small one: the profile screen already refuses anything
1170
+ // over 2 MB, this is the same rule where it cannot be skipped.
1171
+ match /user_avatars/{uid}/{fileName} {
1172
+ allow read: if isVerified();
1173
+ allow create, update: if isVerified()
1174
+ && request.auth.uid == uid
1175
+ && isUploadableImage()
1176
+ && underSizeCap(5 * 1024 * 1024)
1177
+ && (hasOwnerMeta(request.resource) ? request.resource.metadata.ownerId == request.auth.uid : true);
1178
+ allow delete: if isVerified() && request.auth.uid == uid;
1179
+ }
1180
+
1181
+ // --- Special Match: files on a sent entry ---
1182
+ // The path is case_files/<collection>/<record id>/<file>, and that record
1183
+ // id is the whole point: it is what lets these rules ask the RECORD who
1184
+ // may open the file, rather than guessing from a folder name. One
1185
+ // Firestore read per upload and per delete; none per view, because a
1186
+ // download link carries its own token and never comes back through here.
1187
+ //
1188
+ // An upload lands BEFORE the record is saved — the add form mints the id
1189
+ // first, so the box inside it has somewhere to put things — which is why
1190
+ // creating is allowed while the record does not exist yet, exactly as a
1191
+ // form attachment may arrive before its entry. Once the record is there,
1192
+ // uploading is an editor's.
1193
+ match /case_files/{coll}/{recordId}/{fileName} {
1194
+ allow read: if isVerified() && coll.matches('^case_.*')
1195
+ && ((caseRecordExists(coll, recordId) && onCaseListOrPublished(caseRecord(coll, recordId).access))
1196
+ || isFileOwner());
1197
+
1198
+ allow create: if isVerified() && coll.matches('^case_.*')
1199
+ && underSizeCap(10 * 1024 * 1024)
1200
+ && allowedFileType()
1201
+ && hasOwnerMeta(request.resource)
1202
+ && request.resource.metadata.ownerId == request.auth.uid
1203
+ && (!caseRecordExists(coll, recordId)
1204
+ || onCaseList(caseRecord(coll, recordId).editors));
1205
+
1206
+ // Overwriting a file in place stays the uploader's, as it is everywhere
1207
+ // else — nothing in the app does it, and it would change what somebody
1208
+ // else's link already serves.
1209
+ allow update: if isVerified() && coll.matches('^case_.*')
1210
+ && underSizeCap(10 * 1024 * 1024)
1211
+ && allowedFileType()
1212
+ && isFileOwner();
1213
+
1214
+ // Deleting follows the record: whoever may delete the entry may take
1215
+ // its files with it, or the file would be stranded behind a record
1216
+ // already gone — out of everybody's reach and still on the bill.
1217
+ allow delete: if isVerified() && coll.matches('^case_.*')
1218
+ && ((caseRecordExists(coll, recordId) && onCaseList(caseRecord(coll, recordId).editors))
1219
+ || isFileOwner());
1220
+ }
1221
+
1222
+ // --- Special Match: files in a conversation ---
1223
+ // A group's photo is chat_files/<conversation>/icon/<name>; anything
1224
+ // somebody sends inside a message sits under the message's own id. There
1225
+ // is no upload before the conversation exists: unlike a sent entry, whose
1226
+ // id is minted before the add form opens, a conversation is made by its
1227
+ // first message and a file only ever follows one.
1228
+ match /chat_files/{threadId}/{allPaths=**} {
1229
+ allow read: if onChatThread(threadId);
1230
+
1231
+ allow create: if onChatThread(threadId)
1232
+ && underSizeCap(10 * 1024 * 1024)
1233
+ && allowedFileType()
1234
+ && hasOwnerMeta(request.resource)
1235
+ && request.resource.metadata.ownerId == request.auth.uid;
1236
+
1237
+ // Nothing in the app overwrites a file in place, and doing it here
1238
+ // would change what somebody else's link already serves — a message
1239
+ // already read would quietly become a different message.
1240
+ allow update: if false;
1241
+
1242
+ // Whoever put it there takes it down. Leaving a group's old photo
1243
+ // behind costs pennies; letting anybody in a conversation delete what
1244
+ // somebody else sent does not read as chat.
1245
+ allow delete: if isVerified() && resource != null && hasOwnerMeta(resource)
1246
+ && resource.metadata.ownerId == request.auth.uid;
1247
+ }
1248
+
1249
+ // --- Generic Folder Match ---
1250
+ match /{folder}/{allPaths=**} {
1251
+
1252
+ // Public folder access
1253
+ allow read: if folder.matches('^pub_.*');
1254
+
1255
+ // Internal folder access — internal members and admins only.
1256
+ // The folder test comes first on purpose: isInternal() reaches across to
1257
+ // Firestore, so every read of a pub_ file would otherwise pay for a
1258
+ // profile lookup it does not need. (Signed-in reads pay one either way
1259
+ // now — isVerified() has to look the deactivation flag up.)
1260
+ allow read: if folder.matches('^mem_.*') && isInternal();
1261
+ allow create: if folder.matches('^mem_.*')
1262
+ && isInternal()
1263
+ && underSizeCap(10 * 1024 * 1024)
1264
+ && allowedFileType()
1265
+ && hasOwnerMeta(request.resource)
1266
+ && request.resource.metadata.ownerId == request.auth.uid;
1267
+ allow update: if folder.matches('^mem_.*')
1268
+ && isInternal()
1269
+ && underSizeCap(10 * 1024 * 1024)
1270
+ && allowedFileType()
1271
+ && resource != null
1272
+ && hasOwnerMeta(resource)
1273
+ && resource.metadata.ownerId == request.auth.uid;
1274
+ // Deleting follows the record, not the uploader. Since 1.14.6 every team
1275
+ // member may delete every mem_ record, whoever added it, and the record
1276
+ // is the only place the file's link lives — so a delete that stopped at
1277
+ // the uploader left the file stranded behind a record already gone, out
1278
+ // of everybody's reach and still on the owner's bill. Overwriting a file
1279
+ // in place (update, above) stays the uploader's: nothing in the app does
1280
+ // it, and it would change what a colleague's link serves.
1281
+ allow delete: if folder.matches('^mem_.*')
1282
+ && isInternal();
1283
+
1284
+ // A page kept for one of your own roles — the same shape as mem_ above:
1285
+ // everybody marked with the role reads every file there and may delete
1286
+ // any of them, exactly as they may delete any record on the page; each
1287
+ // of them may overwrite only what they put up themselves.
1288
+ allow read: if isRoleFolder(folder) && hasFolderRole(folder);
1289
+ allow create: if isRoleFolder(folder)
1290
+ && hasFolderRole(folder)
1291
+ && underSizeCap(10 * 1024 * 1024)
1292
+ && allowedFileType()
1293
+ && hasOwnerMeta(request.resource)
1294
+ && request.resource.metadata.ownerId == request.auth.uid;
1295
+ allow update: if isRoleFolder(folder)
1296
+ && hasFolderRole(folder)
1297
+ && underSizeCap(10 * 1024 * 1024)
1298
+ && allowedFileType()
1299
+ && resource != null
1300
+ && hasOwnerMeta(resource)
1301
+ && resource.metadata.ownerId == request.auth.uid;
1302
+ allow delete: if isRoleFolder(folder)
1303
+ && hasFolderRole(folder);
1304
+
1305
+ // The team's own public pictures. A booking page carries a photo of the
1306
+ // person it is for, and since 1.14.18 that person makes the page
1307
+ // themselves — so the picture has to be theirs to put up. Same shape as
1308
+ // mem_ above: capped, type-filtered, stamped with who uploaded it, and
1309
+ // only that person (or an admin, below) may change or remove it. It is
1310
+ // the ONE public folder the team writes; everything else under pub_ is
1311
+ // still the admins' or the form attachments below.
1312
+ allow create: if folder == 'pub_files'
1313
+ && isInternal()
1314
+ && underSizeCap(10 * 1024 * 1024)
1315
+ && allowedFileType()
1316
+ && hasOwnerMeta(request.resource)
1317
+ && request.resource.metadata.ownerId == request.auth.uid;
1318
+ allow update: if folder == 'pub_files'
1319
+ && isInternal()
1320
+ && underSizeCap(10 * 1024 * 1024)
1321
+ && allowedFileType()
1322
+ && resource != null
1323
+ && hasOwnerMeta(resource)
1324
+ && resource.metadata.ownerId == request.auth.uid;
1325
+ allow delete: if folder == 'pub_files'
1326
+ && isInternal()
1327
+ && resource != null
1328
+ && hasOwnerMeta(resource)
1329
+ && resource.metadata.ownerId == request.auth.uid;
1330
+
1331
+ // Form attachments. A visitor answering a public form is not signed in,
1332
+ // so this one folder takes an anonymous create — capped and
1333
+ // type-filtered like every other non-admin write, and write-once: no
1334
+ // update, no delete, so a submitted file cannot be swapped after the
1335
+ // owner has read the entry. The pub_ prefix is what makes the file
1336
+ // readable back, which the download link in the Inbox entry needs.
1337
+ allow create: if folder == 'pub_form_uploads'
1338
+ && underSizeCap(10 * 1024 * 1024)
1339
+ && allowedFileType();
1340
+
1341
+ // User-specific folders. user_avatars starts with user_ too, and until
1342
+ // it was named here any verified account could drop a file at the path
1343
+ // where somebody else's picture goes; the avatars have their own match
1344
+ // above and answer to that alone.
1345
+ allow create: if isVerified()
1346
+ && folder.matches('^user_.*')
1347
+ && folder != 'user_avatars'
1348
+ && underSizeCap(10 * 1024 * 1024)
1349
+ && allowedFileType()
1350
+ && hasOwnerMeta(request.resource)
1351
+ && request.resource.metadata.ownerId == request.auth.uid;
1352
+ allow update: if isVerified()
1353
+ && folder.matches('^user_.*')
1354
+ && folder != 'user_avatars'
1355
+ && underSizeCap(10 * 1024 * 1024)
1356
+ && allowedFileType()
1357
+ && resource != null
1358
+ && hasOwnerMeta(resource)
1359
+ && resource.metadata.ownerId == request.auth.uid;
1360
+ allow read, delete: if isVerified()
1361
+ && folder.matches('^user_.*')
1362
+ && resource != null
1363
+ && hasOwnerMeta(resource)
1364
+ && resource.metadata.ownerId == request.auth.uid;
1365
+
1366
+ // Admin access overrides — the Assets screen holds the logo, the favicon
1367
+ // and anything else the owner needs the app to serve, so it is not capped
1368
+ // or filtered.
1369
+ allow read, create: if isAdmin();
1370
+ allow update, delete: if isAdmin();
1371
+ }
1372
+ }
1373
+ }
1374
+ `,h=[{key:`VITE_FIREBASE_API_KEY`,label:`API Key`,shownAs:`apiKey`},{key:`VITE_FIREBASE_AUTH_DOMAIN`,label:`Auth Domain`,shownAs:`authDomain`},{key:`VITE_FIREBASE_PROJECT_ID`,label:`Project ID`,shownAs:`projectId`},{key:`VITE_FIREBASE_STORAGE_BUCKET`,label:`Storage Bucket`,shownAs:`storageBucket`},{key:`VITE_FIREBASE_MESSAGING_SENDER_ID`,label:`Messaging Sender ID`,shownAs:`messagingSenderId`},{key:`VITE_FIREBASE_APP_ID`,label:`App ID`,shownAs:`appId`}];function g(e){let t;try{if(t={BASE_URL:`/`,DEV:!1,MODE:`production`,PROD:!0,SSR:!1,VITE_ADMIN_EMAILS:void 0,VITE_FIREBASE_API_KEY:void 0,VITE_FIREBASE_APP_ID:void 0,VITE_FIREBASE_AUTH_DOMAIN:void 0,VITE_FIREBASE_MESSAGING_SENDER_ID:void 0,VITE_FIREBASE_PROJECT_ID:void 0,VITE_FIREBASE_STORAGE_BUCKET:void 0}?.[e],t&&typeof t==`string`&&t.trim()!==``)return t}catch{}try{if(t=(typeof window<`u`?window:globalThis)?.process?.env?.[e],t&&typeof t==`string`&&t.trim()!==``)return t}catch{}try{switch(e){case`VITE_FIREBASE_API_KEY`:t=void 0;break;case`VITE_FIREBASE_AUTH_DOMAIN`:t=void 0;break;case`VITE_FIREBASE_PROJECT_ID`:t=void 0;break;case`VITE_FIREBASE_STORAGE_BUCKET`:t=void 0;break;case`VITE_FIREBASE_MESSAGING_SENDER_ID`:t=void 0;break;case`VITE_FIREBASE_APP_ID`:t=void 0;break}if(t&&typeof t==`string`&&t.trim()!==``)return t}catch{}return``}function _(e,t){let n=g(e);if(n)return n;if(t?.firebaseConfig){let n={VITE_FIREBASE_API_KEY:`apiKey`,VITE_FIREBASE_AUTH_DOMAIN:`authDomain`,VITE_FIREBASE_PROJECT_ID:`projectId`,VITE_FIREBASE_STORAGE_BUCKET:`storageBucket`,VITE_FIREBASE_MESSAGING_SENDER_ID:`messagingSenderId`,VITE_FIREBASE_APP_ID:`appId`}[e];if(n&&t.firebaseConfig[n])return t.firebaseConfig[n]}return``}function v(e){let t=[],n=[],r=t=>_(t,e);for(let{key:e,label:i}of h){let a=r(e);a?a.trim()===``&&n.push(i):t.push(i)}let i=[...t,...n];return{allSet:i.length===0,missing:i,empty:n}}function y(){return v().allSet}var b=m;function x(e){let t=e.map(e=>e.trim().toLowerCase()).filter(Boolean).filter((e,t,n)=>n.indexOf(e)===t),n=t.length?t.map(e=>`'${e.replace(/'/g,``)}'`).join(`, `):`'your@email.com'`;return p.replace(/(function adminEmails\(\) \{\s*return )\[[^\]]*\];/,`$1[${n}];`)}function S(e){return e.split(/[,\s]+/).map(e=>e.trim()).filter(Boolean)}var C=/^[^@\s]+@[^@\s]+\.[^@\s]+$/;function w({code:e,label:t,compact:i,onCopy:s}){let[c,l]=a.default.useState(!1);return(0,o.jsxs)(n.sr.button,{whileHover:{scale:1.02},whileTap:{scale:.98},onClick:()=>{navigator.clipboard?.writeText(e),s?.(),l(!0),setTimeout(()=>l(!1),2e3)},className:`inline-flex items-center font-bold transition-all shrink-0 cursor-pointer ${i?`gap-1.5 h-9 px-3 rounded-xl text-[12px] self-start sm:self-auto`:`gap-2 px-4 py-2 mt-2 rounded-xl text-[13px] tracking-wide`} ${c?`bg-emerald-500/10 text-emerald-500 border border-emerald-500/20`:`btn-secondary${i?``:` shadow-sm`}`}`,children:[c?(0,o.jsx)(r.sn,{className:i?`w-3.5 h-3.5`:`w-4 h-4`}):(0,o.jsx)(r.Jt,{className:i?`w-3.5 h-3.5`:`w-4 h-4`}),c?i?`Copied`:`Copied to Clipboard!`:t]})}function T(e){let t=``;try{t=new URL(e).hostname}catch{t=``}let n=t===`localhost`||t===`127.0.0.1`||t.endsWith(`.local`);return{host:t,local:n}}function E(){let{host:e,local:t}=T(typeof window>`u`?``:window.location.href);return e?(0,o.jsxs)(`div`,{className:`glass-panel border border-[var(--panel-border)] rounded-2xl bg-background flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-3 px-4 py-3 mt-3`,children:[(0,o.jsxs)(`div`,{className:`min-w-0`,children:[(0,o.jsx)(`div`,{className:`text-[13.5px] font-bold text-foreground break-all sm:break-normal sm:truncate`,children:e}),(0,o.jsx)(`div`,{className:`text-[12.5px] text-foreground/50 font-medium`,children:t?`this machine — Firebase trusts it already, nothing to add`:`your app's address, read from this page`})]}),!t&&(0,o.jsx)(w,{compact:!0,code:e,label:`Copy the address`})]}):null}function D({name:e,shownAs:t,hint:n}){let[i,s]=a.default.useState(!1);return(0,o.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-3 px-4 py-3 border-b border-[var(--panel-border)]/40 last:border-0`,children:[(0,o.jsxs)(`div`,{className:`min-w-0`,children:[(0,o.jsx)(`div`,{className:`text-[13.5px] font-bold text-foreground break-all sm:break-normal sm:truncate`,children:e}),(0,o.jsx)(`div`,{className:`text-[12.5px] text-foreground/50 font-medium`,children:n??(0,o.jsxs)(o.Fragment,{children:[`the value Firebase labels `,t]})})]}),(0,o.jsxs)(`button`,{type:`button`,onClick:()=>{navigator.clipboard?.writeText(e),s(!0),setTimeout(()=>s(!1),2e3)},className:`inline-flex items-center gap-1.5 h-9 px-3 rounded-xl text-[12px] font-bold shrink-0 self-start sm:self-auto transition-all cursor-pointer active:scale-[0.98] ${i?`bg-emerald-500/10 text-emerald-500 border border-emerald-500/20`:`btn-secondary`}`,children:[i?(0,o.jsx)(r.sn,{className:`w-3.5 h-3.5`}):(0,o.jsx)(r.Jt,{className:`w-3.5 h-3.5`}),i?`Copied`:`Copy name`]})]})}var O={VITE_FIREBASE_API_KEY:`AIzaSyD3k9xQeL7vR2mN8pT4wY6…`,VITE_FIREBASE_AUTH_DOMAIN:`your-project.firebaseapp.com`,VITE_FIREBASE_PROJECT_ID:`your-project`,VITE_FIREBASE_MESSAGING_SENDER_ID:`482915736204`,VITE_FIREBASE_APP_ID:`1:482915736204:web:9c1e7a4b2d8f6e3a`},k=h.map(({key:e})=>e===`VITE_FIREBASE_STORAGE_BUCKET`?{name:e,value:`leave empty`,empty:!0}:{name:e,value:O[e]});function A({name:e,value:t,empty:n}){return(0,o.jsxs)(`div`,{className:`px-3 sm:px-4 py-1.5 flex flex-col gap-1 sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.25rem] sm:gap-3 sm:items-center`,children:[(0,o.jsx)(`div`,{title:e,className:`min-w-0 sm:h-9 sm:px-3 flex items-center sm:rounded-xl sm:bg-foreground/[0.05] text-[12.5px] sm:text-[13px] font-semibold text-foreground/80`,children:(0,o.jsx)(`span`,{className:`break-all sm:truncate`,children:e})}),(0,o.jsxs)(`div`,{className:`flex items-center gap-2 sm:contents`,children:[(0,o.jsx)(`div`,{className:`flex-1 min-w-0 h-9 px-3 flex items-center rounded-xl border text-[12.5px] sm:text-[13px] font-medium ${n?`border-dashed border-accent/40 text-accent`:`border-[var(--panel-border)]/60 text-foreground/45`}`,children:(0,o.jsx)(`span`,{className:`truncate`,children:t})}),(0,o.jsx)(`span`,{"aria-hidden":`true`,className:`w-9 h-9 shrink-0 rounded-xl flex items-center justify-center bg-foreground/[0.05] text-foreground/35`,children:(0,o.jsx)(r.Vt,{className:`w-4 h-4`})})]})]})}function j(){return(0,o.jsxs)(`div`,{className:`glass-panel border border-[var(--panel-border)] rounded-2xl overflow-hidden bg-background`,children:[(0,o.jsx)(`div`,{className:`max-sm:hidden sm:grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2.25rem] gap-3 px-4 pt-3.5 pb-1`,children:[`Name`,`Value`].map(e=>(0,o.jsx)(`span`,{className:`px-3 text-[12.5px] font-semibold text-foreground/45`,children:e},e))}),(0,o.jsx)(`div`,{className:`py-2 sm:py-1.5`,children:k.map(e=>(0,o.jsx)(A,{...e},e.name))}),(0,o.jsx)(`div`,{className:`flex justify-end px-3 sm:px-4 py-3 border-t border-[var(--panel-border)]/40`,children:(0,o.jsx)(`span`,{"aria-hidden":`true`,className:`btn-secondary inline-flex items-center h-9 px-4 rounded-xl text-[12.5px] font-bold select-none`,children:`Apply`})})]})}function M(){let[e,t]=(0,a.useState)(n.mr),[i,s]=(0,a.useState)(``),[c,l]=(0,a.useState)([]),[u,d]=(0,a.useState)(``),f=e=>{t(e),n.pr(e),i&&(s(``),l([])),u&&d(``)},p=()=>{let t=S(e);if(t.length===0){d(`Type an email address first — it is what the rules will name.`);return}let n=t.filter(e=>!C.test(e));if(n.length>0){d(`That does not look like an email address: ${n.join(`, `)}`);return}d(``),l(t.map(e=>e.trim().toLowerCase())),s(x(t))};return(0,o.jsxs)(`div`,{className:`glass-panel border border-[var(--panel-border)] rounded-2xl p-5 mb-2 flex flex-col gap-4`,children:[(0,o.jsxs)(`div`,{className:`flex items-center gap-3.5 min-w-0`,children:[(0,o.jsx)(`div`,{className:`w-10 h-10 rounded-2xl flex items-center justify-center shrink-0 border border-[var(--panel-border)]/50 bg-accent/10 text-accent`,children:(0,o.jsx)(r.j,{className:`w-5 h-5`})}),(0,o.jsxs)(`div`,{className:`flex flex-col gap-0.5 min-w-0`,children:[(0,o.jsx)(`h3`,{className:`text-[15px] font-bold text-foreground tracking-tight leading-tight truncate`,children:`Who runs this app?`}),(0,o.jsx)(`p`,{className:`text-[12px] text-foreground/50 font-normal leading-snug truncate`,children:`The email you sign in with becomes the owner — separate several with commas.`})]})]}),(0,o.jsxs)(`div`,{className:`flex flex-col sm:flex-row sm:items-center gap-2`,children:[(0,o.jsx)(`input`,{type:`text`,value:e,"aria-label":`The email address you will sign in with`,placeholder:`you@yourcompany.com`,onChange:e=>f(e.target.value),onKeyDown:e=>{e.key===`Enter`&&(e.preventDefault(),p())},className:`w-full h-10 px-3.5 rounded-[var(--input-radius,0.75rem)] border border-[var(--panel-border)] bg-[var(--panel-bg)] text-[13.5px] text-foreground outline-none transition-all duration-300 placeholder:text-foreground/35 hover:border-accent/40 focus:border-accent console-field ${u?`is-invalid border-red-500/60`:``}`}),(0,o.jsx)(`button`,{type:`button`,onClick:p,className:`btn-primary inline-flex items-center justify-center h-10 px-4 rounded-[var(--btn-radius,0.75rem)] text-[12.5px] font-bold text-white shrink-0 cursor-pointer active:scale-[0.98] transition-transform whitespace-nowrap`,children:`Generate the rules`})]}),u&&(0,o.jsx)(`p`,{className:`text-[12px] font-semibold text-red-500 leading-snug px-1`,children:u}),i&&(0,o.jsxs)(`div`,{className:`rounded-2xl border border-emerald-500/20 bg-emerald-500/[0.05] px-3.5 py-3 flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3`,children:[(0,o.jsx)(`span`,{className:`w-7 h-7 rounded-full bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center shrink-0`,children:(0,o.jsx)(r.sn,{className:`w-3.5 h-3.5 text-emerald-500`})}),(0,o.jsxs)(`div`,{className:`min-w-0 flex-1 flex flex-col gap-0.5`,children:[(0,o.jsxs)(`p`,{"data-admin-emails":!0,className:`text-[13px] font-bold text-foreground leading-snug break-all`,children:[`Ready — they make `,c.join(`, `),` the owner.`]}),(0,o.jsx)(`p`,{className:`text-[12px] font-medium text-foreground/50 leading-snug`,children:`Paste them into Firebase, then sign in with that address — your admin tabs appear on their own within a minute or two.`})]}),(0,o.jsx)(w,{compact:!0,code:i,label:`Copy the rules`,onCopy:()=>n.fr(c)})]})]})}function N({text:e,label:t}){return(0,o.jsxs)(`div`,{className:`glass-panel border border-[var(--panel-border)] rounded-2xl bg-background overflow-hidden`,children:[(0,o.jsxs)(`div`,{className:`flex items-center justify-between gap-3 px-4 py-3 border-b border-[var(--panel-border)]/40`,children:[(0,o.jsx)(`span`,{className:`text-[12px] font-extrabold tracking-tight text-gradient truncate max-sm:hidden`,children:`Paste this into your assistant`}),(0,o.jsx)(w,{compact:!0,code:e,label:t})]}),(0,o.jsx)(`pre`,{className:`px-4 py-3 max-h-64 overflow-auto text-[11.5px] leading-relaxed font-mono text-foreground/70 whitespace-pre-wrap [overflow-wrap:anywhere]`,children:e})]})}function P({number:e,title:t,aside:n,children:r}){return(0,o.jsxs)(`div`,{className:`flex flex-col relative pb-10 mb-10 border-b border-[var(--panel-border)]/50 last:border-0 last:pb-0 last:mb-0`,children:[(0,o.jsxs)(`div`,{className:`flex items-center gap-3 mb-4`,children:[(0,o.jsx)(`span`,{className:`w-8 h-8 shrink-0 rounded-full bg-accent/10 border border-accent/20 text-accent flex items-center justify-center text-[14px] font-extrabold`,children:e}),(0,o.jsx)(`h3`,{className:`text-[17px] md:text-[19px] font-bold text-foreground tracking-tight flex-1 min-w-0`,children:t}),n]}),(0,o.jsx)(`div`,{className:`text-[14px] md:text-[15px] text-foreground/70 leading-relaxed space-y-4`,children:r})]})}function F({basename:e}){let[t,i]=(0,a.useState)(!1);return(0,o.jsxs)(`div`,{className:`mt-6 sm:mt-8 relative z-10 flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6 p-5 sm:p-6 rounded-3xl border border-accent/20 bg-accent/[0.06]`,children:[(0,o.jsxs)(`div`,{className:`min-w-0 flex-1`,children:[(0,o.jsx)(`h3`,{className:`text-[16px] sm:text-[17px] font-bold text-foreground tracking-tight`,children:`Worked through every step?`}),(0,o.jsx)(`p`,{className:`text-[13px] sm:text-[13.5px] text-foreground/60 font-medium leading-relaxed mt-1`,children:`Your app will start again and take you to sign in. Use the email address you typed into the rules — that is the one this app knows as its owner.`})]}),(0,o.jsx)(n.sr.button,{type:`button`,whileHover:t?void 0:{scale:1.02},whileTap:t?void 0:{scale:.98},onClick:()=>{if(t)return;i(!0),f();let n=String(e||``).replace(/\/+$/,``);window.location.assign(`${n}/login`)},disabled:t,"aria-busy":t,className:`btn-primary inline-flex items-center justify-center h-12 px-6 rounded-[var(--btn-radius,0.75rem)] text-[13.5px] font-bold text-white shrink-0 cursor-pointer whitespace-nowrap shadow-lg disabled:cursor-default`,children:(0,o.jsxs)(`span`,{className:`grid place-items-center`,children:[(0,o.jsxs)(`span`,{"aria-hidden":t,className:`col-start-1 row-start-1 inline-flex items-center gap-2 ${t?`invisible`:``}`,children:[`I've completed all the steps`,(0,o.jsx)(r.yn,{className:`w-4 h-4`})]}),(0,o.jsxs)(`span`,{"aria-hidden":!t,className:`col-start-1 row-start-1 inline-flex items-center gap-2 ${t?``:`invisible`}`,children:[`Starting your app`,(0,o.jsx)(r.ht,{className:`w-4 h-4 animate-spin`})]})]})})]})}function I({standalone:e}){let r=n.Na(),[s,c]=l(),d=s===`local`,f={project:d?2:1,webApp:d?3:2,keys:d?4:3,signIn:d?5:4,database:d?6:5,storage:d?7:6},p=T(typeof window>`u`?``:window.location.href).local,[m]=(0,a.useState)(()=>{let e=null;try{e=localStorage.getItem(`theme-mode`)}catch{}return e===`light`?!1:e===`dark`?!0:window.matchMedia(`(prefers-color-scheme: dark)`).matches});return(0,a.useEffect)(()=>{e&&document.documentElement.setAttribute(`data-theme`,m?`dark`:`light`)},[m,e]),(0,a.useEffect)(()=>{if(e&&t.f.faviconUrl){let e=document.querySelector(`link[rel*='icon']`);e||(e=document.createElement(`link`),e.rel=`icon`,document.head.appendChild(e)),e.href=t.f.faviconUrl}},[e]),(0,o.jsxs)(`main`,{className:`flex-1 w-full max-w-7xl mx-auto p-4 pt-2 md:p-8 md:pt-4 z-10 flex flex-col min-h-screen`,children:[(0,o.jsx)(n.sr.div,{initial:{opacity:0,y:20},animate:{opacity:1,y:0},className:`mb-6 flex items-center justify-between`,children:(0,o.jsxs)(`div`,{children:[(0,o.jsx)(t.t,{children:`Setup`}),(0,o.jsxs)(`div`,{className:`flex items-center gap-2 text-foreground/40 font-bold uppercase tracking-[0.2em] text-[11px]`,children:[(0,o.jsx)(`span`,{className:`w-8 h-[1px] bg-foreground/10`}),`/setup`]})]})}),(0,o.jsxs)(`div`,{className:`flex flex-col gap-6 flex-1 pb-16`,children:[(0,o.jsxs)(n.sr.div,{initial:{opacity:0,y:20},animate:{opacity:1,y:0},transition:{delay:.1},className:`glass-panel border border-[var(--panel-border)] rounded-3xl p-4 sm:p-6 md:p-10 shadow-2xl relative overflow-hidden bg-background flex flex-col fbos-tab-fill`,children:[(0,o.jsx)(`div`,{className:`mb-6 pb-4 sm:mb-8 sm:pb-6 border-b border-[var(--panel-border)]/50 relative z-10`,children:(0,o.jsxs)(`div`,{className:`min-w-0`,children:[(0,o.jsx)(`h2`,{className:`text-[17px] sm:text-xl font-bold text-foreground tracking-tight truncate`,children:`Connect your app to Firebase`}),(0,o.jsxs)(`p`,{className:`text-[13px] font-medium text-foreground/50 mt-1 sm:mt-1.5 leading-snug truncate`,children:[(0,o.jsx)(`span`,{className:`sm:hidden`,children:`Google's free data and sign-in service.`}),(0,o.jsx)(`span`,{className:`max-sm:hidden`,children:`Firebase is Google's free service that stores your data and signs people in — about ten minutes, once.`})]})]})}),!1,(0,o.jsxs)(`div`,{className:`flex flex-col relative z-10 p-4 sm:p-6 md:p-8 bg-foreground/[0.015] rounded-3xl border border-[var(--panel-border)]/50`,children:[d&&(0,o.jsxs)(P,{number:1,title:`Set your project up`,children:[(0,o.jsx)(`p`,{className:`text-foreground/80`,children:`Your assistant does this part — none of it is yours to type. Copy the block below into Claude Code, Codex or Antigravity and let it finish before you carry on. If you are already looking at this page on your own machine, paste it anyway: it only fills in what is missing and changes nothing that is already right.`}),(0,o.jsx)(N,{text:u,label:`Copy the setup prompt`})]}),(0,o.jsx)(P,{number:f.project,title:`Create a Firebase project`,children:(0,o.jsxs)(`ol`,{className:`space-y-2 list-decimal list-inside text-foreground/80`,children:[(0,o.jsxs)(`li`,{children:[`Open`,` `,(0,o.jsx)(`a`,{href:`https://console.firebase.google.com`,target:`_blank`,rel:`noopener noreferrer`,className:`text-accent underline underline-offset-2 font-semibold hover:text-accent-deep transition-colors`,children:`console.firebase.google.com`}),` `,`and click `,(0,o.jsx)(`strong`,{children:`Add project`}),`. Sign in with your Google account if it asks.`]}),(0,o.jsxs)(`li`,{children:[`Type any name you like and click `,(0,o.jsx)(`strong`,{children:`Continue`}),`.`]}),(0,o.jsxs)(`li`,{children:[`If it offers `,(0,o.jsx)(`strong`,{children:`Enable Gemini in Firebase`}),`, switch it off.`]}),(0,o.jsxs)(`li`,{children:[`Switch off `,(0,o.jsx)(`strong`,{children:`Enable Google Analytics for this project`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Create project`}),`, wait, then click `,(0,o.jsx)(`strong`,{children:`Continue`}),`.`]})]})}),(0,o.jsx)(P,{number:f.webApp,title:`Add a web app and copy the six values`,children:(0,o.jsxs)(`ol`,{className:`space-y-2 list-decimal list-inside text-foreground/80`,children:[(0,o.jsxs)(`li`,{children:[`On that page click `,(0,o.jsx)(`strong`,{children:`+ Add app`}),` under your project's name, then pick the `,(0,o.jsx)(`strong`,{children:`</> Web`}),` icon.`]}),(0,o.jsxs)(`li`,{children:[`Give it any nickname (`,(0,o.jsx)(`span`,{className:`text-accent`,children:`MyApp`}),` is fine). Leave `,(0,o.jsx)(`strong`,{children:`Firebase Hosting`}),` unticked.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Register app`}),`.`]}),(0,o.jsx)(`li`,{children:`Firebase now shows six values on screen, each with a label beside it — apiKey, authDomain, projectId, storageBucket, messagingSenderId and appId. You will copy them one at a time in the next step, so keep this tab open.`})]})}),(0,o.jsx)(P,{number:f.keys,title:d?`Put the six values in your .env`:`Paste the values into the environment variables window`,children:d?(0,o.jsxs)(o.Fragment,{children:[(0,o.jsxs)(`p`,{className:`text-foreground/80`,children:[`Step 1 made a `,(0,o.jsx)(`strong`,{children:`.env`}),` file, and every name below is already a line in it. Open it — or ask your assistant to open it for you — and put each value from your Firebase tab between that line's quotes. The names have to match letter for letter, so find them with the Copy name button rather than retyping.`]}),(0,o.jsxs)(`p`,{className:`text-foreground/80`,children:[`Six lines, and that is all of them. Fill five of them now and leave`,` `,(0,o.jsx)(`strong`,{children:`VITE_FIREBASE_STORAGE_BUCKET`}),` empty — step `,f.storage,` gives you its real value.`]}),(0,o.jsxs)(`p`,{className:`text-foreground/80`,children:[(0,o.jsx)(`strong`,{children:`Leave the app as it is for now.`}),` One value is still missing, and starting it again is the last thing you do on this whole page.`]}),(0,o.jsx)(`div`,{className:`glass-panel border border-[var(--panel-border)] rounded-2xl overflow-hidden bg-background`,children:h.map(({key:e,shownAs:t})=>(0,o.jsx)(D,{name:e,shownAs:t,hint:e===`VITE_FIREBASE_STORAGE_BUCKET`?`leave this one empty — step ${f.storage} gives you the real address`:void 0},e))})]}):(0,o.jsxs)(o.Fragment,{children:[(0,o.jsxs)(`p`,{className:`text-foreground/80`,children:[`AI Studio pops up a window that says`,` `,(0,o.jsx)(`strong`,{children:`Enter your environment variables to continue`}),`, with a form like the one below. Paste each value from your Firebase tab into its row — the values below are only examples of what each one looks like.`]}),(0,o.jsxs)(`p`,{className:`text-foreground/80`,children:[`Leave `,(0,o.jsx)(`strong`,{children:`VITE_FIREBASE_STORAGE_BUCKET`}),` empty and keep the window open — don't press `,(0,o.jsx)(`strong`,{children:`Apply`}),` yet. Step `,f.storage,` gives you the last value, and Apply comes after it.`]}),(0,o.jsx)(j,{})]})}),(0,o.jsxs)(P,{number:f.signIn,title:`Turn on sign-in`,children:[(0,o.jsxs)(`ol`,{className:`space-y-2 list-decimal list-inside text-foreground/80`,children:[(0,o.jsxs)(`li`,{children:[`In the Firebase menu on the left, open `,(0,o.jsx)(`strong`,{children:`Authentication`}),` and click `,(0,o.jsx)(`strong`,{children:`Get started`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Open the `,(0,o.jsx)(`strong`,{children:`Sign-in method`}),` tab.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Email/Password`}),`, switch on the first toggle, also switch on `,(0,o.jsx)(`strong`,{children:`Email link (passwordless sign-in)`}),`, and `,(0,o.jsx)(`strong`,{children:`Save`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Add provider`}),`, choose `,(0,o.jsx)(`strong`,{children:`Google`}),`, switch it on, pick your own address as the support email, and `,(0,o.jsx)(`strong`,{children:`Save`}),`. The app needs this for the `,(0,o.jsx)(`strong`,{children:`Sign in with Google`}),` button to work.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Add provider`}),`, choose `,(0,o.jsx)(`strong`,{children:`Anonymous`}),`, switch it on, and `,(0,o.jsx)(`strong`,{children:`Save`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Open the `,(0,o.jsx)(`strong`,{children:`Settings`}),` tab, then `,(0,o.jsx)(`strong`,{children:`Authorized domains`}),`. Your app has to be listed here or nobody can sign in.`]}),p?(0,o.jsxs)(`li`,{children:[(0,o.jsx)(`strong`,{children:`Nothing to add here yet.`}),` This app is being served from your own machine, and Firebase trusts that address from the start. Come back and add the real one the day you put your app on the internet.`]}):(0,o.jsxs)(`li`,{children:[(0,o.jsx)(`strong`,{children:`Your app's address is right here`}),` — copy it below, then in Firebase click `,(0,o.jsx)(`strong`,{children:`Add domain`}),`, paste it, and save.`]})]}),(0,o.jsx)(E,{})]}),(0,o.jsxs)(P,{number:f.database,title:`Create the database and publish its rules`,children:[(0,o.jsxs)(`ol`,{className:`space-y-2 list-decimal list-inside text-foreground/80 mb-4`,children:[(0,o.jsxs)(`li`,{children:[`In the Firebase menu, open `,(0,o.jsx)(`strong`,{children:`Firestore Database`}),` and click `,(0,o.jsx)(`strong`,{children:`Create database`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Pick `,(0,o.jsx)(`strong`,{children:`Standard edition`}),` if asked, choose the location closest to your people, start in `,(0,o.jsx)(`strong`,{children:`Production mode`}),`, and click Continue.`]}),(0,o.jsxs)(`li`,{children:[`In the box below, type the email address you will sign in with and click `,(0,o.jsx)(`strong`,{children:`Generate the rules`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Copy the rules`}),`, then in Firebase open the `,(0,o.jsx)(`strong`,{children:`Rules`}),` tab, select everything that is there, paste over it, and click `,(0,o.jsx)(`strong`,{children:`Publish`}),`.`]})]}),(0,o.jsx)(M,{})]}),(0,o.jsxs)(P,{number:f.storage,title:`Create storage and publish its rules`,children:[(0,o.jsxs)(`ol`,{className:`space-y-2 list-decimal list-inside text-foreground/80 mb-4`,children:[(0,o.jsxs)(`li`,{children:[`In the Firebase menu, open `,(0,o.jsx)(`strong`,{children:`Storage`}),`. Google asks you to upgrade the project first: click `,(0,o.jsx)(`strong`,{children:`Upgrade project`}),` and pick the pay-as-you-go`,` `,(0,o.jsx)(`strong`,{children:`Blaze plan`}),`. Profile photos and attachments cost about $0.03 per gigabyte a month, so this is cents. Set a budget you are comfortable with (say $20), link a billing account, and continue.`]}),(0,o.jsxs)(`li`,{children:[`Click `,(0,o.jsx)(`strong`,{children:`Get started`}),`, choose a no-cost location, start in `,(0,o.jsx)(`strong`,{children:`Production mode`}),`, and click `,(0,o.jsx)(`strong`,{children:`Create`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Open the `,(0,o.jsx)(`strong`,{children:`Rules`}),` tab, select everything that is there, paste these over it, and click `,(0,o.jsx)(`strong`,{children:`Publish`}),`.`]}),(0,o.jsxs)(`li`,{children:[`Now Firebase shows the storage address at the top of the page — something like`,` `,`your-project.firebasestorage.app. `,(0,o.jsx)(`strong`,{children:`Copy that`}),`. This is the real one; the one Firebase showed you back in step `,f.webApp,` was a guess made before storage existed.`]}),d?(0,o.jsxs)(`li`,{children:[`Back in your `,(0,o.jsx)(`strong`,{children:`.env`}),` file, paste it into`,` `,(0,o.jsx)(`strong`,{children:`VITE_FIREBASE_STORAGE_BUCKET`}),` — the line you left empty.`]}):(0,o.jsxs)(`li`,{children:[`Back in the environment variables window from step `,f.keys,`, paste it into`,` `,(0,o.jsx)(`strong`,{children:`VITE_FIREBASE_STORAGE_BUCKET`}),` — the row you left empty.`]}),d?(0,o.jsxs)(`li`,{children:[(0,o.jsx)(`strong`,{children:`Start the app again.`}),` Stop it and start it once more — or just ask your assistant to restart it. A value added while it is running is not read until then. That is the last thing to do: then reload this page and your app is connected.`]}):(0,o.jsxs)(`li`,{children:[(0,o.jsx)(`strong`,{children:`Press Apply, then reload this page.`}),` Your app is connected.`]})]}),(0,o.jsx)(w,{code:b,label:`Copy the storage rules`})]})]}),(0,o.jsx)(F,{basename:r.basename})]}),(0,o.jsx)(i.t,{})]})]})}Object.defineProperty(exports,`a`,{enumerable:!0,get:function(){return l}}),Object.defineProperty(exports,`i`,{enumerable:!0,get:function(){return y}}),Object.defineProperty(exports,`n`,{enumerable:!0,get:function(){return D}}),Object.defineProperty(exports,`r`,{enumerable:!0,get:function(){return I}}),Object.defineProperty(exports,`t`,{enumerable:!0,get:function(){return w}});