@rapidmx/web-client 0.8.0 → 0.10.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.
- package/README.md +192 -88
- package/apps/shared/auth/accountUrl.ts +9 -0
- package/apps/shared/auth/adminAccess.ts +99 -0
- package/apps/shared/components/admin/elevation.ts +61 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +73 -7
- package/apps/shared/components/admin/settings/BrandingForm.tsx +2 -1
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +87 -24
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +125 -115
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +15 -3
- package/apps/shared/components/layout/AppShell.tsx +411 -288
- package/apps/shared/components/layout/BrandingChrome.tsx +3 -2
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -389
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -126
- package/apps/shared/components/layout/UserMenu.tsx +303 -150
- package/apps/shared/components/mail/ConversationList.tsx +269 -253
- package/apps/shared/components/mail/ConversationThreadPane.tsx +475 -434
- package/apps/shared/components/mail/LazyReadingPane.tsx +115 -0
- package/apps/shared/components/mail/MailAddress.tsx +88 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +235 -222
- package/apps/shared/components/mail/MessageDetailPane.tsx +1458 -1383
- package/apps/shared/components/mail/NewMailToasts.tsx +141 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +279 -160
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -418
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1842 -1700
- package/apps/shared/components/mail/compose/ComposeWindowPlaceholder.tsx +111 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -118
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +48 -0
- package/apps/shared/components/mail/compose/composePerf.ts +46 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -100
- package/apps/shared/components/mail/layout/MailShell.tsx +476 -445
- package/apps/shared/components/mail/unreadStyle.tsx +75 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +252 -240
- package/apps/shared/components/tasks/layout/TasksShell.tsx +15 -3
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -0
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -0
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -0
- package/apps/shared/keyboard/dispatch.ts +124 -0
- package/apps/shared/keyboard/format.ts +89 -0
- package/apps/shared/keyboard/keymap.ts +114 -0
- package/apps/shared/keyboard/match.ts +44 -0
- package/apps/shared/keyboard/parse.ts +136 -0
- package/apps/shared/keyboard/platform.ts +34 -0
- package/apps/shared/keyboard/registry.ts +65 -0
- package/apps/shared/keyboard/targets.ts +79 -0
- package/apps/shared/keyboard/useShortcut.ts +50 -0
- package/apps/shared/keyboard/useShortcutProps.ts +17 -0
- package/apps/shared/mail/folderCounts.ts +302 -0
- package/apps/shared/mail/listSnapshots.ts +87 -0
- package/apps/shared/mail/mergeFirstPage.ts +47 -0
- package/apps/shared/mail/messageReadState.ts +85 -0
- package/apps/shared/mail/newMailNotifications.ts +183 -0
- package/apps/shared/mail/useMailConnection.ts +150 -0
- package/apps/shared/mail/useMailLiveUpdates.ts +232 -0
- package/apps/shared/mail/useMarkMessageRead.ts +47 -0
- package/apps/shared/mail/useNewMailNotifications.ts +163 -0
- package/apps/shared/mail/useUnreadTitle.ts +42 -0
- package/apps/shared/navigation/AppRouter.tsx +300 -0
- package/apps/shared/navigation/appHrefs.ts +23 -0
- package/apps/shared/navigation/frameContext.tsx +35 -0
- package/apps/shared/navigation/idle.ts +45 -0
- package/apps/shared/navigation/routerContext.tsx +83 -0
- package/apps/shared/navigation/routes.ts +72 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -98
- package/apps/shared/styles/app.css +28 -10
- package/apps/www/_routedPage.tsx +24 -0
- package/apps/www/_routes.ts +35 -0
- package/apps/www/calendar/index.tsx +55 -19
- package/apps/www/contacts/[uid].tsx +112 -107
- package/apps/www/contacts/index.tsx +584 -567
- package/apps/www/index.tsx +2280 -1854
- package/apps/www/messages/[uid].tsx +106 -101
- package/apps/www/settings/auto-reply/index.tsx +4 -1
- package/apps/www/settings/encryption/index.tsx +1252 -1249
- package/apps/www/settings/filters/[uid].tsx +4 -1
- package/apps/www/settings/filters/index.tsx +102 -99
- package/apps/www/settings/filters/new/index.tsx +138 -133
- package/apps/www/settings/labels/index.tsx +204 -201
- package/apps/www/settings/privacy/index.tsx +4 -1
- package/apps/www/settings/read-receipts/index.tsx +4 -1
- package/apps/www/settings/sharing/index.tsx +277 -274
- package/apps/www/settings/signatures/[uid].tsx +170 -167
- package/apps/www/settings/signatures/index.tsx +88 -85
- package/apps/www/settings/signatures/new/index.tsx +134 -129
- package/apps/www/tasks/index.tsx +18 -2
- package/dist/apps/shared/auth/accountUrl.d.ts +2 -0
- package/dist/apps/shared/auth/accountUrl.js +8 -0
- package/dist/apps/shared/auth/adminAccess.d.ts +30 -0
- package/dist/apps/shared/auth/adminAccess.js +89 -0
- package/dist/apps/shared/components/admin/elevation.d.ts +24 -0
- package/dist/apps/shared/components/admin/elevation.js +56 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +12 -4
- package/dist/apps/shared/components/admin/layout/AdminShell.js +49 -6
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +1 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +31 -13
- package/dist/apps/shared/components/calendar/layout/CalendarShell.d.ts +1 -1
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +8 -4
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +7 -4
- package/dist/apps/shared/components/contacts/layout/ContactsShell.d.ts +1 -1
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +15 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +29 -4
- package/dist/apps/shared/components/layout/AppShell.js +73 -14
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +3 -2
- package/dist/apps/shared/components/layout/BrandingChrome.js +3 -2
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -9
- package/dist/apps/shared/components/layout/MailboxProvisioning.d.ts +5 -2
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +45 -8
- package/dist/apps/shared/components/layout/UserMenu.d.ts +28 -9
- package/dist/apps/shared/components/layout/UserMenu.js +87 -14
- package/dist/apps/shared/components/mail/ConversationList.js +9 -13
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +53 -28
- package/dist/apps/shared/components/mail/LazyReadingPane.d.ts +9 -0
- package/dist/apps/shared/components/mail/LazyReadingPane.js +82 -0
- package/dist/apps/shared/components/mail/MailAddress.d.ts +27 -0
- package/dist/apps/shared/components/mail/MailAddress.js +39 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +4 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +7 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +87 -36
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +19 -0
- package/dist/apps/shared/components/mail/NewMailToasts.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +30 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +71 -5
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +4 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +122 -27
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.d.ts +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindowPlaceholder.js +33 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +9 -1
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +20 -2
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +11 -0
- package/dist/apps/shared/components/mail/compose/composePerf.d.ts +16 -0
- package/dist/apps/shared/components/mail/compose/composePerf.js +41 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +13 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.js +67 -11
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +19 -4
- package/dist/apps/shared/components/mail/layout/MailShell.js +85 -85
- package/dist/apps/shared/components/mail/unreadStyle.d.ts +45 -0
- package/dist/apps/shared/components/mail/unreadStyle.js +61 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +1 -1
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +15 -5
- package/dist/apps/shared/components/tasks/layout/TasksShell.d.ts +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +15 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.d.ts +14 -0
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +37 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.d.ts +20 -0
- package/dist/apps/shared/keyboard/ShortcutProvider.js +52 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.d.ts +14 -0
- package/dist/apps/shared/keyboard/ShortcutsDialog.js +42 -0
- package/dist/apps/shared/keyboard/dispatch.d.ts +16 -0
- package/dist/apps/shared/keyboard/dispatch.js +109 -0
- package/dist/apps/shared/keyboard/format.d.ts +12 -0
- package/dist/apps/shared/keyboard/format.js +74 -0
- package/dist/apps/shared/keyboard/keymap.d.ts +294 -0
- package/dist/apps/shared/keyboard/keymap.js +84 -0
- package/dist/apps/shared/keyboard/match.d.ts +20 -0
- package/dist/apps/shared/keyboard/match.js +31 -0
- package/dist/apps/shared/keyboard/parse.d.ts +31 -0
- package/dist/apps/shared/keyboard/parse.js +107 -0
- package/dist/apps/shared/keyboard/platform.d.ts +15 -0
- package/dist/apps/shared/keyboard/platform.js +22 -0
- package/dist/apps/shared/keyboard/registry.d.ts +39 -0
- package/dist/apps/shared/keyboard/registry.js +33 -0
- package/dist/apps/shared/keyboard/targets.d.ts +14 -0
- package/dist/apps/shared/keyboard/targets.js +67 -0
- package/dist/apps/shared/keyboard/useShortcut.d.ts +19 -0
- package/dist/apps/shared/keyboard/useShortcut.js +35 -0
- package/dist/apps/shared/keyboard/useShortcutProps.d.ts +10 -0
- package/dist/apps/shared/keyboard/useShortcutProps.js +15 -0
- package/dist/apps/shared/mail/folderCounts.d.ts +78 -0
- package/dist/apps/shared/mail/folderCounts.js +212 -0
- package/dist/apps/shared/mail/listSnapshots.d.ts +46 -0
- package/dist/apps/shared/mail/listSnapshots.js +43 -0
- package/dist/apps/shared/mail/mergeFirstPage.d.ts +23 -0
- package/dist/apps/shared/mail/mergeFirstPage.js +30 -0
- package/dist/apps/shared/mail/messageReadState.d.ts +32 -0
- package/dist/apps/shared/mail/messageReadState.js +63 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +62 -0
- package/dist/apps/shared/mail/newMailNotifications.js +138 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +55 -0
- package/dist/apps/shared/mail/useMailConnection.js +96 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +55 -0
- package/dist/apps/shared/mail/useMailLiveUpdates.js +182 -0
- package/dist/apps/shared/mail/useMarkMessageRead.d.ts +12 -0
- package/dist/apps/shared/mail/useMarkMessageRead.js +44 -0
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +41 -0
- package/dist/apps/shared/mail/useNewMailNotifications.js +110 -0
- package/dist/apps/shared/mail/useUnreadTitle.d.ts +18 -0
- package/dist/apps/shared/mail/useUnreadTitle.js +31 -0
- package/dist/apps/shared/navigation/AppRouter.d.ts +52 -0
- package/dist/apps/shared/navigation/AppRouter.js +242 -0
- package/dist/apps/shared/navigation/appHrefs.d.ts +14 -0
- package/dist/apps/shared/navigation/appHrefs.js +20 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +19 -0
- package/dist/apps/shared/navigation/frameContext.js +24 -0
- package/dist/apps/shared/navigation/idle.d.ts +14 -0
- package/dist/apps/shared/navigation/idle.js +43 -0
- package/dist/apps/shared/navigation/routerContext.d.ts +37 -0
- package/dist/apps/shared/navigation/routerContext.js +56 -0
- package/dist/apps/shared/navigation/routes.d.ts +32 -0
- package/dist/apps/shared/navigation/routes.js +37 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +17 -3
- package/dist/apps/shared/styles/app.css +28 -10
- package/dist/apps/www/_routedPage.d.ts +12 -0
- package/dist/apps/www/_routedPage.js +19 -0
- package/dist/apps/www/_routes.d.ts +11 -0
- package/dist/apps/www/_routes.js +29 -0
- package/dist/apps/www/calendar/index.d.ts +2 -2
- package/dist/apps/www/calendar/index.js +39 -8
- package/dist/apps/www/contacts/[uid].d.ts +3 -9
- package/dist/apps/www/contacts/[uid].js +6 -2
- package/dist/apps/www/contacts/index.d.ts +2 -2
- package/dist/apps/www/contacts/index.js +17 -4
- package/dist/apps/www/index.d.ts +2 -2
- package/dist/apps/www/index.js +361 -34
- package/dist/apps/www/messages/[uid].d.ts +3 -7
- package/dist/apps/www/messages/[uid].js +7 -4
- package/dist/apps/www/settings/auto-reply/index.d.ts +2 -2
- package/dist/apps/www/settings/auto-reply/index.js +3 -1
- package/dist/apps/www/settings/encryption/index.d.ts +2 -2
- package/dist/apps/www/settings/encryption/index.js +3 -1
- package/dist/apps/www/settings/filters/[uid].d.ts +2 -2
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/index.js +3 -1
- package/dist/apps/www/settings/filters/new/index.d.ts +2 -2
- package/dist/apps/www/settings/filters/new/index.js +6 -2
- package/dist/apps/www/settings/labels/index.d.ts +2 -2
- package/dist/apps/www/settings/labels/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.d.ts +2 -2
- package/dist/apps/www/settings/privacy/index.js +3 -1
- package/dist/apps/www/settings/read-receipts/index.d.ts +2 -2
- package/dist/apps/www/settings/read-receipts/index.js +3 -1
- package/dist/apps/www/settings/sharing/index.d.ts +2 -2
- package/dist/apps/www/settings/sharing/index.js +3 -1
- package/dist/apps/www/settings/signatures/[uid].d.ts +2 -2
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/index.js +3 -1
- package/dist/apps/www/settings/signatures/new/index.d.ts +2 -2
- package/dist/apps/www/settings/signatures/new/index.js +6 -2
- package/dist/apps/www/tasks/index.d.ts +2 -2
- package/dist/apps/www/tasks/index.js +15 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,88 +1,192 @@
|
|
|
1
|
-
# RapidMX: Web Client
|
|
2
|
-
|
|
3
|
-
[](https://www.npmjs.com/package/@rapidmx/web-client)
|
|
4
|
-
|
|
5
|
-
RapidMX's webmail (`apps/www`), admin console (`apps/admin`) and escrow console (`apps/escrow`) React UI. The pages are
|
|
6
|
-
served and hydrated by [`rapidmx/server`](https://github.com/RapidMX/server) through `@rapidrest/react`'s file-convention
|
|
7
|
-
routes, and `@rapidmx/electron-client` reuses the same components. Platform-agnostic API clients, hooks and generic UI
|
|
8
|
-
primitives live in [`@rapidmx/react-shared`](https://github.com/RapidMX/react-shared).
|
|
9
|
-
|
|
10
|
-
## Package layout
|
|
11
|
-
|
|
12
|
-
The package ships the TSX sources (`apps/`) and a compiled mirror (`dist/apps/`, JavaScript plus `.d.ts` declarations).
|
|
13
|
-
There is no root export. Every module is its own subpath, mapped by `package.json`'s `exports` from
|
|
14
|
-
`@rapidmx/web-client/<path>.js` to `dist/apps/<path>.js`:
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
import SettingsShell from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
`@rapidmx/web-client/shared/styles/app.css` is the Tailwind entry point and design tokens.
|
|
21
|
-
|
|
22
|
-
##
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
1
|
+
# RapidMX: Web Client
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@rapidmx/web-client)
|
|
4
|
+
|
|
5
|
+
RapidMX's webmail (`apps/www`), admin console (`apps/admin`) and escrow console (`apps/escrow`) React UI. The pages are
|
|
6
|
+
served and hydrated by [`rapidmx/server`](https://github.com/RapidMX/server) through `@rapidrest/react`'s file-convention
|
|
7
|
+
routes, and `@rapidmx/electron-client` reuses the same components. Platform-agnostic API clients, hooks and generic UI
|
|
8
|
+
primitives live in [`@rapidmx/react-shared`](https://github.com/RapidMX/react-shared).
|
|
9
|
+
|
|
10
|
+
## Package layout
|
|
11
|
+
|
|
12
|
+
The package ships the TSX sources (`apps/`) and a compiled mirror (`dist/apps/`, JavaScript plus `.d.ts` declarations).
|
|
13
|
+
There is no root export. Every module is its own subpath, mapped by `package.json`'s `exports` from
|
|
14
|
+
`@rapidmx/web-client/<path>.js` to `dist/apps/<path>.js`:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import SettingsShell from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`@rapidmx/web-client/shared/styles/app.css` is the Tailwind entry point and design tokens.
|
|
21
|
+
|
|
22
|
+
## Navigation without page loads
|
|
23
|
+
|
|
24
|
+
`@rapidrest/react` has no router: every page is a server-rendered document that hydrates only its own page component. The
|
|
25
|
+
`www` pages (Mail, Calendar, Contacts, Tasks, Settings and their subpages) therefore render a small client-side router
|
|
26
|
+
themselves, and go between one another - and between the folders of Mail - without a page load:
|
|
27
|
+
|
|
28
|
+
- each `apps/www` page's default export is `routedPage("/its/route", Page)` (`apps/www/_routedPage.tsx`). The server
|
|
29
|
+
renders and the browser hydrates exactly what it did before; the first load is unchanged. `apps/www/_routes.ts` lists the
|
|
30
|
+
pages, each with a dynamic `import()` so it is a chunk of its own (a test keeps it in step with the files);
|
|
31
|
+
- one `AppShell` chrome (the app rail, header, user menu, impersonation banner, compose windows in progress, the unlock
|
|
32
|
+
prompt and the idle-key timer) stays mounted, and only the page inside it is replaced. A page's own shell
|
|
33
|
+
(`MailShell`, `CalendarShell`, ...) still renders `AppShell`; inside the router that is only its children;
|
|
34
|
+
- **links stay ordinary links.** Every same-origin `<a href>` to a route in the table is taken over - plain left clicks
|
|
35
|
+
only, so ctrl/cmd/shift/middle click, `target`, `download`, `#hash` links and links marked `data-full-reload` keep
|
|
36
|
+
the browser's behaviour, and pages that are not in the table (the admin and escrow consoles, plugin pages, other sites) are
|
|
37
|
+
reached with a page load. The address bar always holds the real, shareable URL (`/?mailboxUid=&folderUid=`), and back and
|
|
38
|
+
forward work;
|
|
39
|
+
- a page's code is fetched when the pointer, focus or a press reaches a link to it, and for the app rail's pages when the
|
|
40
|
+
browser is idle after load (not with data saving on); if it can't be loaded the router falls back to a page load;
|
|
41
|
+
- after a page change focus moves to the content region (`#app-content`), the window scrolls to the top, `document.title`
|
|
42
|
+
follows the page and a polite live region announces it.
|
|
43
|
+
|
|
44
|
+
Code that decides where to go uses the two hooks in `shared/navigation/AppRouter.js`:
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { useLocation, useNavigate } from "@rapidmx/web-client/shared/navigation/AppRouter.js";
|
|
48
|
+
|
|
49
|
+
const navigate = useNavigate(); // navigate("/contacts"), navigate("/?mailboxUid=a&folderUid=b", { replace: true })
|
|
50
|
+
const { pathname, search, hash } = useLocation(); // empty until read after the first render, so server and browser agree
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`navigate()` changes the page without a load when the URL is a route of the app and is an ordinary navigation otherwise
|
|
54
|
+
(outside the router too, where it is `window.location.href = ...`). Page props are the same for every `www` page except
|
|
55
|
+
`params`, which the router recomputes from the URL. Plugin pages are not part of the router (they render their own
|
|
56
|
+
`AppShell` chrome) and are opened with a page load.
|
|
57
|
+
|
|
58
|
+
The compose window, the reading pane, S/MIME and the emoji list are also chunks of their own, loaded on demand or fetched
|
|
59
|
+
when the browser is idle, so a page's first JavaScript is React and what the first screen draws.
|
|
60
|
+
|
|
61
|
+
## Keyboard shortcuts
|
|
62
|
+
|
|
63
|
+
One keyboard layer, `shared/keyboard/`, lives in the persistent app frame (`AppChrome`), so the shortcuts work in every view -
|
|
64
|
+
Mail, Calendar, Contacts, Tasks and Settings - and a page change only changes which of them are registered. `?` (or `Ctrl+/`)
|
|
65
|
+
opens a "Keyboard shortcuts" dialog, also reached from the account menu, that lists what is available *here* - the global
|
|
66
|
+
shortcuts plus those of the view on screen (and of an open compose window) - with the platform's own key names. `mod` below is
|
|
67
|
+
**Ctrl** on Windows and Linux and **Cmd** on a Mac; the navigation set is Ctrl+Shift on every platform (Cmd+Shift collides with the
|
|
68
|
+
browsers' own, so it is not offered).
|
|
69
|
+
|
|
70
|
+
| Where | Key | Does |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| Everywhere | `Ctrl+Shift+A` | Account (auth-server's account page; only offered when one is configured) |
|
|
73
|
+
| | `Ctrl+Shift+S` / `B` / `M` / `C` / `L` | Settings / Contacts / Mail / Calendar / To-Do |
|
|
74
|
+
| | `?` or `Ctrl+/` | Keyboard shortcuts |
|
|
75
|
+
| Mail | `Alt+N` | New message |
|
|
76
|
+
| | `mod+R` / `mod+Shift+R` / `mod+Shift+F` | Reply / Reply all / Forward the selected message |
|
|
77
|
+
| | `mod+D` or `Delete` | Delete the selected message or conversation (moves it to Deleted Items) |
|
|
78
|
+
| | `E` or `Backspace` | Archive |
|
|
79
|
+
| | `mod+Shift+V` | Move to folder |
|
|
80
|
+
| | `Ctrl+Q` / `mod+U` | Mark as read / unread (`Ctrl+Q` on a Mac too: `Cmd+Q` quits) |
|
|
81
|
+
| | `Insert` | Flag or unflag |
|
|
82
|
+
| | `Down` or `J` / `Up` or `K` | Next / previous message (conversation) |
|
|
83
|
+
| | `Ctrl+.` / `Ctrl+,` | Next / previous unread |
|
|
84
|
+
| | `Enter` / `Escape` | Open the selected message on its own page / clear the selection (leave select mode, clear the search) |
|
|
85
|
+
| | `/` or `mod+E` | Search |
|
|
86
|
+
| Compose window | `mod+Enter` | Send |
|
|
87
|
+
| | `mod+S` | Save draft |
|
|
88
|
+
| | `Escape` | Close, keeping the draft (the existing "keep draft / discard" question still applies) |
|
|
89
|
+
| | `Alt+N` | Another new message |
|
|
90
|
+
| Calendar | `Alt+N` | New event |
|
|
91
|
+
| | `T` | Today |
|
|
92
|
+
| | `Left` / `Right` (or `mod+Left` / `mod+Right`) | Previous / next period |
|
|
93
|
+
| | `Ctrl+Alt+1` / `2` / `3` / `4` | Day / work week / week / month |
|
|
94
|
+
| Contacts | `Alt+N` | New contact |
|
|
95
|
+
| | `/` or `mod+E` | Search |
|
|
96
|
+
| Tasks | `Alt+N` | New task (moves to the "Add a task" field) |
|
|
97
|
+
|
|
98
|
+
In the desktop client (`@rapidmx/electron-client`, which exposes `window.rapidmx`) `Ctrl+N` (`Cmd+N` on a Mac) also creates - a new
|
|
99
|
+
message, event, contact or task in the current view - and `Ctrl+Shift+T` also goes to Tasks; browsers keep both for themselves, which is
|
|
100
|
+
why the web client uses `Alt+N` and `Ctrl+Shift+L`.
|
|
101
|
+
|
|
102
|
+
How it behaves:
|
|
103
|
+
|
|
104
|
+
- **Views register only what they can do.** A view claims a shortcut with `useShortcut(SHORTCUTS.mail.reply, handler, { enabled })`
|
|
105
|
+
for as long as it is mounted and able to do it (Reply exists only while a message is selected, Archive not for Drafts or Outbox),
|
|
106
|
+
so there are no dead keys and the help dialog is always accurate. `SHORTCUTS` in `shared/keyboard/keymap.js` is the one key map;
|
|
107
|
+
handlers are looked up when the key is pressed. A handler that returns `false` declines the key.
|
|
108
|
+
- **Scopes.** `global`, the view's own (`mail`, `calendar`, `contacts`, `tasks`), `compose` (while focus is inside a compose window; it
|
|
109
|
+
beats the view behind it) and `dialog`: while a modal dialog (`aria-modal`) is open only its own shortcuts and its own Escape work.
|
|
110
|
+
- **Typing is never taken.** Bare keys (`J`, `E`, `?`, `Delete`) do nothing while focus is in a text field, select or the rich-text
|
|
111
|
+
editor, and caret keys with a modifier (word jumps) stay the field's. Chords with Ctrl/Alt/Cmd work from a field, except Option on
|
|
112
|
+
a Mac and Ctrl+Alt (AltGr) elsewhere, which type characters. Copy, Cut, Paste, Select all, Undo, Redo and Find are never
|
|
113
|
+
bound. Enter and Space are left to a focused button or link, and an open menu keeps its own keys. Events already
|
|
114
|
+
`defaultPrevented` (the editor's own bindings, a menu), IME composition and held-key repeats of one-shot actions are ignored, and
|
|
115
|
+
the browser's default is prevented only when a handler actually ran.
|
|
116
|
+
- **Layouts.** A key is matched on `event.key` (so it follows AZERTY or Dvorak) with `event.code` as the fallback when `event.key` is
|
|
117
|
+
not a Latin character - macOS Option+N, or a Cyrillic layout.
|
|
118
|
+
- **Hints.** Buttons that a shortcut also does carry `aria-keyshortcuts` and a tooltip such as "Reply (Ctrl+R)"; their accessible
|
|
119
|
+
names are unchanged (`useShortcutProps()` gives a control both).
|
|
120
|
+
|
|
121
|
+
Known limits: a browser keeps a few keys for itself (`Ctrl+N`, `Ctrl+T`, `Ctrl+W` are never delivered to a page, and some browsers claim
|
|
122
|
+
`Ctrl+Shift+A/B/C/M/S`); and a message body is shown in a sandboxed iframe, which forwards no key events, so after clicking into a
|
|
123
|
+
message body press Tab or click the list before using a shortcut. In the compose body the editor's own bindings win over `Ctrl+Shift+S`, `B`
|
|
124
|
+
and `L` (strike-through, quote, align left).
|
|
125
|
+
|
|
126
|
+
## Plugin UI surface
|
|
127
|
+
|
|
128
|
+
Server plugins can ship their own pages (see the plugin manifest's `ui` field in `@rapidmx/restapi`). The server builds
|
|
129
|
+
them together with this package, so they share one React, one `@rapidmx/react-shared` state and one stylesheet. The
|
|
130
|
+
modules below are the **supported surface for plugin pages**. Anything else under `apps/` is internal and may change in
|
|
131
|
+
any release.
|
|
132
|
+
|
|
133
|
+
### Shells
|
|
134
|
+
|
|
135
|
+
| Import | Use |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `shared/components/layout/AppShell.js` | Chrome for webmail apps: app rail, header, user menu, impersonation banner, compose and unlock providers. `active` is a core app or the plugin's `appRail` item id. The user menu shows an "Admin Console" item to an administrator even when their session isn't elevated (it asks auth-server for the user's own roles, using the `trustedRoles` page prop) and, in Mail, the new-mail pop-up switch. |
|
|
138
|
+
| `shared/components/settings/layout/SettingsShell.js` | Settings chrome with the section list and mailbox switcher. `active` is the plugin's `settingsSections` item id. `useSettingsShell()` gives the selected `mailboxUid` and the accessible `mailboxes`. |
|
|
139
|
+
| `shared/components/admin/layout/AdminShell.js` | Admin console chrome, gated on administrator access - an administrator whose session isn't elevated is sent to auth-server's `/auth/elevate` page and returned. `active` is the plugin's `adminNav` item id. |
|
|
140
|
+
| `shared/components/layout/BrandingChrome.js` | `BrandingHeader` and `BrandingFooter`, for pages that don't use a shell, such as public pages. |
|
|
141
|
+
| `shared/plugins/pluginNav.js` | The `PluginNav`, `PluginUiNavItem` and `PluginNavProps` types. |
|
|
142
|
+
|
|
143
|
+
Every www and admin page receives a `pluginNav` prop from the server. It lists the settings sections, admin sections and
|
|
144
|
+
app rail entries of every enabled plugin whose UI built. The shells append those entries after their own, with a generic
|
|
145
|
+
icon. An entry whose id matches a core entry is skipped, and so is one whose `href` isn't a same-origin path. Pass the
|
|
146
|
+
page props straight to the shell so the navigation shows:
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
import React from "react";
|
|
150
|
+
import SettingsShell, {
|
|
151
|
+
SettingsShellProps,
|
|
152
|
+
useSettingsShell,
|
|
153
|
+
} from "@rapidmx/web-client/shared/components/settings/layout/SettingsShell.js";
|
|
154
|
+
|
|
155
|
+
export default function RemindersSettingsPage(props: Omit<SettingsShellProps, "active">) {
|
|
156
|
+
return (
|
|
157
|
+
<SettingsShell {...props} active="reminders">
|
|
158
|
+
<RemindersSettings />
|
|
159
|
+
</SettingsShell>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function RemindersSettings() {
|
|
164
|
+
const { mailboxUid } = useSettingsShell();
|
|
165
|
+
return <p>Settings for {mailboxUid}</p>;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Public and escrow pages get no `pluginNav`.
|
|
170
|
+
|
|
171
|
+
### From `@rapidmx/react-shared`
|
|
172
|
+
|
|
173
|
+
Plugin pages import these directly from `@rapidmx/react-shared`, which the server resolves to the same copy the shells
|
|
174
|
+
use:
|
|
175
|
+
|
|
176
|
+
- `branding/useBranding.js`: `useBranding()`, for the branding and icon of pages outside a shell;
|
|
177
|
+
- `auth/session.js`: `useRedirectIfUnauthenticated()`, already called by every shell;
|
|
178
|
+
- `util/api.js`: `apiFetch()` and `ApiRequestError`, for calling the plugin's own API routes;
|
|
179
|
+
- `mail/mailApi.js`: mailboxes and folders, such as `listMailboxes()` and `listFolders()`;
|
|
180
|
+
- `components/buttons/Button.js`, `components/feedback/Alert.js` and `components/feedback/Skeleton.js`;
|
|
181
|
+
- `components/forms/FormField.js`;
|
|
182
|
+
- `components/overlays/Modal.js` and `components/overlays/Drawer.js`;
|
|
183
|
+
- `components/pickers/MiniDatePicker.js`.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
yarn install
|
|
189
|
+
yarn test # vitest with coverage gates
|
|
190
|
+
yarn lint
|
|
191
|
+
yarn build # tsc into dist/apps
|
|
192
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** auth-server's account page, or `undefined` when no auth-server is configured. Tolerates a configured URL with a trailing slash. */
|
|
7
|
+
export function accountUrlOf(authServerUrl: string | undefined): string | undefined {
|
|
8
|
+
return authServerUrl ? `${authServerUrl.replace(/\/+$/, "")}/account` : undefined;
|
|
9
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { authApiFetch } from "@rapidmx/react-shared/util/api.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether the signed-in user holds an administrator role, learned without elevating.
|
|
9
|
+
*
|
|
10
|
+
* The JWT the browser carries can't say: auth-server strips every trusted role from a token that isn't elevated
|
|
11
|
+
* (`TokenUtils.resolveTokenUser()`), so `trusted` (from the JWT) is false for a real administrator until they confirm
|
|
12
|
+
* their identity on auth-server's elevation page. The role itself lives on the caller's own `User` record, which they may
|
|
13
|
+
* always read - `GET {authServerUrl}/api/users/me` (the same call auth-server's own UI makes "to check for admin access") -
|
|
14
|
+
* so the answer is read from there, with the same cross-origin credentials the profile lookups use.
|
|
15
|
+
*
|
|
16
|
+
* This only decides whether a menu item is shown. The `/admin` pages and API stay authoritative: they check the role and
|
|
17
|
+
* the elevation themselves, and the link only navigates (`/admin` then runs the elevation redirect), so a wrong answer
|
|
18
|
+
* - a stale cache, a tampered `sessionStorage` - can show a link that leads to "no administrator access", never more.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The roles the server treats as trusted when it isn't configured otherwise (`trusted_roles`, default `["admin"]`). */
|
|
22
|
+
export const DEFAULT_TRUSTED_ROLES: readonly string[] = ["admin"];
|
|
23
|
+
|
|
24
|
+
/** `sessionStorage` key prefix of a remembered answer; the user's uid follows, so another account signing in in the same
|
|
25
|
+
* tab never inherits it. */
|
|
26
|
+
export const ADMIN_ACCESS_KEY_PREFIX = "rapidmx-admin-access:";
|
|
27
|
+
|
|
28
|
+
/** How long a remembered answer is trusted - long enough that navigating between the apps costs no request, short enough
|
|
29
|
+
* that a role granted or removed shows up within the sitting. */
|
|
30
|
+
export const ADMIN_ACCESS_TTL_MS = 30 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
/** Lookups in flight, by key, so two components asking at once make one request. */
|
|
33
|
+
const lookups = new Map<string, Promise<boolean | undefined>>();
|
|
34
|
+
|
|
35
|
+
/** Forgets the lookups in flight (tests). */
|
|
36
|
+
export function resetAdminAccessLookups(): void {
|
|
37
|
+
lookups.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readRemembered(key: string, now: number): boolean | undefined {
|
|
41
|
+
try {
|
|
42
|
+
const stored = JSON.parse(sessionStorage.getItem(key) ?? "null") as { admin?: unknown; at?: unknown } | null;
|
|
43
|
+
if (typeof stored?.admin === "boolean" && typeof stored.at === "number" && now - stored.at >= 0 && now - stored.at < ADMIN_ACCESS_TTL_MS) {
|
|
44
|
+
return stored.admin;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Unreadable, or storage unavailable - ask again.
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function remember(key: string, admin: boolean, now: number): void {
|
|
53
|
+
try {
|
|
54
|
+
sessionStorage.setItem(key, JSON.stringify({ admin, at: now }));
|
|
55
|
+
} catch {
|
|
56
|
+
// Storage unavailable - the next page load asks again.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolves `true` when `userUid`'s own `User` record on auth-server carries one of `trustedRoles`, `false` when it
|
|
62
|
+
* doesn't, and `undefined` when that couldn't be established (unreachable, blocked by CORS, not signed in, an unexpected
|
|
63
|
+
* body, or a record that isn't this user's). Only a real answer is remembered; a failure is asked again on the next page.
|
|
64
|
+
* Never rejects.
|
|
65
|
+
*/
|
|
66
|
+
export function lookUpAdminAccess(
|
|
67
|
+
authServerUrl: string,
|
|
68
|
+
userUid: string,
|
|
69
|
+
trustedRoles: readonly string[] = DEFAULT_TRUSTED_ROLES,
|
|
70
|
+
now: number = Date.now(),
|
|
71
|
+
): Promise<boolean | undefined> {
|
|
72
|
+
const key = `${ADMIN_ACCESS_KEY_PREFIX}${userUid}`;
|
|
73
|
+
const remembered = readRemembered(key, now);
|
|
74
|
+
if (remembered !== undefined) {
|
|
75
|
+
return Promise.resolve(remembered);
|
|
76
|
+
}
|
|
77
|
+
const inFlight = lookups.get(key);
|
|
78
|
+
if (inFlight) {
|
|
79
|
+
return inFlight;
|
|
80
|
+
}
|
|
81
|
+
const lookup = (async (): Promise<boolean | undefined> => {
|
|
82
|
+
try {
|
|
83
|
+
const user = await authApiFetch<{ uid?: unknown; roles?: unknown } | null>(authServerUrl, "/users/me");
|
|
84
|
+
if (!user || user.uid !== userUid || !Array.isArray(user.roles)) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
const admin = user.roles.some((role) => typeof role === "string" && trustedRoles.includes(role));
|
|
88
|
+
remember(key, admin, now);
|
|
89
|
+
return admin;
|
|
90
|
+
} catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
} finally {
|
|
93
|
+
// Answered: what was learned is in storage; a failure may be asked again.
|
|
94
|
+
lookups.delete(key);
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
lookups.set(key, lookup);
|
|
98
|
+
return lookup;
|
|
99
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `ApiRequestError.code` a `@RequiresElevation()` endpoint answers a caller whose token isn't elevated with
|
|
9
|
+
* (a 403 - `ApiErrors.AUTH_REQUIRES_ELEVATION` in `@rapidrest/service-core`). Distinct from `api-103` (the caller is
|
|
10
|
+
* elevated but lacks the trusted role), which no amount of elevating fixes.
|
|
11
|
+
*/
|
|
12
|
+
export const ELEVATION_REQUIRED_CODE = "api-104";
|
|
13
|
+
|
|
14
|
+
/** `sessionStorage` key holding the time (`Date.now()`) the browser was last sent to auth-server to elevate. */
|
|
15
|
+
export const ELEVATION_ATTEMPT_KEY = "rapidmx-admin-elevation-attempt";
|
|
16
|
+
|
|
17
|
+
/** How long after sending the browser to elevate a second `api-104` is taken to mean the elevation didn't take
|
|
18
|
+
* effect (e.g. auth-server's elevated cookie never reaching this origin), rather than a fresh need to elevate. */
|
|
19
|
+
export const ELEVATION_RETRY_WINDOW_MS = 2 * 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/** Whether `err` is a 403 from an elevation-gated endpoint for a caller whose token isn't elevated. */
|
|
22
|
+
export function isElevationRequired(err: unknown): boolean {
|
|
23
|
+
return err instanceof ApiRequestError && err.status === 403 && err.code === ELEVATION_REQUIRED_CODE;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** auth-server's elevation page: it sends the browser back to `returnTo` once the user has confirmed their
|
|
27
|
+
* identity, or to its own account page if they cancel. */
|
|
28
|
+
export function elevationUrl(authServerUrl: string, returnTo: string): string {
|
|
29
|
+
return `${authServerUrl}/auth/elevate?return_to=${encodeURIComponent(returnTo)}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether the browser was sent to elevate within `ELEVATION_RETRY_WINDOW_MS` of `now`. `false` when nothing was
|
|
33
|
+
* recorded, the record is unreadable or from the future, or storage is unavailable. */
|
|
34
|
+
export function elevationAttemptedRecently(now: number = Date.now()): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const recorded = Number(sessionStorage.getItem(ELEVATION_ATTEMPT_KEY));
|
|
37
|
+
const elapsed = now - recorded;
|
|
38
|
+
return recorded > 0 && elapsed >= 0 && elapsed < ELEVATION_RETRY_WINDOW_MS;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Remembers, for this tab, that the browser is about to be sent to elevate. A no-op where storage is unavailable
|
|
45
|
+
* (then only the first-round protection is lost - elevating needs the user to confirm each time round). */
|
|
46
|
+
export function recordElevationAttempt(now: number = Date.now()): void {
|
|
47
|
+
try {
|
|
48
|
+
sessionStorage.setItem(ELEVATION_ATTEMPT_KEY, String(now));
|
|
49
|
+
} catch {
|
|
50
|
+
// See the doc comment.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Forgets any recorded attempt - once elevation has worked, or when the user asks to try again. */
|
|
55
|
+
export function clearElevationAttempt(): void {
|
|
56
|
+
try {
|
|
57
|
+
sessionStorage.removeItem(ELEVATION_ATTEMPT_KEY);
|
|
58
|
+
} catch {
|
|
59
|
+
// Nothing was recorded.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -26,9 +26,17 @@ import { getSetupStatus } from "@rapidmx/react-shared/admin/setupApi.js";
|
|
|
26
26
|
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
27
27
|
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
28
28
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
29
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
29
30
|
import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
30
|
-
import { BrandingFooter
|
|
31
|
+
import { BrandingFooter } from "../../layout/BrandingChrome.js";
|
|
31
32
|
import UserMenu from "../../layout/UserMenu.js";
|
|
33
|
+
import {
|
|
34
|
+
clearElevationAttempt,
|
|
35
|
+
elevationAttemptedRecently,
|
|
36
|
+
elevationUrl,
|
|
37
|
+
isElevationRequired,
|
|
38
|
+
recordElevationAttempt,
|
|
39
|
+
} from "../elevation.js";
|
|
32
40
|
import { signOutOfConsole } from "../signOut.js";
|
|
33
41
|
import { mergePluginNavItems, PluginNav, PluginNavProps } from "../../../plugins/pluginNav.js";
|
|
34
42
|
|
|
@@ -68,7 +76,9 @@ export interface AdminShellProps extends PluginNavProps {
|
|
|
68
76
|
impersonationBaseUrl?: string;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
|
|
79
|
+
/** `elevating`: the browser is being sent to auth-server to confirm the user's identity. `elevationFailed`: it was
|
|
80
|
+
* sent a moment ago and the console still isn't elevated - see `elevation.ts`. */
|
|
81
|
+
type Status = "checking" | "denied" | "elevating" | "elevationFailed" | "error" | "authorized";
|
|
72
82
|
|
|
73
83
|
/** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
|
|
74
84
|
* anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
|
|
@@ -148,14 +158,24 @@ export function adminNavItems(pluginNav?: PluginNav): NavItem[] {
|
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
/**
|
|
151
|
-
* Gates every `apps/admin` page behind the `admin` trusted role
|
|
152
|
-
* `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
|
|
153
|
-
*
|
|
154
|
-
*
|
|
161
|
+
* Gates every `apps/admin` page behind the `admin` trusted role, and behind an elevated session. Uses
|
|
162
|
+
* `GET /api/admin/release-notes` (any `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a
|
|
163
|
+
* canary. The endpoint is class-level `@RequiresElevation()`, checked *before* the trusted-role check, so a 200 means the
|
|
164
|
+
* caller's JWT is elevated and carries a trusted role: show the console. Otherwise:
|
|
165
|
+
*
|
|
166
|
+
* 403 `api-104` means the JWT isn't elevated (an administrator's normal sign-in). There is no local step-up form; the
|
|
167
|
+
* browser is sent to auth-server's `/auth/elevate?return_to=<this page>`, which returns it here once the user has
|
|
168
|
+
* confirmed their identity (or to its own account page if they cancel). Sent at most once per
|
|
169
|
+
* `ELEVATION_RETRY_WINDOW_MS`, so an elevated cookie that never reaches this origin can't bounce the browser back
|
|
170
|
+
* and forth - see `elevation.ts`, and the "didn't take effect" alert with its own "Try again" below.
|
|
171
|
+
*
|
|
172
|
+
* 403 `api-103` (elevated, but not an administrator), any other 403, and 401 mean "no administrator access".
|
|
155
173
|
*/
|
|
156
174
|
export default function AdminShell({ active, userUid, authServerUrl, pluginNav, children }: PropsWithChildren<AdminShellProps>) {
|
|
157
175
|
const [status, setStatus] = useState<Status>("checking");
|
|
158
176
|
const [error, setError] = useState<string | null>(null);
|
|
177
|
+
// `branding` only feeds the footer below: the admin-configured header is for the webmail and public pages, not the
|
|
178
|
+
// console. `useBranding()` is still what injects the custom stylesheet and supplies the rail's icon.
|
|
159
179
|
const { branding, iconSrc } = useBranding();
|
|
160
180
|
|
|
161
181
|
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
@@ -166,6 +186,8 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
166
186
|
}
|
|
167
187
|
apiFetch("/admin/release-notes")
|
|
168
188
|
.then(async () => {
|
|
189
|
+
// Elevated (or elevation isn't needed): a later expiry of the elevation may send the user round again.
|
|
190
|
+
clearElevationAttempt();
|
|
169
191
|
// Until first-run setup is finished, every other admin page sends the admin to the wizard. A failed
|
|
170
192
|
// check never blocks the console - the admin can still reach setup from the Mailboxes page.
|
|
171
193
|
if (active !== "setup") {
|
|
@@ -181,6 +203,17 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
181
203
|
setStatus("authorized");
|
|
182
204
|
})
|
|
183
205
|
.catch((err) => {
|
|
206
|
+
if (isElevationRequired(err)) {
|
|
207
|
+
// Without auth-server's URL there is nowhere to send the browser to elevate.
|
|
208
|
+
if (!authServerUrl) {
|
|
209
|
+
setStatus("denied");
|
|
210
|
+
} else if (elevationAttemptedRecently()) {
|
|
211
|
+
setStatus("elevationFailed");
|
|
212
|
+
} else {
|
|
213
|
+
startElevation(authServerUrl);
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
184
217
|
if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
|
|
185
218
|
setStatus("denied");
|
|
186
219
|
return;
|
|
@@ -190,6 +223,18 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
190
223
|
});
|
|
191
224
|
}, [userUid]);
|
|
192
225
|
|
|
226
|
+
/** Remembers the attempt, then sends the browser to auth-server to confirm the user's identity. */
|
|
227
|
+
function startElevation(authServer: string) {
|
|
228
|
+
recordElevationAttempt();
|
|
229
|
+
setStatus("elevating");
|
|
230
|
+
window.location.href = elevationUrl(authServer, window.location.href);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleRetryElevation() {
|
|
234
|
+
clearElevationAttempt();
|
|
235
|
+
startElevation(authServerUrl!);
|
|
236
|
+
}
|
|
237
|
+
|
|
193
238
|
function handleSignOut() {
|
|
194
239
|
// Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
|
|
195
240
|
void signOutOfConsole(authServerUrl);
|
|
@@ -198,6 +243,28 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
198
243
|
let content: ReactNode;
|
|
199
244
|
if (!userUid || status === "checking") {
|
|
200
245
|
content = <div className="min-h-screen" />;
|
|
246
|
+
} else if (status === "elevating") {
|
|
247
|
+
content = (
|
|
248
|
+
<div className="min-h-screen flex items-center justify-center p-8">
|
|
249
|
+
<p role="status" className="text-sm text-text-muted">
|
|
250
|
+
Redirecting to confirm your identity…
|
|
251
|
+
</p>
|
|
252
|
+
</div>
|
|
253
|
+
);
|
|
254
|
+
} else if (status === "elevationFailed") {
|
|
255
|
+
content = (
|
|
256
|
+
<div className="min-h-screen flex items-center justify-center p-8">
|
|
257
|
+
<div className="w-full max-w-md flex flex-col items-start">
|
|
258
|
+
<Alert>
|
|
259
|
+
Confirming your identity didn’t take effect, so the administrator console is still locked. Try again,
|
|
260
|
+
and if this keeps happening, sign out and sign back in.
|
|
261
|
+
</Alert>
|
|
262
|
+
<Button type="button" className="!w-auto" onClick={handleRetryElevation}>
|
|
263
|
+
Try again
|
|
264
|
+
</Button>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
201
268
|
} else if (status === "denied") {
|
|
202
269
|
content = (
|
|
203
270
|
<div className="min-h-screen flex items-center justify-center p-8">
|
|
@@ -260,7 +327,6 @@ export default function AdminShell({ active, userUid, authServerUrl, pluginNav,
|
|
|
260
327
|
|
|
261
328
|
return (
|
|
262
329
|
<>
|
|
263
|
-
<BrandingHeader branding={branding} />
|
|
264
330
|
{content}
|
|
265
331
|
<BrandingFooter branding={branding} />
|
|
266
332
|
</>
|
|
@@ -131,7 +131,8 @@ export default function BrandingForm({ branding, onChange, embedded = false }: B
|
|
|
131
131
|
<h1 className="text-xl font-bold uppercase tracking-wide mb-1">Branding</h1>
|
|
132
132
|
<p className="text-sm text-text-muted mb-5">
|
|
133
133
|
Customize the logo, nav-header icon, product name, and chrome shown to every visitor of the
|
|
134
|
-
webmail and admin console — including anonymous booking-page visitors.
|
|
134
|
+
webmail and admin console — including anonymous booking-page visitors. The header appears on the
|
|
135
|
+
webmail and public pages, not in the admin console; the footer appears in both.
|
|
135
136
|
</p>
|
|
136
137
|
</>
|
|
137
138
|
)}
|