@rapidmx/web-client 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +88 -0
  2. package/apps/admin/audit-log/index.tsx +35 -8
  3. package/apps/admin/data-requests/index.tsx +480 -485
  4. package/apps/admin/distribution-lists/[uid].tsx +19 -1
  5. package/apps/admin/escrow-scopes/[uid].tsx +348 -237
  6. package/apps/admin/escrow-scopes/new/index.tsx +8 -2
  7. package/apps/admin/index.tsx +19 -1
  8. package/apps/admin/ingest-queue/index.tsx +44 -5
  9. package/apps/admin/mailboxes/[uid].tsx +211 -184
  10. package/apps/admin/quarantine/index.tsx +46 -14
  11. package/apps/admin/transport-rules/[uid].tsx +30 -1
  12. package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
  13. package/apps/admin/transport-rules/index.tsx +24 -6
  14. package/apps/admin/transport-rules/new/index.tsx +30 -1
  15. package/apps/escrow/_layout.tsx +2 -3
  16. package/apps/escrow/audit-log/index.tsx +196 -168
  17. package/apps/escrow/matters/[uid].tsx +621 -521
  18. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
  19. package/apps/shared/components/admin/layout/AdminShell.tsx +28 -9
  20. package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
  21. package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
  22. package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
  23. package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
  24. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
  25. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
  26. package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
  27. package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
  28. package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
  29. package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
  30. package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
  31. package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
  32. package/apps/shared/components/admin/signOut.ts +79 -0
  33. package/apps/shared/components/admin/usePagedList.tsx +129 -0
  34. package/apps/shared/components/calendar/EventModal.tsx +717 -549
  35. package/apps/shared/components/calendar/MonthView.tsx +173 -139
  36. package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
  37. package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
  38. package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
  39. package/apps/shared/components/calendar/allDay.ts +124 -0
  40. package/apps/shared/components/calendar/layout/CalendarShell.tsx +194 -192
  41. package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
  42. package/apps/shared/components/contacts/ContactForm.tsx +377 -348
  43. package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
  44. package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
  45. package/apps/shared/components/contacts/contactKeys.ts +78 -0
  46. package/apps/shared/components/contacts/layout/ContactsShell.tsx +199 -197
  47. package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
  48. package/apps/shared/components/layout/AppShell.tsx +123 -33
  49. package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
  50. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
  51. package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
  52. package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
  53. package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
  54. package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
  55. package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
  56. package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
  57. package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
  58. package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
  59. package/apps/shared/components/mail/layout/MailShell.tsx +420 -408
  60. package/apps/shared/components/mail/pinnedSigners.ts +124 -0
  61. package/apps/shared/components/mail/verificationSeals.ts +125 -0
  62. package/apps/shared/components/mail/writableMailboxes.ts +101 -0
  63. package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
  64. package/apps/shared/components/settings/layout/SettingsShell.tsx +23 -9
  65. package/apps/shared/components/tasks/layout/TasksShell.tsx +201 -199
  66. package/apps/shared/mail/listAllPages.ts +39 -0
  67. package/apps/shared/plugins/pluginNav.ts +73 -0
  68. package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
  69. package/apps/shared/search/localIndexBuilder.ts +481 -179
  70. package/apps/shared/search/localIndexRpcClient.ts +231 -29
  71. package/apps/shared/search/localIndexSchema.ts +15 -6
  72. package/apps/shared/search/localIndexStorage.ts +98 -0
  73. package/apps/shared/search/localIndexVFS.ts +32 -1
  74. package/apps/shared/search/localIndexWorker.ts +648 -186
  75. package/apps/www/calendar/index.tsx +438 -417
  76. package/apps/www/contacts/[uid].tsx +21 -2
  77. package/apps/www/contacts/index.tsx +116 -12
  78. package/apps/www/index.tsx +1314 -1041
  79. package/apps/www/messages/[uid].tsx +18 -6
  80. package/apps/www/settings/auto-reply/index.tsx +134 -129
  81. package/apps/www/settings/encryption/index.tsx +739 -216
  82. package/apps/www/settings/filters/[uid].tsx +171 -155
  83. package/apps/www/settings/filters/new/index.tsx +8 -1
  84. package/apps/www/settings/focused-inbox/index.tsx +150 -148
  85. package/apps/www/settings/privacy/index.tsx +494 -436
  86. package/apps/www/settings/read-receipts/index.tsx +148 -125
  87. package/apps/www/settings/sharing/index.tsx +17 -14
  88. package/apps/www/tasks/index.tsx +638 -579
  89. package/dist/apps/admin/_404.d.ts +2 -0
  90. package/dist/apps/admin/_500.d.ts +2 -0
  91. package/dist/apps/admin/_layout.d.ts +8 -0
  92. package/dist/apps/admin/audit-log/index.d.ts +11 -0
  93. package/dist/apps/admin/audit-log/index.js +38 -8
  94. package/dist/apps/admin/branding/index.d.ts +3 -0
  95. package/dist/apps/admin/data-requests/index.d.ts +3 -0
  96. package/dist/apps/admin/data-requests/index.js +52 -72
  97. package/dist/apps/admin/distribution-lists/[uid].d.ts +7 -0
  98. package/dist/apps/admin/distribution-lists/[uid].js +1 -1
  99. package/dist/apps/admin/distribution-lists/index.d.ts +3 -0
  100. package/dist/apps/admin/distribution-lists/new/index.d.ts +3 -0
  101. package/dist/apps/admin/domains/[uid].d.ts +7 -0
  102. package/dist/apps/admin/domains/index.d.ts +3 -0
  103. package/dist/apps/admin/domains/new/index.d.ts +3 -0
  104. package/dist/apps/admin/encryption-policy/index.d.ts +3 -0
  105. package/dist/apps/admin/escrow-scopes/[uid].d.ts +21 -0
  106. package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
  107. package/dist/apps/admin/escrow-scopes/index.d.ts +3 -0
  108. package/dist/apps/admin/escrow-scopes/new/index.d.ts +3 -0
  109. package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
  110. package/dist/apps/admin/index.d.ts +3 -0
  111. package/dist/apps/admin/index.js +4 -1
  112. package/dist/apps/admin/ingest-queue/index.d.ts +4 -0
  113. package/dist/apps/admin/ingest-queue/index.js +24 -6
  114. package/dist/apps/admin/mailbox-policy/index.d.ts +3 -0
  115. package/dist/apps/admin/mailboxes/[uid].d.ts +7 -0
  116. package/dist/apps/admin/mailboxes/[uid].js +20 -4
  117. package/dist/apps/admin/mailboxes/new/index.d.ts +3 -0
  118. package/dist/apps/admin/plugins/index.d.ts +3 -0
  119. package/dist/apps/admin/quarantine/index.d.ts +6 -0
  120. package/dist/apps/admin/quarantine/index.js +20 -11
  121. package/dist/apps/admin/retention-policy/index.d.ts +3 -0
  122. package/dist/apps/admin/setup/index.d.ts +3 -0
  123. package/dist/apps/admin/transport-rules/[uid].d.ts +7 -0
  124. package/dist/apps/admin/transport-rules/[uid].js +18 -2
  125. package/dist/apps/admin/transport-rules/_transportRuleConfig.d.ts +21 -0
  126. package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
  127. package/dist/apps/admin/transport-rules/index.d.ts +3 -0
  128. package/dist/apps/admin/transport-rules/index.js +22 -6
  129. package/dist/apps/admin/transport-rules/new/index.d.ts +3 -0
  130. package/dist/apps/admin/transport-rules/new/index.js +18 -2
  131. package/dist/apps/escrow/_layout.d.ts +10 -0
  132. package/dist/apps/escrow/_layout.js +3 -3
  133. package/dist/apps/escrow/audit-log/index.d.ts +9 -0
  134. package/dist/apps/escrow/audit-log/index.js +26 -1
  135. package/dist/apps/escrow/index.d.ts +3 -0
  136. package/dist/apps/escrow/matters/[uid].d.ts +7 -0
  137. package/dist/apps/escrow/matters/[uid].js +77 -42
  138. package/dist/apps/escrow/matters/new/index.d.ts +3 -0
  139. package/dist/apps/shared/components/admin/distributionLists/MemberListCard.d.ts +17 -0
  140. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.d.ts +32 -0
  141. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
  142. package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +32 -0
  143. package/dist/apps/shared/components/admin/layout/AdminShell.js +15 -6
  144. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.d.ts +12 -0
  145. package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
  146. package/dist/apps/shared/components/admin/mailboxes/MailboxTable.d.ts +6 -0
  147. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.d.ts +19 -0
  148. package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
  149. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +12 -0
  150. package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
  151. package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +9 -0
  152. package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
  153. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.d.ts +12 -0
  154. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
  155. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.d.ts +12 -0
  156. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
  157. package/dist/apps/shared/components/admin/settings/LoadedSettingsForm.d.ts +9 -0
  158. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.d.ts +19 -0
  159. package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
  160. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.d.ts +10 -0
  161. package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
  162. package/dist/apps/shared/components/admin/settings/PluginsManager.d.ts +4 -0
  163. package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
  164. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.d.ts +9 -0
  165. package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
  166. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.d.ts +14 -0
  167. package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
  168. package/dist/apps/shared/components/admin/setup/SetupWizard.d.ts +36 -0
  169. package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
  170. package/dist/apps/shared/components/admin/signOut.d.ts +23 -0
  171. package/dist/apps/shared/components/admin/signOut.js +75 -0
  172. package/dist/apps/shared/components/admin/usePagedList.d.ts +39 -0
  173. package/dist/apps/shared/components/admin/usePagedList.js +99 -0
  174. package/dist/apps/shared/components/calendar/CalendarListSidebar.d.ts +26 -0
  175. package/dist/apps/shared/components/calendar/EventModal.d.ts +46 -0
  176. package/dist/apps/shared/components/calendar/EventModal.js +142 -21
  177. package/dist/apps/shared/components/calendar/MonthView.d.ts +15 -0
  178. package/dist/apps/shared/components/calendar/MonthView.js +12 -5
  179. package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +20 -0
  180. package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
  181. package/dist/apps/shared/components/calendar/ResourcePicker.d.ts +19 -0
  182. package/dist/apps/shared/components/calendar/SplitDayView.d.ts +28 -0
  183. package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
  184. package/dist/apps/shared/components/calendar/TimeGridView.d.ts +17 -0
  185. package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
  186. package/dist/apps/shared/components/calendar/allDay.d.ts +51 -0
  187. package/dist/apps/shared/components/calendar/allDay.js +111 -0
  188. package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +40 -0
  189. package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -3
  190. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +23 -0
  191. package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
  192. package/dist/apps/shared/components/contacts/ContactForm.d.ts +20 -0
  193. package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
  194. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +40 -0
  195. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  196. package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +25 -0
  197. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +40 -0
  198. package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
  199. package/dist/apps/shared/components/contacts/contactKeys.d.ts +29 -0
  200. package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
  201. package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +21 -0
  202. package/dist/apps/shared/components/contacts/layout/ContactsShell.js +2 -2
  203. package/dist/apps/shared/components/escrow/layout/EscrowShell.d.ts +25 -0
  204. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
  205. package/dist/apps/shared/components/forms/StringListField.d.ts +22 -0
  206. package/dist/apps/shared/components/layout/AppShell.d.ts +49 -0
  207. package/dist/apps/shared/components/layout/AppShell.js +97 -26
  208. package/dist/apps/shared/components/layout/BrandingChrome.d.ts +18 -0
  209. package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
  210. package/dist/apps/shared/components/layout/KeyEnrollmentGate.d.ts +51 -0
  211. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
  212. package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +13 -0
  213. package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
  214. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.d.ts +66 -0
  215. package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
  216. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +45 -0
  217. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
  218. package/dist/apps/shared/components/layout/UserMenu.d.ts +23 -0
  219. package/dist/apps/shared/components/mail/ConversationList.d.ts +11 -0
  220. package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +23 -0
  221. package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
  222. package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +93 -0
  223. package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
  224. package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +58 -0
  225. package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
  226. package/dist/apps/shared/components/mail/compose/ComposeToolbar.d.ts +15 -0
  227. package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +37 -0
  228. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
  229. package/dist/apps/shared/components/mail/compose/EmojiPicker.d.ts +14 -0
  230. package/dist/apps/shared/components/mail/compose/GifPicker.d.ts +13 -0
  231. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +34 -0
  232. package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.d.ts +16 -0
  233. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.d.ts +27 -0
  234. package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
  235. package/dist/apps/shared/components/mail/layout/MailShell.d.ts +57 -0
  236. package/dist/apps/shared/components/mail/layout/MailShell.js +13 -4
  237. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +33 -0
  238. package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
  239. package/dist/apps/shared/components/mail/verificationSeals.d.ts +22 -0
  240. package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
  241. package/dist/apps/shared/components/mail/writableMailboxes.d.ts +15 -0
  242. package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
  243. package/dist/apps/shared/components/rules/RuleBuilder.d.ts +70 -0
  244. package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
  245. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +41 -0
  246. package/dist/apps/shared/components/settings/layout/SettingsShell.js +19 -9
  247. package/dist/apps/shared/components/tasks/TasksSidebar.d.ts +41 -0
  248. package/dist/apps/shared/components/tasks/TasksToolbar.d.ts +14 -0
  249. package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +24 -0
  250. package/dist/apps/shared/components/tasks/layout/TasksShell.js +2 -2
  251. package/dist/apps/shared/mail/findWellKnownFolderUid.d.ts +5 -0
  252. package/dist/apps/shared/mail/listAllPages.d.ts +17 -0
  253. package/dist/apps/shared/mail/listAllPages.js +25 -0
  254. package/dist/apps/shared/plugins/pluginNav.d.ts +39 -0
  255. package/dist/apps/shared/plugins/pluginNav.js +33 -0
  256. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +13 -0
  257. package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
  258. package/dist/apps/shared/search/localIndexBlockCipher.d.ts +36 -0
  259. package/dist/apps/shared/search/localIndexBuilder.d.ts +85 -0
  260. package/dist/apps/shared/search/localIndexBuilder.js +292 -37
  261. package/dist/apps/shared/search/localIndexKey.d.ts +4 -0
  262. package/dist/apps/shared/search/localIndexRpcClient.d.ts +71 -0
  263. package/dist/apps/shared/search/localIndexRpcClient.js +191 -26
  264. package/dist/apps/shared/search/localIndexSchema.d.ts +111 -0
  265. package/dist/apps/shared/search/localIndexSchema.js +12 -6
  266. package/dist/apps/shared/search/localIndexSizePreference.d.ts +27 -0
  267. package/dist/apps/shared/search/localIndexStorage.d.ts +28 -0
  268. package/dist/apps/shared/search/localIndexStorage.js +86 -0
  269. package/dist/apps/shared/search/localIndexVFS.d.ts +101 -0
  270. package/dist/apps/shared/search/localIndexVFS.js +68 -40
  271. package/dist/apps/shared/search/localIndexWorker.d.ts +145 -0
  272. package/dist/apps/shared/search/localIndexWorker.js +519 -173
  273. package/dist/apps/shared/search/searchTier2.d.ts +30 -0
  274. package/dist/apps/www/_404.d.ts +2 -0
  275. package/dist/apps/www/_500.d.ts +2 -0
  276. package/dist/apps/www/_layout.d.ts +14 -0
  277. package/dist/apps/www/calendar/index.d.ts +3 -0
  278. package/dist/apps/www/calendar/index.js +28 -10
  279. package/dist/apps/www/contacts/[uid].d.ts +13 -0
  280. package/dist/apps/www/contacts/[uid].js +13 -2
  281. package/dist/apps/www/contacts/index.d.ts +3 -0
  282. package/dist/apps/www/contacts/index.js +80 -10
  283. package/dist/apps/www/index.d.ts +3 -0
  284. package/dist/apps/www/index.js +339 -108
  285. package/dist/apps/www/messages/[uid].d.ts +11 -0
  286. package/dist/apps/www/messages/[uid].js +18 -6
  287. package/dist/apps/www/settings/auto-reply/index.d.ts +4 -0
  288. package/dist/apps/www/settings/auto-reply/index.js +9 -4
  289. package/dist/apps/www/settings/encryption/index.d.ts +7 -0
  290. package/dist/apps/www/settings/encryption/index.js +514 -97
  291. package/dist/apps/www/settings/filters/[uid].d.ts +8 -0
  292. package/dist/apps/www/settings/filters/[uid].js +22 -7
  293. package/dist/apps/www/settings/filters/_mailFilterRuleConfig.d.ts +11 -0
  294. package/dist/apps/www/settings/filters/index.d.ts +4 -0
  295. package/dist/apps/www/settings/filters/new/index.d.ts +4 -0
  296. package/dist/apps/www/settings/filters/new/index.js +8 -2
  297. package/dist/apps/www/settings/focused-inbox/index.d.ts +4 -0
  298. package/dist/apps/www/settings/focused-inbox/index.js +3 -1
  299. package/dist/apps/www/settings/labels/index.d.ts +4 -0
  300. package/dist/apps/www/settings/privacy/index.d.ts +4 -0
  301. package/dist/apps/www/settings/privacy/index.js +57 -22
  302. package/dist/apps/www/settings/read-receipts/index.d.ts +4 -0
  303. package/dist/apps/www/settings/read-receipts/index.js +10 -3
  304. package/dist/apps/www/settings/sharing/index.d.ts +4 -0
  305. package/dist/apps/www/settings/sharing/index.js +7 -11
  306. package/dist/apps/www/settings/signatures/[uid].d.ts +8 -0
  307. package/dist/apps/www/settings/signatures/index.d.ts +4 -0
  308. package/dist/apps/www/settings/signatures/new/index.d.ts +4 -0
  309. package/dist/apps/www/settings/signatures/signatureDefaults.d.ts +10 -0
  310. package/dist/apps/www/tasks/index.d.ts +3 -0
  311. package/dist/apps/www/tasks/index.js +50 -10
  312. package/package.json +3 -3
  313. package/apps/shared/components/booking/AvailabilityEditor.tsx +0 -118
  314. package/apps/www/settings/booking-types/[uid].tsx +0 -300
  315. package/apps/www/settings/booking-types/index.tsx +0 -98
  316. package/apps/www/settings/booking-types/new/index.tsx +0 -212
  317. package/dist/apps/shared/components/booking/AvailabilityEditor.js +0 -45
  318. package/dist/apps/www/settings/booking-types/[uid].js +0 -129
  319. package/dist/apps/www/settings/booking-types/index.js +0 -29
  320. package/dist/apps/www/settings/booking-types/new/index.js +0 -86
@@ -1,595 +1,1235 @@
1
- ///////////////////////////////////////////////////////////////////////////////
2
- // Copyright (C) 2026 Jean-Philippe Steinmetz
3
- // SPDX-License-Identifier: MPL-2.0
4
- ///////////////////////////////////////////////////////////////////////////////
5
- import React, { FormEvent, useCallback, useEffect, useRef, useState } from "react";
6
- import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
- import {
8
- addPlugin,
9
- getPluginStatus,
10
- listPlugins,
11
- lookupPluginPackage,
12
- Plugin,
13
- PluginInstanceStatus,
14
- PluginRegistryLookup,
15
- PluginSettingDefinition,
16
- PluginSettingValue,
17
- PluginStatus,
18
- removePlugin,
19
- updatePlugin,
20
- } from "@rapidmx/react-shared/admin/pluginsApi.js";
21
- import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
22
- import Button from "@rapidmx/react-shared/components/buttons/Button.js";
23
- import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
24
-
25
- const INPUT_CLASS =
26
- "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
27
-
28
- /** How often status is refreshed while server copies are still applying a change. */
29
- const PENDING_POLL_MS = 5000;
30
-
31
- function errorMessage(err: unknown, fallback: string): string {
32
- return err instanceof ApiRequestError ? err.message : fallback;
33
- }
34
-
35
- /** Installed plugins with their rollout status, and every plugin action - shared by the Plugins page and the
36
- * setup wizard. */
37
- export default function PluginsManager() {
38
- const [plugins, setPlugins] = useState<Plugin[]>([]);
39
- const [status, setStatus] = useState<PluginStatus | null>(null);
40
- const [loading, setLoading] = useState(true);
41
- const [error, setError] = useState<string | null>(null);
42
- const [adding, setAdding] = useState(false);
43
- const [upgrading, setUpgrading] = useState<Plugin | null>(null);
44
- const [configuring, setConfiguring] = useState<Plugin | null>(null);
45
- const [removing, setRemoving] = useState<Plugin | null>(null);
46
- const [busyUid, setBusyUid] = useState<string | null>(null);
47
-
48
- const refreshStatus = useCallback(() => {
49
- // Status is advisory - a failure to read it shouldn't hide the plugin list.
50
- return getPluginStatus()
51
- .then(setStatus)
52
- .catch(() => setStatus(null));
53
- }, []);
54
-
55
- useEffect(() => {
56
- Promise.all([listPlugins(), refreshStatus()])
57
- .then(([list]) => setPlugins(list))
58
- .catch((err) => setError(errorMessage(err, "Could not load plugins.")))
59
- .finally(() => setLoading(false));
60
- }, [refreshStatus]);
61
-
62
- const pending: boolean = !!status && status.instances.some((instance) => instance.hash !== status.hash);
63
- useEffect(() => {
64
- if (!pending) {
65
- return;
66
- }
67
- const timer = setInterval(() => void refreshStatus(), PENDING_POLL_MS);
68
- return () => clearInterval(timer);
69
- }, [pending, refreshStatus]);
70
-
71
- /** Applies a saved change locally and re-reads status, since saving starts a rollout. */
72
- function applied(updated: Plugin) {
73
- setPlugins((prev) => {
74
- const exists = prev.some((plugin) => plugin.uid === updated.uid);
75
- const next = exists ? prev.map((plugin) => (plugin.uid === updated.uid ? updated : plugin)) : [...prev, updated];
76
- return next.sort((a, b) => a.name.localeCompare(b.name));
77
- });
78
- void refreshStatus();
79
- }
80
-
81
- async function toggle(plugin: Plugin) {
82
- setBusyUid(plugin.uid);
83
- setError(null);
84
- try {
85
- applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: !plugin.enabled }));
86
- } catch (err) {
87
- setError(errorMessage(err, `Could not ${plugin.enabled ? "disable" : "enable"} ${plugin.manifest.displayName}.`));
88
- } finally {
89
- setBusyUid(null);
90
- }
91
- }
92
-
93
- return (
94
- <>
95
- <div className="flex items-center justify-between mb-2">
96
- <h1 className="text-xl font-bold uppercase tracking-wide">Plugins</h1>
97
- <Button type="button" className="!w-auto" onClick={() => setAdding(true)}>
98
- + Add plugin
99
- </Button>
100
- </div>
101
- <p className="text-sm text-text-muted mb-5 max-w-3xl">
102
- Plugins add protocols and features to every server. Changes are applied by restarting the servers
103
- one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones
104
- you trust.
105
- </p>
106
-
107
- {error && <Alert>{error}</Alert>}
108
- <RolloutBanner status={status} />
109
-
110
- {loading ? (
111
- <p className="text-sm text-text-muted">Loading&hellip;</p>
112
- ) : plugins.length === 0 ? (
113
- <p className="text-sm text-text-muted">No plugins installed.</p>
114
- ) : (
115
- <div className="overflow-x-auto">
116
- <table className="w-full text-sm border-collapse">
117
- <thead>
118
- <tr>
119
- {["Plugin", "Version", "Status", "Enabled", ""].map((h) => (
120
- <th
121
- key={h}
122
- className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
123
- >
124
- {h}
125
- </th>
126
- ))}
127
- </tr>
128
- </thead>
129
- <tbody>
130
- {plugins.map((plugin) => (
131
- <tr key={plugin.uid}>
132
- <td className="py-2.5 px-2.5 border-b border-border align-top">
133
- <div className="font-semibold">{plugin.manifest.displayName}</div>
134
- <div className="text-xs text-text-muted">{plugin.name}</div>
135
- {plugin.manifest.description && (
136
- <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
137
- )}
138
- </td>
139
- <td className="py-2.5 px-2.5 border-b border-border align-top">{plugin.packageVersion}</td>
140
- <td className="py-2.5 px-2.5 border-b border-border align-top">
141
- <PluginStatusCell plugin={plugin} status={status} />
142
- </td>
143
- <td className="py-2.5 px-2.5 border-b border-border align-top">
144
- <label className="inline-flex items-center gap-2">
145
- <input
146
- type="checkbox"
147
- aria-label={`Enable ${plugin.manifest.displayName}`}
148
- checked={plugin.enabled}
149
- disabled={busyUid === plugin.uid}
150
- onChange={() => void toggle(plugin)}
151
- />
152
- </label>
153
- </td>
154
- <td className="py-2.5 px-2.5 border-b border-border align-top text-right whitespace-nowrap">
155
- {(plugin.manifest.settings ?? []).length > 0 && (
156
- <Button variant="text" type="button" className="!w-auto" onClick={() => setConfiguring(plugin)}>
157
- Settings
158
- </Button>
159
- )}
160
- <Button variant="text" type="button" className="!w-auto" onClick={() => setUpgrading(plugin)}>
161
- Change version
162
- </Button>
163
- <Button variant="text" type="button" className="!w-auto" onClick={() => setRemoving(plugin)}>
164
- Remove
165
- </Button>
166
- </td>
167
- </tr>
168
- ))}
169
- </tbody>
170
- </table>
171
- </div>
172
- )}
173
-
174
- <AddPluginModal
175
- open={adding}
176
- onClose={() => setAdding(false)}
177
- onAdded={(plugin) => {
178
- setAdding(false);
179
- applied(plugin);
180
- }}
181
- />
182
- {upgrading && (
183
- <ChangeVersionModal
184
- plugin={upgrading}
185
- onClose={() => setUpgrading(null)}
186
- onSaved={(plugin) => {
187
- setUpgrading(null);
188
- applied(plugin);
189
- }}
190
- />
191
- )}
192
- {configuring && (
193
- <SettingsModal
194
- plugin={configuring}
195
- onClose={() => setConfiguring(null)}
196
- onSaved={(plugin) => {
197
- setConfiguring(null);
198
- applied(plugin);
199
- }}
200
- />
201
- )}
202
- {removing && (
203
- <RemoveModal
204
- plugin={removing}
205
- onClose={() => setRemoving(null)}
206
- onRemoved={() => {
207
- const uid = removing.uid;
208
- setRemoving(null);
209
- setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
210
- void refreshStatus();
211
- }}
212
- />
213
- )}
214
- </>
215
- );
216
- }
217
-
218
- /** Shown while any server copy hasn't applied the saved plugin set yet, or when one is in safe mode. */
219
- function RolloutBanner({ status }: { status: PluginStatus | null }) {
220
- if (!status || status.instances.length === 0) {
221
- return null;
222
- }
223
- const behind = status.instances.filter((instance) => instance.hash !== status.hash).length;
224
- const safeMode = status.instances.filter((instance) => instance.safeMode);
225
- return (
226
- <>
227
- {behind > 0 && (
228
- <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
229
- Applying changes: {status.instances.length - behind} of {status.instances.length}{" "}
230
- {status.instances.length === 1 ? "server" : "servers"} updated. Servers restart one at a time.
231
- </p>
232
- )}
233
- {safeMode.length > 0 && (
234
- <Alert>
235
- {safeMode.map((instance) => instance.instance).join(", ")} started without any plugins because recent
236
- starts failed. Fix or disable the failing plugin, and the server will pick up the change.
237
- </Alert>
238
- )}
239
- </>
240
- );
241
- }
242
-
243
- function PluginStatusCell({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
244
- if (!plugin.enabled) {
245
- return <span className="text-text-muted">Disabled</span>;
246
- }
247
- if (!status || status.instances.length === 0) {
248
- return <span className="text-text-muted">Unknown</span>;
249
- }
250
- const current: PluginInstanceStatus[] = status.instances.filter((instance) => instance.hash === status.hash);
251
- const loaded = current.filter((instance) =>
252
- instance.loaded.some((entry) => entry.name === plugin.name && entry.version === plugin.packageVersion),
253
- ).length;
254
- const errors = current.flatMap((instance) =>
255
- instance.errors.filter((entry) => entry.name === plugin.name).map((entry) => `${instance.instance}: ${entry.message}`),
256
- );
257
- return (
258
- <div>
259
- <span className={loaded === status.instances.length ? "text-success font-medium" : "text-text-muted"}>
260
- Loaded on {loaded} of {status.instances.length} {status.instances.length === 1 ? "server" : "servers"}
261
- </span>
262
- {errors.map((message) => (
263
- <div key={message} className="text-xs text-danger mt-1">
264
- {message}
265
- </div>
266
- ))}
267
- </div>
268
- );
269
- }
270
-
271
- function AddPluginModal({ open, onClose, onAdded }: { open: boolean; onClose: () => void; onAdded: (plugin: Plugin) => void }) {
272
- const [name, setName] = useState("");
273
- const [lookup, setLookup] = useState<PluginRegistryLookup | null>(null);
274
- const [selectedVersion, setSelectedVersion] = useState("");
275
- const [error, setError] = useState<string | null>(null);
276
- const [busy, setBusy] = useState(false);
277
-
278
- useEffect(() => {
279
- if (!open) {
280
- setName("");
281
- setLookup(null);
282
- setSelectedVersion("");
283
- setError(null);
284
- }
285
- }, [open]);
286
-
287
- async function find(e: FormEvent) {
288
- e.preventDefault();
289
- if (!name.trim()) {
290
- setError("Enter a package name.");
291
- return;
292
- }
293
- setBusy(true);
294
- setError(null);
295
- try {
296
- const found = await lookupPluginPackage(name.trim());
297
- setLookup(found);
298
- setSelectedVersion(found.selected.version);
299
- } catch (err) {
300
- setLookup(null);
301
- setError(errorMessage(err, "Could not look that package up."));
302
- } finally {
303
- setBusy(false);
304
- }
305
- }
306
-
307
- async function add() {
308
- setBusy(true);
309
- setError(null);
310
- try {
311
- onAdded(await addPlugin(lookup!.package.name, selectedVersion));
312
- } catch (err) {
313
- setError(errorMessage(err, "Could not add the plugin."));
314
- } finally {
315
- setBusy(false);
316
- }
317
- }
318
-
319
- const manifest = lookup?.selected.manifest;
320
- return (
321
- <Modal open={open} onClose={onClose} title="Add plugin">
322
- {error && <Alert>{error}</Alert>}
323
- <form onSubmit={find} className="flex gap-2 items-end mb-4">
324
- <label className="flex flex-col gap-1.5 text-sm flex-1">
325
- <span className="font-semibold">Package name</span>
326
- <input
327
- aria-label="Package name"
328
- className={INPUT_CLASS}
329
- value={name}
330
- placeholder="@rapidmx/activesync"
331
- onChange={(e) => {
332
- setName(e.target.value);
333
- setLookup(null);
334
- }}
335
- />
336
- </label>
337
- <Button type="submit" variant="secondary" className="!w-auto" loading={busy && !lookup} disabled={busy}>
338
- Find
339
- </Button>
340
- </form>
341
- {lookup && (
342
- <div className="flex flex-col gap-3">
343
- {typeof manifest === "string" ? (
344
- <Alert>{manifest}</Alert>
345
- ) : (
346
- <div className="text-sm">
347
- <div className="font-semibold">{manifest?.displayName}</div>
348
- {manifest?.description && <div className="text-text-muted">{manifest.description}</div>}
349
- </div>
350
- )}
351
- <label className="flex flex-col gap-1.5 text-sm">
352
- <span className="font-semibold">Version</span>
353
- <select aria-label="Version" className={INPUT_CLASS} value={selectedVersion} onChange={(e) => setSelectedVersion(e.target.value)}>
354
- {lookup.package.versions.map((version) => (
355
- <option key={version} value={version}>
356
- {version}
357
- {version === lookup.package.latest ? " (latest)" : ""}
358
- </option>
359
- ))}
360
- </select>
361
- </label>
362
- <div className="flex gap-2 justify-end">
363
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
364
- Cancel
365
- </Button>
366
- <Button type="button" className="!w-auto" loading={busy} disabled={busy || typeof manifest === "string"} onClick={() => void add()}>
367
- Add plugin
368
- </Button>
369
- </div>
370
- </div>
371
- )}
372
- </Modal>
373
- );
374
- }
375
-
376
- function ChangeVersionModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
377
- const [versions, setVersions] = useState<string[] | null>(null);
378
- const [latest, setLatest] = useState<string | undefined>();
379
- const [selected, setSelected] = useState(plugin.packageVersion);
380
- const [error, setError] = useState<string | null>(null);
381
- const [busy, setBusy] = useState(false);
382
-
383
- useEffect(() => {
384
- lookupPluginPackage(plugin.name)
385
- .then((found) => {
386
- setVersions(found.package.versions);
387
- setLatest(found.package.latest);
388
- })
389
- .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
390
- }, [plugin.name]);
391
-
392
- async function save() {
393
- setBusy(true);
394
- setError(null);
395
- try {
396
- onSaved(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion: selected }));
397
- } catch (err) {
398
- setError(errorMessage(err, "Could not change the version."));
399
- } finally {
400
- setBusy(false);
401
- }
402
- }
403
-
404
- return (
405
- <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
406
- {error && <Alert>{error}</Alert>}
407
- {versions === null ? (
408
- !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
409
- ) : (
410
- <div className="flex flex-col gap-3">
411
- <label className="flex flex-col gap-1.5 text-sm">
412
- <span className="font-semibold">Version</span>
413
- <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
414
- {versions.map((version) => (
415
- <option key={version} value={version}>
416
- {version}
417
- {version === latest ? " (latest)" : ""}
418
- {version === plugin.packageVersion ? " (installed)" : ""}
419
- </option>
420
- ))}
421
- </select>
422
- </label>
423
- <div className="flex gap-2 justify-end">
424
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
425
- Cancel
426
- </Button>
427
- <Button
428
- type="button"
429
- className="!w-auto"
430
- loading={busy}
431
- disabled={busy || selected === plugin.packageVersion}
432
- onClick={() => void save()}
433
- >
434
- Save
435
- </Button>
436
- </div>
437
- </div>
438
- )}
439
- </Modal>
440
- );
441
- }
442
-
443
- /** A setting's current form value: its saved value, else its default. Kept as a string for text inputs. */
444
- function initialValue(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): PluginSettingValue | "" {
445
- const value = saved ?? definition.default;
446
- if (definition.type === "boolean") {
447
- return value === true;
448
- }
449
- return value === undefined ? "" : String(value);
450
- }
451
-
452
- function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
453
- const definitions: PluginSettingDefinition[] = plugin.manifest.settings ?? [];
454
- const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
455
- const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
456
- const [error, setError] = useState<string | null>(null);
457
- const [busy, setBusy] = useState(false);
458
-
459
- async function save(e: FormEvent) {
460
- e.preventDefault();
461
- const settings: Record<string, PluginSettingValue | null> = {};
462
- for (const definition of definitions) {
463
- const value = values[definition.key];
464
- if (definition.type === "number") {
465
- if (value === "") {
466
- settings[definition.key] = null;
467
- continue;
468
- }
469
- // A number input only ever reports a valid number or an empty string.
470
- settings[definition.key] = Number(value);
471
- } else {
472
- settings[definition.key] = value === "" ? null : value;
473
- }
474
- }
475
- setBusy(true);
476
- setError(null);
477
- try {
478
- onSaved(await updatePlugin(plugin.uid, { version: plugin.version, settings }));
479
- } catch (err) {
480
- setError(errorMessage(err, "Could not save the settings."));
481
- } finally {
482
- setBusy(false);
483
- }
484
- }
485
-
486
- return (
487
- <Modal open onClose={onClose} title={`${plugin.manifest.displayName} settings`}>
488
- {error && <Alert>{error}</Alert>}
489
- <form onSubmit={save} className="flex flex-col gap-4">
490
- {definitions.map((definition) => (
491
- <SettingField
492
- key={definition.key}
493
- definition={definition}
494
- value={values[definition.key]}
495
- onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
496
- />
497
- ))}
498
- <p className="text-xs text-text-muted">Saving restarts the servers one at a time to apply the new settings.</p>
499
- <div className="flex gap-2 justify-end">
500
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
501
- Cancel
502
- </Button>
503
- <Button type="submit" className="!w-auto" loading={busy} disabled={busy}>
504
- Save
505
- </Button>
506
- </div>
507
- </form>
508
- </Modal>
509
- );
510
- }
511
-
512
- function SettingField({
513
- definition,
514
- value,
515
- onChange,
516
- }: {
517
- definition: PluginSettingDefinition;
518
- value: PluginSettingValue | "";
519
- onChange: (value: PluginSettingValue | "") => void;
520
- }) {
521
- const help = definition.help && <span className="text-xs text-text-muted">{definition.help}</span>;
522
- if (definition.type === "boolean") {
523
- return (
524
- <label className="flex flex-col gap-1 text-sm">
525
- <span className="inline-flex items-center gap-2 font-semibold">
526
- <input type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
527
- {definition.label}
528
- </span>
529
- {help}
530
- </label>
531
- );
532
- }
533
- return (
534
- <label className="flex flex-col gap-1.5 text-sm">
535
- <span className="font-semibold">{definition.label}</span>
536
- {definition.type === "select" ? (
537
- <select aria-label={definition.label} className={INPUT_CLASS} value={String(value)} onChange={(e) => onChange(e.target.value)}>
538
- {!definition.required && <option value="">Default</option>}
539
- {(definition.options ?? []).map((option) => (
540
- <option key={option.value} value={option.value}>
541
- {option.label}
542
- </option>
543
- ))}
544
- </select>
545
- ) : (
546
- <input
547
- aria-label={definition.label}
548
- className={INPUT_CLASS}
549
- type={definition.type === "number" ? "number" : "text"}
550
- min={definition.min}
551
- max={definition.max}
552
- required={definition.required}
553
- value={String(value)}
554
- onChange={(e) => onChange(e.target.value)}
555
- />
556
- )}
557
- {help}
558
- </label>
559
- );
560
- }
561
-
562
- function RemoveModal({ plugin, onClose, onRemoved }: { plugin: Plugin; onClose: () => void; onRemoved: () => void }) {
563
- const [error, setError] = useState<string | null>(null);
564
- const [busy, setBusy] = useState(false);
565
-
566
- async function remove() {
567
- setBusy(true);
568
- setError(null);
569
- try {
570
- await removePlugin(plugin.uid);
571
- onRemoved();
572
- } catch (err) {
573
- setError(errorMessage(err, "Could not remove the plugin."));
574
- setBusy(false);
575
- }
576
- }
577
-
578
- return (
579
- <Modal open onClose={onClose} title={`Remove ${plugin.manifest.displayName}?`}>
580
- {error && <Alert>{error}</Alert>}
581
- <p className="text-sm mb-4">
582
- The servers stop running this plugin after they restart. Data it stored stays in the database, and adding
583
- the plugin again brings it back.
584
- </p>
585
- <div className="flex gap-2 justify-end">
586
- <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
587
- Cancel
588
- </Button>
589
- <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void remove()}>
590
- Remove
591
- </Button>
592
- </div>
593
- </Modal>
594
- );
595
- }
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useCallback, useEffect, useRef, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import {
8
+ addPlugin,
9
+ expectedPlanOf,
10
+ getPluginStatus,
11
+ getPluginUpdates,
12
+ listPluginNamespaces,
13
+ listPlugins,
14
+ lookupPluginPackage,
15
+ planPluginChange,
16
+ Plugin,
17
+ PluginChangePlan,
18
+ PluginInstanceStatus,
19
+ PluginNamespace,
20
+ PluginRegistryLookup,
21
+ PluginSearchResult,
22
+ PluginSettingDefinition,
23
+ PluginSettingValue,
24
+ PluginStatus,
25
+ PluginUpdateInfo,
26
+ removePlugin,
27
+ searchPlugins,
28
+ updatePlugin,
29
+ } from "@rapidmx/react-shared/admin/pluginsApi.js";
30
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
31
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
32
+ import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
33
+
34
+ const INPUT_CLASS =
35
+ "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
36
+
37
+ /** How often status is refreshed while server copies are still applying a change. */
38
+ const PENDING_POLL_MS = 5000;
39
+
40
+ /** How long status keeps being polled after a saved change, since servers may not have noticed it yet. */
41
+ const AFTER_CHANGE_POLL_MS = 2 * 60 * 1000;
42
+
43
+ /** The longest wait between retries while no status has ever been read. */
44
+ const MAX_STATUS_RETRY_MS = 60 * 1000;
45
+
46
+ function errorMessage(err: unknown, fallback: string): string {
47
+ return err instanceof ApiRequestError ? err.message : fallback;
48
+ }
49
+
50
+ /** Runs `action`, resolving why it failed, or `null`. */
51
+ async function attempt(action: () => Promise<void>, failure: string): Promise<string | null> {
52
+ try {
53
+ await action();
54
+ return null;
55
+ } catch (err) {
56
+ return errorMessage(err, failure);
57
+ }
58
+ }
59
+
60
+ /** A change waiting for the administrator to confirm the other plugins it also installs or enables. */
61
+ interface PendingChange {
62
+ displayName: string;
63
+ plan: PluginChangePlan;
64
+ apply: (plan: PluginChangePlan) => Promise<void>;
65
+ failure: string;
66
+ /** The installed plugin being changed, which stays busy while the change waits for confirmation. */
67
+ uid?: string;
68
+ }
69
+
70
+ /** Installed plugins with their rollout status, and every plugin action - shared by the Plugins page and the
71
+ * setup wizard. */
72
+ export default function PluginsManager() {
73
+ const [plugins, setPlugins] = useState<Plugin[]>([]);
74
+ const [status, setStatus] = useState<PluginStatus | null>(null);
75
+ /** Set when the last status refresh failed, so what's shown may be out of date. */
76
+ const [statusStale, setStatusStale] = useState(false);
77
+ /** When polling started by a saved change stops (epoch ms), or 0 when there's none. */
78
+ const [pollUntil, setPollUntil] = useState(0);
79
+ const [updates, setUpdates] = useState<Map<string, PluginUpdateInfo>>(new Map());
80
+ const [loading, setLoading] = useState(true);
81
+ const [error, setError] = useState<string | null>(null);
82
+ /** The installed plugin whose enable just failed, offered a retry next to `error`. */
83
+ const [retryEnableUid, setRetryEnableUid] = useState<string | null>(null);
84
+ const [adding, setAdding] = useState(false);
85
+ const [upgrading, setUpgrading] = useState<Plugin | null>(null);
86
+ const [configuring, setConfiguring] = useState<Plugin | null>(null);
87
+ const [removing, setRemoving] = useState<Plugin | null>(null);
88
+ const [confirming, setConfirming] = useState<PendingChange | null>(null);
89
+ /** Installed plugins with an action under way. */
90
+ const [busyUids, setBusyUids] = useState<ReadonlySet<string>>(new Set());
91
+ /** Set when re-reading the list after a change failed, so rows may not show what that change also did. */
92
+ const [listStale, setListStale] = useState(false);
93
+ /** How many times status has been retried while none has ever been read. */
94
+ const [statusRetries, setStatusRetries] = useState(0);
95
+ const statusRequest = useRef<Promise<void> | null>(null);
96
+
97
+ const setBusy = (uid: string, busy: boolean) =>
98
+ setBusyUids((prev) => {
99
+ const next = new Set(prev);
100
+ if (busy) {
101
+ next.add(uid);
102
+ } else {
103
+ next.delete(uid);
104
+ }
105
+ return next;
106
+ });
107
+
108
+ const refreshStatus = useCallback(() => {
109
+ // One read at a time: a poll that comes due while a slow read is still running shares it.
110
+ statusRequest.current ??= getPluginStatus()
111
+ .then((next) => {
112
+ setStatus(next);
113
+ setStatusStale(false);
114
+ })
115
+ // Status is advisory - a failure to read it shouldn't hide the plugin list, or lose the last status read.
116
+ .catch(() => setStatusStale(true))
117
+ .finally(() => {
118
+ statusRequest.current = null;
119
+ });
120
+ return statusRequest.current;
121
+ }, []);
122
+
123
+ const refreshUpdates = useCallback(() => {
124
+ // Also advisory: a registry that can't be reached just means no update badges.
125
+ return getPluginUpdates()
126
+ .then((list) => setUpdates(new Map(list.map((info) => [info.uid, info]))))
127
+ .catch(() => setUpdates(new Map()));
128
+ }, []);
129
+
130
+ /** Re-reads the list, for changes that also installed or enabled other plugins. */
131
+ const reload = useCallback(() => {
132
+ return listPlugins()
133
+ .then((list) => {
134
+ setPlugins(list);
135
+ setListStale(false);
136
+ })
137
+ .catch(() => setListStale(true));
138
+ }, []);
139
+
140
+ useEffect(() => {
141
+ Promise.all([listPlugins(), refreshStatus()])
142
+ .then(([list]) => {
143
+ setPlugins(list);
144
+ void refreshUpdates();
145
+ })
146
+ .catch((err) => setError(errorMessage(err, "Could not load plugins.")))
147
+ .finally(() => setLoading(false));
148
+ }, [refreshStatus, refreshUpdates]);
149
+
150
+ const pending: boolean = !!status && status.instances.some((instance) => instance.hash !== status.hash);
151
+ useEffect(() => {
152
+ if (!pending && pollUntil === 0) {
153
+ return;
154
+ }
155
+ const timer = setInterval(() => void refreshStatus(), PENDING_POLL_MS);
156
+ // Polling for a saved change ends after a while; a rollout that's under way keeps it going.
157
+ const stop = pollUntil > 0 ? setTimeout(() => setPollUntil(0), Math.max(0, pollUntil - Date.now())) : undefined;
158
+ return () => {
159
+ clearInterval(timer);
160
+ clearTimeout(stop);
161
+ };
162
+ }, [pending, pollUntil, refreshStatus]);
163
+
164
+ // With no status read at all there's nothing pending to poll for, so keep retrying - less often each time.
165
+ const neverRead: boolean = !status && statusStale;
166
+ useEffect(() => {
167
+ if (!neverRead) {
168
+ return;
169
+ }
170
+ const timer = setTimeout(
171
+ () => void refreshStatus().then(() => setStatusRetries((n) => n + 1)),
172
+ Math.min(PENDING_POLL_MS * 2 ** statusRetries, MAX_STATUS_RETRY_MS),
173
+ );
174
+ return () => clearTimeout(timer);
175
+ }, [neverRead, statusRetries, refreshStatus]);
176
+
177
+ /** Re-reads status, and keeps doing so for a while, since saving starts a rollout servers pick up shortly. */
178
+ function watchRollout() {
179
+ setPollUntil(Date.now() + AFTER_CHANGE_POLL_MS);
180
+ void refreshStatus();
181
+ }
182
+
183
+ /** Applies saved changes locally and re-reads status and updates, since saving starts a rollout. */
184
+ function applied(...changed: Plugin[]) {
185
+ setPlugins((prev) => {
186
+ let next = prev;
187
+ for (const updated of changed) {
188
+ const exists = next.some((plugin) => plugin.uid === updated.uid);
189
+ next = exists ? next.map((plugin) => (plugin.uid === updated.uid ? updated : plugin)) : [...next, updated];
190
+ }
191
+ return [...next].sort((a, b) => a.name.localeCompare(b.name));
192
+ });
193
+ watchRollout();
194
+ void refreshUpdates();
195
+ }
196
+
197
+ /** Runs an action on an installed plugin's row, which is busy meanwhile, and shows why it failed. */
198
+ async function onRow(plugin: Plugin, action: () => Promise<string | null>) {
199
+ setBusy(plugin.uid, true);
200
+ setError(null);
201
+ setRetryEnableUid(null);
202
+ const problem = await action();
203
+ if (problem) {
204
+ setError(problem);
205
+ }
206
+ // A change waiting for confirmation keeps the row busy by itself, until the confirmation closes.
207
+ setBusy(plugin.uid, false);
208
+ }
209
+
210
+ function toggle(plugin: Plugin) {
211
+ const displayName: string = plugin.manifest.displayName;
212
+ if (plugin.enabled) {
213
+ return onRow(plugin, () =>
214
+ attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: false })), `Could not disable ${displayName}.`),
215
+ );
216
+ }
217
+ // Enabling a plugin also installs or enables the plugins it requires, so it's previewed like any other change.
218
+ // The installed version is planned from its stored manifest, so this doesn't need the registry. A preview that
219
+ // fails is never skipped - enabling without one could install or enable other plugins the administrator never
220
+ // saw - so the failure is shown with a retry instead.
221
+ return onRow(plugin, async () => {
222
+ const problem = await planned(
223
+ plugin.name,
224
+ plugin.packageVersion,
225
+ displayName,
226
+ async (plan) => {
227
+ applied(await updatePlugin(plugin.uid, { version: plugin.version, enabled: true, expectedPlan: expectedPlanOf(plan) }));
228
+ if (plan.install.length > 0 || plan.enable.length > 0) {
229
+ void reload();
230
+ }
231
+ },
232
+ `Could not enable ${displayName}.`,
233
+ { uid: plugin.uid },
234
+ );
235
+ if (problem) {
236
+ setRetryEnableUid(plugin.uid);
237
+ }
238
+ return problem;
239
+ });
240
+ }
241
+
242
+ /**
243
+ * Checks what installing or changing a plugin also takes before doing it. Resolves why it can't be done, or `null`
244
+ * once it's done - or, when it also installs or enables other plugins, once they're shown for confirmation.
245
+ */
246
+ async function planned(
247
+ name: string,
248
+ packageVersion: string,
249
+ displayName: string,
250
+ apply: (plan: PluginChangePlan) => Promise<void>,
251
+ failure: string,
252
+ options: { uid?: string } = {},
253
+ ): Promise<string | null> {
254
+ let plan: PluginChangePlan;
255
+ try {
256
+ plan = await planPluginChange(name, packageVersion);
257
+ } catch (err) {
258
+ return errorMessage(err, failure);
259
+ }
260
+ if (plan.conflicts.length > 0) {
261
+ return `${displayName} ${plan.plugin.version} can't be installed. ${plan.conflicts.join(" ")}`;
262
+ }
263
+ if (plan.install.length > 0 || plan.enable.length > 0) {
264
+ setConfirming({ displayName, plan, apply, failure, uid: options.uid });
265
+ return null;
266
+ }
267
+ return attempt(() => apply(plan), failure);
268
+ }
269
+
270
+ function install(name: string, packageVersion: string, displayName: string) {
271
+ return planned(
272
+ name,
273
+ packageVersion,
274
+ displayName,
275
+ async (plan) => {
276
+ const result = await addPlugin(name, packageVersion, expectedPlanOf(plan));
277
+ applied(...result.dependencies, result.plugin);
278
+ },
279
+ `Could not install ${displayName}.`,
280
+ );
281
+ }
282
+
283
+ function changeVersion(plugin: Plugin, packageVersion: string) {
284
+ const failure = `Could not upgrade ${plugin.manifest.displayName}.`;
285
+ if (!plugin.enabled) {
286
+ // A disabled plugin stays disabled, so nothing it requires is installed or enabled - there's nothing to preview.
287
+ return attempt(async () => applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion })), failure);
288
+ }
289
+ return planned(
290
+ plugin.name,
291
+ packageVersion,
292
+ plugin.manifest.displayName,
293
+ async (plan) => {
294
+ applied(await updatePlugin(plugin.uid, { version: plugin.version, packageVersion, expectedPlan: expectedPlanOf(plan) }));
295
+ if (plan.install.length > 0 || plan.enable.length > 0) {
296
+ void reload();
297
+ }
298
+ },
299
+ failure,
300
+ { uid: plugin.uid },
301
+ );
302
+ }
303
+
304
+ function upgrade(plugin: Plugin, packageVersion: string) {
305
+ return onRow(plugin, () => changeVersion(plugin, packageVersion));
306
+ }
307
+
308
+ // Retried against the plugin as it's listed now (a newer version, or already enabled elsewhere, hides the retry).
309
+ const retryEnable: Plugin | undefined = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
310
+
311
+ const displayNameOf = (name: string): string => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
312
+
313
+ return (
314
+ <>
315
+ <div className="flex items-center justify-between mb-2">
316
+ <h1 className="text-xl font-bold uppercase tracking-wide">Plugins</h1>
317
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setAdding(true)}>
318
+ Add by name
319
+ </Button>
320
+ </div>
321
+ <p className="text-sm text-text-muted mb-5 max-w-3xl">
322
+ Plugins add protocols and features to every server. Changes are applied by restarting the servers
323
+ one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones
324
+ you trust.
325
+ </p>
326
+
327
+ {error && (
328
+ <Alert>
329
+ {error}
330
+ {retryEnable && (
331
+ <>
332
+ {" "}
333
+ <Button
334
+ type="button"
335
+ variant="text"
336
+ className="!w-auto !p-0"
337
+ onClick={() => void toggle(retryEnable)}
338
+ >
339
+ Try again
340
+ </Button>
341
+ </>
342
+ )}
343
+ </Alert>
344
+ )}
345
+ <RolloutBanner status={status} />
346
+ {statusStale && status && (
347
+ <p className="mb-4 text-xs text-text-muted">Couldn&apos;t refresh server status. Showing the last status reported.</p>
348
+ )}
349
+ {listStale && (
350
+ <p className="mb-4 text-xs text-text-muted">
351
+ Couldn&apos;t re-read the plugin list, so it may be out of date.{" "}
352
+ <Button type="button" variant="text" className="!w-auto !p-0 !text-xs" onClick={() => void reload()}>
353
+ Retry
354
+ </Button>
355
+ </p>
356
+ )}
357
+
358
+ <h2 className="text-sm font-bold uppercase tracking-wide mb-2">Installed plugins</h2>
359
+ {loading ? (
360
+ <p className="text-sm text-text-muted">Loading&hellip;</p>
361
+ ) : plugins.length === 0 ? (
362
+ <p className="text-sm text-text-muted">No plugins installed.</p>
363
+ ) : (
364
+ <div className="overflow-x-auto">
365
+ <table className="w-full text-sm border-collapse">
366
+ <thead>
367
+ <tr>
368
+ {["Plugin", "Version", "State", "Servers", ""].map((h) => (
369
+ <th
370
+ key={h}
371
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
372
+ >
373
+ {h}
374
+ </th>
375
+ ))}
376
+ </tr>
377
+ </thead>
378
+ <tbody>
379
+ {plugins.map((plugin) => {
380
+ const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
381
+ const latest: string | undefined = update?.updateAvailable ? update.latestVersion : undefined;
382
+ const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid;
383
+ const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
384
+ const requiredBy: string[] = plugins
385
+ .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
386
+ .map((other) => other.manifest.displayName);
387
+ return (
388
+ <tr key={plugin.uid}>
389
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
390
+ <div className="font-semibold">{plugin.manifest.displayName}</div>
391
+ <div className="text-xs text-text-muted">{plugin.name}</div>
392
+ {plugin.manifest.description && (
393
+ <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
394
+ )}
395
+ {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
396
+ {requiredBy.length > 0 && (
397
+ <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
398
+ )}
399
+ </td>
400
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
401
+ <div>{plugin.packageVersion}</div>
402
+ {latest && (
403
+ <div className="mt-1 flex flex-col items-start gap-1">
404
+ <span className="text-xs font-semibold text-primary-dark">Update available: {latest}</span>
405
+ <Button
406
+ type="button"
407
+ variant="secondary"
408
+ className="!w-auto !py-1 !px-2 !text-xs"
409
+ aria-label={`Upgrade ${plugin.manifest.displayName} to ${latest}`}
410
+ disabled={busy}
411
+ onClick={() => void upgrade(plugin, latest)}
412
+ >
413
+ Upgrade
414
+ </Button>
415
+ </div>
416
+ )}
417
+ </td>
418
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
419
+ {plugin.enabled ? (
420
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
421
+ Enabled
422
+ </span>
423
+ ) : (
424
+ <span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
425
+ Disabled
426
+ </span>
427
+ )}
428
+ </td>
429
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
430
+ <PluginStatusCell plugin={plugin} status={status} />
431
+ </td>
432
+ <td className="py-2.5 px-2.5 border-b border-border align-top text-right whitespace-nowrap">
433
+ <Button
434
+ variant="text"
435
+ type="button"
436
+ className="!w-auto"
437
+ aria-label={`${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`}
438
+ disabled={busy}
439
+ onClick={() => void toggle(plugin)}
440
+ >
441
+ {plugin.enabled ? "Disable" : "Enable"}
442
+ </Button>
443
+ {(plugin.manifest.settings ?? []).length > 0 && (
444
+ <Button variant="text" type="button" className="!w-auto" onClick={() => setConfiguring(plugin)}>
445
+ Settings
446
+ </Button>
447
+ )}
448
+ <Button variant="text" type="button" className="!w-auto" onClick={() => setUpgrading(plugin)}>
449
+ Change version
450
+ </Button>
451
+ <Button variant="text" type="button" className="!w-auto" onClick={() => setRemoving(plugin)}>
452
+ Uninstall
453
+ </Button>
454
+ </td>
455
+ </tr>
456
+ );
457
+ })}
458
+ </tbody>
459
+ </table>
460
+ </div>
461
+ )}
462
+
463
+ <PluginBrowser
464
+ plugins={plugins}
465
+ onInstall={(result) => install(result.name, result.version, result.name)}
466
+ // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
467
+ onUpgrade={(uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid)!, packageVersion)}
468
+ />
469
+
470
+ <AddPluginModal
471
+ open={adding}
472
+ onClose={() => setAdding(false)}
473
+ onAdd={async (name, packageVersion, displayName) => {
474
+ const problem = await install(name, packageVersion, displayName);
475
+ if (!problem) {
476
+ setAdding(false);
477
+ }
478
+ return problem;
479
+ }}
480
+ />
481
+ {upgrading && (
482
+ <ChangeVersionModal
483
+ plugin={upgrading}
484
+ onClose={() => setUpgrading(null)}
485
+ onSave={async (packageVersion) => {
486
+ const problem = await changeVersion(upgrading, packageVersion);
487
+ if (!problem) {
488
+ setUpgrading(null);
489
+ }
490
+ return problem;
491
+ }}
492
+ />
493
+ )}
494
+ {confirming && (
495
+ <ConfirmDependenciesModal
496
+ change={confirming}
497
+ displayNameOf={displayNameOf}
498
+ onClose={() => setConfirming(null)}
499
+ onDone={() => setConfirming(null)}
500
+ />
501
+ )}
502
+ {configuring && (
503
+ <SettingsModal
504
+ plugin={configuring}
505
+ onClose={() => setConfiguring(null)}
506
+ onSaved={(plugin) => {
507
+ setConfiguring(null);
508
+ applied(plugin);
509
+ }}
510
+ />
511
+ )}
512
+ {removing && (
513
+ <RemoveModal
514
+ plugin={removing}
515
+ onClose={() => setRemoving(null)}
516
+ onRemoved={() => {
517
+ const uid = removing.uid;
518
+ setRemoving(null);
519
+ setPlugins((prev) => prev.filter((plugin) => plugin.uid !== uid));
520
+ watchRollout();
521
+ }}
522
+ />
523
+ )}
524
+ </>
525
+ );
526
+ }
527
+
528
+ const ALL_NAMESPACES = "";
529
+
530
+ /** Searches the configured namespaces' registries for plugin packages, with install and upgrade actions. */
531
+ function PluginBrowser({
532
+ plugins,
533
+ onInstall,
534
+ onUpgrade,
535
+ }: {
536
+ plugins: Plugin[];
537
+ /** Resolves why the plugin couldn't be installed, or `null`. */
538
+ onInstall: (result: PluginSearchResult) => Promise<string | null>;
539
+ onUpgrade: (uid: string, packageVersion: string) => Promise<void>;
540
+ }) {
541
+ const [namespaces, setNamespaces] = useState<PluginNamespace[]>([]);
542
+ const [namespace, setNamespace] = useState(ALL_NAMESPACES);
543
+ const [results, setResults] = useState<PluginSearchResult[] | null>(null);
544
+ const [searching, setSearching] = useState(false);
545
+ const [busyName, setBusyName] = useState<string | null>(null);
546
+ const [error, setError] = useState<string | null>(null);
547
+
548
+ useEffect(() => {
549
+ listPluginNamespaces()
550
+ .then(setNamespaces)
551
+ .catch(() => setNamespaces([]));
552
+ }, []);
553
+
554
+ async function search(e: FormEvent) {
555
+ e.preventDefault();
556
+ setSearching(true);
557
+ setError(null);
558
+ try {
559
+ setResults(await searchPlugins(namespace || undefined));
560
+ } catch (err) {
561
+ setResults(null);
562
+ setError(errorMessage(err, "Could not search for plugins."));
563
+ } finally {
564
+ setSearching(false);
565
+ }
566
+ }
567
+
568
+ // Keep results in step with installs and upgrades made anywhere on the page.
569
+ const rows: PluginSearchResult[] = (results ?? []).map((result) => {
570
+ const installed: Plugin | undefined = plugins.find((plugin) => plugin.name === result.name);
571
+ return {
572
+ ...result,
573
+ installedUid: installed?.uid,
574
+ installedVersion: installed?.packageVersion,
575
+ updateAvailable: !!installed && installed.packageVersion !== result.version && result.updateAvailable,
576
+ };
577
+ });
578
+
579
+ async function install(result: PluginSearchResult) {
580
+ setBusyName(result.name);
581
+ setError(null);
582
+ setError(await onInstall(result));
583
+ setBusyName(null);
584
+ }
585
+
586
+ async function upgrade(result: PluginSearchResult) {
587
+ setBusyName(result.name);
588
+ try {
589
+ await onUpgrade(result.installedUid!, result.version);
590
+ } finally {
591
+ setBusyName(null);
592
+ }
593
+ }
594
+
595
+ return (
596
+ <section aria-labelledby="plugin-browser-title" className="mt-8">
597
+ <h2 id="plugin-browser-title" className="text-sm font-bold uppercase tracking-wide mb-2">
598
+ Find plugins
599
+ </h2>
600
+ <form onSubmit={(e) => void search(e)} className="flex flex-wrap gap-2 items-end mb-3">
601
+ <label className="flex flex-col gap-1.5 text-sm">
602
+ <span className="font-semibold">Namespace</span>
603
+ <select aria-label="Namespace" className={INPUT_CLASS} value={namespace} onChange={(e) => setNamespace(e.target.value)}>
604
+ <option value={ALL_NAMESPACES}>All namespaces</option>
605
+ {namespaces.map((ns) => (
606
+ <option key={ns.name} value={ns.name}>
607
+ {ns.name}
608
+ </option>
609
+ ))}
610
+ </select>
611
+ </label>
612
+ <Button type="submit" className="!w-auto" loading={searching} disabled={searching}>
613
+ Search
614
+ </Button>
615
+ </form>
616
+ {error && <Alert>{error}</Alert>}
617
+ {results !== null &&
618
+ (rows.length === 0 ? (
619
+ <p className="text-sm text-text-muted">No plugins found.</p>
620
+ ) : (
621
+ <div className="overflow-x-auto">
622
+ <table className="w-full text-sm border-collapse">
623
+ <thead>
624
+ <tr>
625
+ {["Package", "Latest version", "Status", ""].map((h) => (
626
+ <th
627
+ key={h}
628
+ className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
629
+ >
630
+ {h}
631
+ </th>
632
+ ))}
633
+ </tr>
634
+ </thead>
635
+ <tbody>
636
+ {rows.map((result) => (
637
+ <tr key={result.name}>
638
+ <td className="py-2.5 px-2.5 border-b border-border align-top">
639
+ <div className="font-semibold">{result.name}</div>
640
+ {result.description && <div className="text-xs text-text-muted max-w-md">{result.description}</div>}
641
+ </td>
642
+ <td className="py-2.5 px-2.5 border-b border-border align-top">{result.version}</td>
643
+ <td className="py-2.5 px-2.5 border-b border-border align-top text-text-muted">
644
+ {result.updateAvailable
645
+ ? `Installed ${result.installedVersion} - update available`
646
+ : result.installedUid
647
+ ? `Installed ${result.installedVersion}`
648
+ : result.allowed
649
+ ? "Not installed"
650
+ : "Not allowed on this server"}
651
+ </td>
652
+ <td className="py-2.5 px-2.5 border-b border-border align-top text-right">
653
+ {result.updateAvailable ? (
654
+ <Button
655
+ type="button"
656
+ variant="secondary"
657
+ className="!w-auto"
658
+ aria-label={`Upgrade ${result.name} to ${result.version}`}
659
+ disabled={busyName === result.name}
660
+ onClick={() => void upgrade(result)}
661
+ >
662
+ Upgrade
663
+ </Button>
664
+ ) : (
665
+ !result.installedUid &&
666
+ result.allowed && (
667
+ <Button
668
+ type="button"
669
+ className="!w-auto"
670
+ aria-label={`Install ${result.name}`}
671
+ loading={busyName === result.name}
672
+ disabled={busyName === result.name}
673
+ onClick={() => void install(result)}
674
+ >
675
+ Install
676
+ </Button>
677
+ )
678
+ )}
679
+ </td>
680
+ </tr>
681
+ ))}
682
+ </tbody>
683
+ </table>
684
+ </div>
685
+ ))}
686
+ </section>
687
+ );
688
+ }
689
+
690
+ /** Name a server reports an error under when it isn't about any single plugin (e.g. the plugin set as a whole failed). */
691
+ const SERVER_WIDE_ERROR_NAME = "*";
692
+
693
+ /**
694
+ * Shown while any server copy hasn't applied the saved plugin set yet, when one is in safe mode, or when one reports a
695
+ * server-wide plugin error - those match no plugin row, so without this they'd never be shown.
696
+ */
697
+ function RolloutBanner({ status }: { status: PluginStatus | null }) {
698
+ if (!status || status.instances.length === 0) {
699
+ return null;
700
+ }
701
+ const behind = status.instances.filter((instance) => instance.hash !== status.hash).length;
702
+ const safeMode = status.instances.filter((instance) => instance.safeMode);
703
+ const serverWideErrors = status.instances.flatMap((instance) =>
704
+ instance.errors
705
+ .filter((entry) => entry.name === SERVER_WIDE_ERROR_NAME)
706
+ .map((entry) => `${instance.instance}: ${entry.message}`),
707
+ );
708
+ return (
709
+ <>
710
+ {serverWideErrors.length > 0 && (
711
+ <Alert>
712
+ Plugins couldn&rsquo;t be loaded:
713
+ <ul className="list-disc pl-5 mt-1">
714
+ {serverWideErrors.map((message) => (
715
+ <li key={message} className="break-words">
716
+ {message}
717
+ </li>
718
+ ))}
719
+ </ul>
720
+ </Alert>
721
+ )}
722
+ {behind > 0 && (
723
+ <p role="status" className="mb-4 text-sm py-2 px-3 rounded-sm bg-surface-alt text-text">
724
+ Applying changes: {status.instances.length - behind} of {status.instances.length}{" "}
725
+ {status.instances.length === 1 ? "server" : "servers"} updated. Servers restart one at a time.
726
+ </p>
727
+ )}
728
+ {safeMode.length > 0 && (
729
+ <Alert>
730
+ {safeMode.map((instance) => instance.instance).join(", ")} started without any plugins because recent
731
+ starts failed. Fix or disable the failing plugin, and the server will pick up the change.
732
+ </Alert>
733
+ )}
734
+ </>
735
+ );
736
+ }
737
+
738
+ function PluginStatusCell({ plugin, status }: { plugin: Plugin; status: PluginStatus | null }) {
739
+ if (!plugin.enabled) {
740
+ return <span className="text-text-muted">Disabled</span>;
741
+ }
742
+ if (!status || status.instances.length === 0) {
743
+ return <span className="text-text-muted">Unknown</span>;
744
+ }
745
+ const current: PluginInstanceStatus[] = status.instances.filter((instance) => instance.hash === status.hash);
746
+ const loaded = current.filter((instance) =>
747
+ instance.loaded.some((entry) => entry.name === plugin.name && entry.version === plugin.packageVersion),
748
+ ).length;
749
+ const errors = current.flatMap((instance) =>
750
+ instance.errors.filter((entry) => entry.name === plugin.name).map((entry) => `${instance.instance}: ${entry.message}`),
751
+ );
752
+ return (
753
+ <div>
754
+ <span className={loaded === status.instances.length ? "text-success font-medium" : "text-text-muted"}>
755
+ Loaded on {loaded} of {status.instances.length} {status.instances.length === 1 ? "server" : "servers"}
756
+ </span>
757
+ {errors.map((message) => (
758
+ <div key={message} className="text-xs text-danger mt-1">
759
+ {message}
760
+ </div>
761
+ ))}
762
+ </div>
763
+ );
764
+ }
765
+
766
+ function AddPluginModal({
767
+ open,
768
+ onClose,
769
+ onAdd,
770
+ }: {
771
+ open: boolean;
772
+ onClose: () => void;
773
+ /** Resolves why the plugin couldn't be added, or `null`. */
774
+ onAdd: (name: string, packageVersion: string, displayName: string) => Promise<string | null>;
775
+ }) {
776
+ const [name, setName] = useState("");
777
+ const [lookup, setLookup] = useState<PluginRegistryLookup | null>(null);
778
+ const [selectedVersion, setSelectedVersion] = useState("");
779
+ const [error, setError] = useState<string | null>(null);
780
+ const [busy, setBusy] = useState(false);
781
+ const [looking, setLooking] = useState(false);
782
+ /** Identifies the latest lookup, so a slower earlier response can't replace what it found. */
783
+ const lookupToken = useRef(0);
784
+ /** Identifies each time the dialog is opened, so an add that finishes after it was closed can't show its result. */
785
+ const openToken = useRef(0);
786
+
787
+ /** Forgets any lookup still in flight. */
788
+ function invalidateLookup() {
789
+ lookupToken.current++;
790
+ setLooking(false);
791
+ }
792
+
793
+ useEffect(() => {
794
+ if (!open) {
795
+ openToken.current++;
796
+ invalidateLookup();
797
+ setName("");
798
+ setLookup(null);
799
+ setSelectedVersion("");
800
+ setError(null);
801
+ setBusy(false);
802
+ }
803
+ }, [open]);
804
+
805
+ /** Looks up `packageName` at `packageVersion` (default: latest). `previous` is what a version change started
806
+ * from - kept, with the failure in place of its manifest, when that version can't be read. */
807
+ async function runLookup(packageName: string, packageVersion?: string, previous?: PluginRegistryLookup) {
808
+ const token = ++lookupToken.current;
809
+ setLooking(true);
810
+ setError(null);
811
+ try {
812
+ const found = await lookupPluginPackage(packageName, packageVersion);
813
+ if (token === lookupToken.current) {
814
+ setLookup(found);
815
+ setSelectedVersion(found.selected.version);
816
+ }
817
+ } catch (err) {
818
+ if (token !== lookupToken.current) {
819
+ return;
820
+ }
821
+ const message = errorMessage(err, "Could not look that package up.");
822
+ if (previous && packageVersion) {
823
+ setLookup({ ...previous, selected: { ...previous.selected, version: packageVersion, manifest: message } });
824
+ } else {
825
+ setLookup(null);
826
+ setError(message);
827
+ }
828
+ } finally {
829
+ if (token === lookupToken.current) {
830
+ setLooking(false);
831
+ }
832
+ }
833
+ }
834
+
835
+ function find(e: FormEvent) {
836
+ e.preventDefault();
837
+ if (!name.trim()) {
838
+ setError("Enter a package name.");
839
+ return;
840
+ }
841
+ void runLookup(name.trim());
842
+ }
843
+
844
+ function chooseVersion(version: string) {
845
+ setSelectedVersion(version);
846
+ // The manifest shown, and whether it's a loadable plugin, depend on the version.
847
+ void runLookup(lookup!.package.name, version, lookup!);
848
+ }
849
+
850
+ const manifest = lookup?.selected.manifest;
851
+
852
+ async function add() {
853
+ const token: number = openToken.current;
854
+ setBusy(true);
855
+ setError(null);
856
+ const name: string = lookup!.package.name;
857
+ const problem = await onAdd(name, selectedVersion, typeof manifest === "object" ? manifest.displayName : name);
858
+ if (token === openToken.current) {
859
+ setError(problem);
860
+ setBusy(false);
861
+ }
862
+ }
863
+
864
+ return (
865
+ <Modal open={open} onClose={onClose} title="Add plugin">
866
+ {error && <Alert>{error}</Alert>}
867
+ <form onSubmit={find} className="flex gap-2 items-end mb-4">
868
+ <label className="flex flex-col gap-1.5 text-sm flex-1">
869
+ <span className="font-semibold">Package name</span>
870
+ <input
871
+ aria-label="Package name"
872
+ className={INPUT_CLASS}
873
+ value={name}
874
+ placeholder="@rapidmx/activesync-plugin"
875
+ onChange={(e) => {
876
+ setName(e.target.value);
877
+ invalidateLookup();
878
+ setLookup(null);
879
+ }}
880
+ />
881
+ </label>
882
+ <Button type="submit" variant="secondary" className="!w-auto" loading={looking && !lookup} disabled={busy || looking}>
883
+ Find
884
+ </Button>
885
+ </form>
886
+ {lookup && (
887
+ <div className="flex flex-col gap-3">
888
+ {typeof manifest === "string" ? (
889
+ <>
890
+ <div className="text-sm font-semibold">{lookup.package.name}</div>
891
+ <Alert>{manifest}</Alert>
892
+ </>
893
+ ) : (
894
+ <div className="text-sm">
895
+ <div className="font-semibold">{manifest?.displayName}</div>
896
+ <div className="text-xs text-text-muted">{lookup.package.name}</div>
897
+ {manifest?.description && <div className="text-text-muted">{manifest.description}</div>}
898
+ </div>
899
+ )}
900
+ <label className="flex flex-col gap-1.5 text-sm">
901
+ <span className="font-semibold">Version</span>
902
+ <select aria-label="Version" className={INPUT_CLASS} value={selectedVersion} onChange={(e) => chooseVersion(e.target.value)}>
903
+ {lookup.package.versions.map((version) => (
904
+ <option key={version} value={version}>
905
+ {version}
906
+ {version === lookup.package.latest ? " (latest)" : ""}
907
+ </option>
908
+ ))}
909
+ </select>
910
+ </label>
911
+ <div className="flex gap-2 justify-end">
912
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
913
+ Cancel
914
+ </Button>
915
+ <Button
916
+ type="button"
917
+ className="!w-auto"
918
+ loading={busy || looking}
919
+ disabled={busy || looking || typeof manifest === "string"}
920
+ onClick={() => void add()}
921
+ >
922
+ Add plugin
923
+ </Button>
924
+ </div>
925
+ </div>
926
+ )}
927
+ </Modal>
928
+ );
929
+ }
930
+
931
+ function ChangeVersionModal({
932
+ plugin,
933
+ onClose,
934
+ onSave,
935
+ }: {
936
+ plugin: Plugin;
937
+ onClose: () => void;
938
+ /** Resolves why the version couldn't be changed, or `null`. */
939
+ onSave: (packageVersion: string) => Promise<string | null>;
940
+ }) {
941
+ const [versions, setVersions] = useState<string[] | null>(null);
942
+ const [latest, setLatest] = useState<string | undefined>();
943
+ const [selected, setSelected] = useState(plugin.packageVersion);
944
+ const [error, setError] = useState<string | null>(null);
945
+ const [busy, setBusy] = useState(false);
946
+
947
+ useEffect(() => {
948
+ lookupPluginPackage(plugin.name)
949
+ .then((found) => {
950
+ setVersions(found.package.versions);
951
+ setLatest(found.package.latest);
952
+ })
953
+ .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
954
+ }, [plugin.name]);
955
+
956
+ async function save() {
957
+ setBusy(true);
958
+ setError(null);
959
+ const problem = await onSave(selected);
960
+ if (problem) {
961
+ setError(problem);
962
+ setBusy(false);
963
+ }
964
+ }
965
+
966
+ return (
967
+ <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
968
+ {error && <Alert>{error}</Alert>}
969
+ {versions === null ? (
970
+ !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
971
+ ) : (
972
+ <div className="flex flex-col gap-3">
973
+ <label className="flex flex-col gap-1.5 text-sm">
974
+ <span className="font-semibold">Version</span>
975
+ <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
976
+ {versions.map((version) => (
977
+ <option key={version} value={version}>
978
+ {version}
979
+ {version === latest ? " (latest)" : ""}
980
+ {version === plugin.packageVersion ? " (installed)" : ""}
981
+ </option>
982
+ ))}
983
+ </select>
984
+ </label>
985
+ <div className="flex gap-2 justify-end">
986
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
987
+ Cancel
988
+ </Button>
989
+ <Button
990
+ type="button"
991
+ className="!w-auto"
992
+ loading={busy}
993
+ disabled={busy || selected === plugin.packageVersion}
994
+ onClick={() => void save()}
995
+ >
996
+ Save
997
+ </Button>
998
+ </div>
999
+ </div>
1000
+ )}
1001
+ </Modal>
1002
+ );
1003
+ }
1004
+
1005
+ /** Lists the plugins a change also installs or enables, and makes the change once confirmed. */
1006
+ function ConfirmDependenciesModal({
1007
+ change,
1008
+ displayNameOf,
1009
+ onClose,
1010
+ onDone,
1011
+ }: {
1012
+ change: PendingChange;
1013
+ displayNameOf: (name: string) => string;
1014
+ onClose: () => void;
1015
+ onDone: () => void;
1016
+ }) {
1017
+ const [error, setError] = useState<string | null>(null);
1018
+ const [busy, setBusy] = useState(false);
1019
+ const { plan } = change;
1020
+
1021
+ async function proceed() {
1022
+ setBusy(true);
1023
+ setError(null);
1024
+ try {
1025
+ await change.apply(plan);
1026
+ onDone();
1027
+ } catch (err) {
1028
+ setError(errorMessage(err, change.failure));
1029
+ setBusy(false);
1030
+ }
1031
+ }
1032
+
1033
+ return (
1034
+ <Modal open onClose={onClose} title={`${change.displayName} requires other plugins`}>
1035
+ {error && <Alert>{error}</Alert>}
1036
+ <p className="text-sm mb-2">
1037
+ {change.displayName} {plan.plugin.version} needs these plugins, so they&apos;ll be changed too:
1038
+ </p>
1039
+ <ul className="text-sm list-disc pl-5 mb-4">
1040
+ {plan.install.map((dependency) => (
1041
+ <li key={dependency.name}>
1042
+ Install {dependency.manifest.displayName} {dependency.version}
1043
+ </li>
1044
+ ))}
1045
+ {plan.enable.map((name) => (
1046
+ <li key={name}>Enable {displayNameOf(name)}</li>
1047
+ ))}
1048
+ </ul>
1049
+ <div className="flex gap-2 justify-end">
1050
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1051
+ Cancel
1052
+ </Button>
1053
+ <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void proceed()}>
1054
+ Continue
1055
+ </Button>
1056
+ </div>
1057
+ </Modal>
1058
+ );
1059
+ }
1060
+
1061
+ /** Whether a required select has no value to fall back on, so the form has to pick (and save) one. */
1062
+ function needsSelection(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): boolean {
1063
+ return (
1064
+ definition.type === "select" &&
1065
+ !!definition.required &&
1066
+ saved === undefined &&
1067
+ definition.default === undefined &&
1068
+ (definition.options ?? []).length > 0
1069
+ );
1070
+ }
1071
+
1072
+ /** A setting's current form value: its saved value, else its default - or, for a required select with neither, its
1073
+ * first option. Kept as a string for text inputs. */
1074
+ function initialValue(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined): PluginSettingValue | "" {
1075
+ if (needsSelection(definition, saved)) {
1076
+ return definition.options![0].value;
1077
+ }
1078
+ const value = saved ?? definition.default;
1079
+ if (definition.type === "boolean") {
1080
+ return value === true;
1081
+ }
1082
+ return value === undefined ? "" : String(value);
1083
+ }
1084
+
1085
+ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
1086
+ // Only opened from the Settings button, which is only shown for a plugin that declares settings.
1087
+ const definitions: PluginSettingDefinition[] = plugin.manifest.settings!;
1088
+ const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
1089
+ const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
1090
+ const [error, setError] = useState<string | null>(null);
1091
+ const [busy, setBusy] = useState(false);
1092
+
1093
+ async function save(e: FormEvent) {
1094
+ e.preventDefault();
1095
+ // The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
1096
+ // - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
1097
+ // A required select with nothing to fall back on is sent as the form picked it.
1098
+ const settings: Record<string, PluginSettingValue | null> = {};
1099
+ let changed = false;
1100
+ for (const definition of definitions) {
1101
+ const key: string = definition.key;
1102
+ const value = values[key];
1103
+ if (value === initial.current[key] && !needsSelection(definition, plugin.settings[key])) {
1104
+ settings[key] = plugin.settings[key] ?? null;
1105
+ continue;
1106
+ }
1107
+ changed = true;
1108
+ // A number input only ever reports a valid number or an empty string.
1109
+ settings[key] = value === "" ? null : definition.type === "number" ? Number(value) : value;
1110
+ }
1111
+ if (!changed) {
1112
+ onClose();
1113
+ return;
1114
+ }
1115
+ setBusy(true);
1116
+ setError(null);
1117
+ try {
1118
+ onSaved(await updatePlugin(plugin.uid, { version: plugin.version, settings }));
1119
+ } catch (err) {
1120
+ setError(errorMessage(err, "Could not save the settings."));
1121
+ } finally {
1122
+ setBusy(false);
1123
+ }
1124
+ }
1125
+
1126
+ return (
1127
+ <Modal open onClose={onClose} title={`${plugin.manifest.displayName} settings`}>
1128
+ {error && <Alert>{error}</Alert>}
1129
+ <form onSubmit={save} className="flex flex-col gap-4">
1130
+ {definitions.map((definition) => (
1131
+ <SettingField
1132
+ key={definition.key}
1133
+ definition={definition}
1134
+ value={values[definition.key]}
1135
+ onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
1136
+ />
1137
+ ))}
1138
+ <p className="text-xs text-text-muted">Saving restarts the servers one at a time to apply the new settings.</p>
1139
+ <div className="flex gap-2 justify-end">
1140
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1141
+ Cancel
1142
+ </Button>
1143
+ <Button type="submit" className="!w-auto" loading={busy} disabled={busy}>
1144
+ Save
1145
+ </Button>
1146
+ </div>
1147
+ </form>
1148
+ </Modal>
1149
+ );
1150
+ }
1151
+
1152
+ function SettingField({
1153
+ definition,
1154
+ value,
1155
+ onChange,
1156
+ }: {
1157
+ definition: PluginSettingDefinition;
1158
+ value: PluginSettingValue | "";
1159
+ onChange: (value: PluginSettingValue | "") => void;
1160
+ }) {
1161
+ const help = definition.help && <span className="text-xs text-text-muted">{definition.help}</span>;
1162
+ if (definition.type === "boolean") {
1163
+ return (
1164
+ <label className="flex flex-col gap-1 text-sm">
1165
+ <span className="inline-flex items-center gap-2 font-semibold">
1166
+ <input type="checkbox" checked={value === true} onChange={(e) => onChange(e.target.checked)} />
1167
+ {definition.label}
1168
+ </span>
1169
+ {help}
1170
+ </label>
1171
+ );
1172
+ }
1173
+ return (
1174
+ <label className="flex flex-col gap-1.5 text-sm">
1175
+ <span className="font-semibold">{definition.label}</span>
1176
+ {definition.type === "select" ? (
1177
+ <select aria-label={definition.label} className={INPUT_CLASS} value={String(value)} onChange={(e) => onChange(e.target.value)}>
1178
+ {!definition.required && <option value="">Default</option>}
1179
+ {(definition.options ?? []).map((option) => (
1180
+ <option key={option.value} value={option.value}>
1181
+ {option.label}
1182
+ </option>
1183
+ ))}
1184
+ </select>
1185
+ ) : (
1186
+ <input
1187
+ aria-label={definition.label}
1188
+ className={INPUT_CLASS}
1189
+ type={definition.type === "number" ? "number" : "text"}
1190
+ min={definition.min}
1191
+ max={definition.max}
1192
+ required={definition.required}
1193
+ value={String(value)}
1194
+ onChange={(e) => onChange(e.target.value)}
1195
+ />
1196
+ )}
1197
+ {help}
1198
+ </label>
1199
+ );
1200
+ }
1201
+
1202
+ function RemoveModal({ plugin, onClose, onRemoved }: { plugin: Plugin; onClose: () => void; onRemoved: () => void }) {
1203
+ const [error, setError] = useState<string | null>(null);
1204
+ const [busy, setBusy] = useState(false);
1205
+
1206
+ async function remove() {
1207
+ setBusy(true);
1208
+ setError(null);
1209
+ try {
1210
+ await removePlugin(plugin.uid);
1211
+ onRemoved();
1212
+ } catch (err) {
1213
+ setError(errorMessage(err, "Could not uninstall the plugin."));
1214
+ setBusy(false);
1215
+ }
1216
+ }
1217
+
1218
+ return (
1219
+ <Modal open onClose={onClose} title={`Uninstall ${plugin.manifest.displayName}?`}>
1220
+ {error && <Alert>{error}</Alert>}
1221
+ <p className="text-sm mb-4">
1222
+ The servers stop running this plugin after they restart. Data it stored stays in the database, and adding
1223
+ the plugin again brings it back.
1224
+ </p>
1225
+ <div className="flex gap-2 justify-end">
1226
+ <Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
1227
+ Cancel
1228
+ </Button>
1229
+ <Button type="button" className="!w-auto" loading={busy} disabled={busy} onClick={() => void remove()}>
1230
+ Uninstall
1231
+ </Button>
1232
+ </div>
1233
+ </Modal>
1234
+ );
1235
+ }