@rapidmx/web-client 0.2.0 → 0.3.1

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 (59) hide show
  1. package/apps/admin/data-requests/index.tsx +485 -0
  2. package/apps/admin/escrow-scopes/[uid].tsx +237 -0
  3. package/apps/admin/escrow-scopes/index.tsx +124 -0
  4. package/apps/admin/escrow-scopes/new/index.tsx +128 -0
  5. package/apps/admin/mailboxes/[uid].tsx +66 -8
  6. package/apps/admin/retention-policy/index.tsx +132 -0
  7. package/apps/escrow/_layout.tsx +41 -0
  8. package/apps/escrow/audit-log/index.tsx +168 -0
  9. package/apps/escrow/index.tsx +139 -0
  10. package/apps/escrow/matters/[uid].tsx +521 -0
  11. package/apps/escrow/matters/new/index.tsx +173 -0
  12. package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
  13. package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
  15. package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
  16. package/apps/shared/components/forms/StringListField.tsx +104 -0
  17. package/apps/shared/components/layout/AppShell.tsx +4 -0
  18. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
  19. package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
  20. package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
  21. package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
  22. package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
  23. package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
  24. package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
  25. package/apps/www/index.tsx +122 -11
  26. package/apps/www/messages/[uid].tsx +15 -1
  27. package/apps/www/settings/encryption/index.tsx +664 -0
  28. package/apps/www/settings/labels/index.tsx +201 -0
  29. package/apps/www/settings/privacy/index.tsx +436 -0
  30. package/dist/apps/admin/data-requests/index.js +197 -0
  31. package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
  32. package/dist/apps/admin/escrow-scopes/index.js +31 -0
  33. package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
  34. package/dist/apps/admin/mailboxes/[uid].js +26 -2
  35. package/dist/apps/admin/retention-policy/index.js +65 -0
  36. package/dist/apps/escrow/_layout.js +10 -0
  37. package/dist/apps/escrow/audit-log/index.js +66 -0
  38. package/dist/apps/escrow/index.js +31 -0
  39. package/dist/apps/escrow/matters/[uid].js +234 -0
  40. package/dist/apps/escrow/matters/new/index.js +75 -0
  41. package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
  42. package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
  43. package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
  44. package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
  45. package/dist/apps/shared/components/forms/StringListField.js +39 -0
  46. package/dist/apps/shared/components/layout/AppShell.js +4 -0
  47. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
  48. package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
  49. package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
  50. package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
  51. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
  52. package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
  53. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
  54. package/dist/apps/www/index.js +110 -10
  55. package/dist/apps/www/messages/[uid].js +12 -2
  56. package/dist/apps/www/settings/encryption/index.js +330 -0
  57. package/dist/apps/www/settings/labels/index.js +95 -0
  58. package/dist/apps/www/settings/privacy/index.js +167 -0
  59. package/package.json +9 -3
@@ -0,0 +1,173 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React, { FormEvent, useState } from "react";
6
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
7
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
8
+ import { createMatter } from "@rapidmx/react-shared/admin/mattersApi.js";
9
+ import EscrowShell, { EscrowShellProps } from "../../../shared/components/escrow/layout/EscrowShell.js";
10
+ import StringListField from "../../../shared/components/forms/StringListField.js";
11
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
12
+ import Button from "@rapidmx/react-shared/components/buttons/Button.js";
13
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
14
+
15
+ const INPUT_CLASS =
16
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
17
+
18
+ export default function NewMatterPage(props: Omit<EscrowShellProps, "active">) {
19
+ return (
20
+ <EscrowShell {...props} active="matters">
21
+ <NewMatterForm />
22
+ </EscrowShell>
23
+ );
24
+ }
25
+
26
+ function NewMatterForm() {
27
+ const [name, setName] = useState("");
28
+ const [description, setDescription] = useState("");
29
+ // No dropdown here: `EscrowScope` (BaseEscrowScopeRoute) is trusted-admin-only end to end, so this
30
+ // app has no holder-readable "list the scopes I hold" endpoint to populate one from — a holder is
31
+ // told their own escrowScopeId out of band by whichever admin granted them the role. Same
32
+ // "server fills a gap, or here, deliberately doesn't paper over one restapi itself doesn't expose"
33
+ // posture as `BaseEscrowInfoRoute`'s own doc comment on the mailbox-owner side of this feature.
34
+ const [escrowScopeId, setEscrowScopeId] = useState("");
35
+ const [custodianMailboxUids, setCustodianMailboxUids] = useState<string[]>([]);
36
+ // Defaults to the last 24 hours rather than "now" for both ends - a `dateRangeStart === dateRangeEnd`
37
+ // default would always fail this form's own "start must be before end" check the instant it's opened,
38
+ // forcing every holder to touch both fields even when they'd otherwise leave one at its default.
39
+ const [dateRangeStart, setDateRangeStart] = useState(toDatetimeLocal(new Date(Date.now() - 24 * 60 * 60_000).toISOString()));
40
+ const [dateRangeEnd, setDateRangeEnd] = useState(toDatetimeLocal(new Date().toISOString()));
41
+ const [error, setError] = useState<string | null>(null);
42
+ const [saving, setSaving] = useState(false);
43
+
44
+ async function handleSubmit(e: FormEvent) {
45
+ e.preventDefault();
46
+ setError(null);
47
+
48
+ if (!name.trim()) {
49
+ setError("A name is required.");
50
+ return;
51
+ }
52
+ if (!escrowScopeId.trim()) {
53
+ setError("An escrow scope uid is required.");
54
+ return;
55
+ }
56
+ if (custodianMailboxUids.length === 0) {
57
+ setError("At least one custodian mailbox is required.");
58
+ return;
59
+ }
60
+ if (new Date(dateRangeStart).getTime() >= new Date(dateRangeEnd).getTime()) {
61
+ setError("The date range start must be before its end.");
62
+ return;
63
+ }
64
+
65
+ setSaving(true);
66
+ try {
67
+ const created = await createMatter({
68
+ name: name.trim(),
69
+ description: description.trim() || undefined,
70
+ escrowScopeId: escrowScopeId.trim(),
71
+ custodianMailboxUids,
72
+ dateRangeStart: new Date(dateRangeStart).toISOString(),
73
+ dateRangeEnd: new Date(dateRangeEnd).toISOString(),
74
+ });
75
+ window.location.href = `/escrow/matters/${encodeURIComponent(created.uid)}`;
76
+ } catch (err) {
77
+ setError(err instanceof ApiRequestError ? err.message : "Could not create the matter.");
78
+ } finally {
79
+ setSaving(false);
80
+ }
81
+ }
82
+
83
+ return (
84
+ <div className="max-w-3xl">
85
+ <h1 className="text-xl font-bold uppercase tracking-wide mb-1">New matter</h1>
86
+ <p className="text-sm text-text-muted mb-5">
87
+ You must already be a holder of the escrow scope you name below — this server checks that at
88
+ creation time, the same as every other action in this console.
89
+ </p>
90
+
91
+ {error && <Alert>{error}</Alert>}
92
+
93
+ <form onSubmit={handleSubmit} className="flex flex-col gap-5">
94
+ <div className="bg-surface border border-border rounded-md p-6">
95
+ <FormField label="Name" htmlFor="name">
96
+ <input
97
+ id="name"
98
+ type="text"
99
+ className={INPUT_CLASS}
100
+ value={name}
101
+ onChange={(e) => setName(e.target.value)}
102
+ placeholder="Smith v. Acme"
103
+ />
104
+ </FormField>
105
+
106
+ <FormField label="Description (optional)" htmlFor="description">
107
+ <input
108
+ id="description"
109
+ type="text"
110
+ className={INPUT_CLASS}
111
+ value={description}
112
+ onChange={(e) => setDescription(e.target.value)}
113
+ />
114
+ </FormField>
115
+
116
+ <FormField label="Escrow scope uid" htmlFor="escrowScopeId">
117
+ <input
118
+ id="escrowScopeId"
119
+ type="text"
120
+ className={INPUT_CLASS}
121
+ value={escrowScopeId}
122
+ onChange={(e) => setEscrowScopeId(e.target.value)}
123
+ />
124
+ </FormField>
125
+
126
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4">
127
+ <FormField label="Date range start" htmlFor="dateRangeStart">
128
+ <input
129
+ id="dateRangeStart"
130
+ type="datetime-local"
131
+ className={INPUT_CLASS}
132
+ value={dateRangeStart}
133
+ onChange={(e) => setDateRangeStart(e.target.value)}
134
+ />
135
+ </FormField>
136
+
137
+ <FormField label="Date range end" htmlFor="dateRangeEnd">
138
+ <input
139
+ id="dateRangeEnd"
140
+ type="datetime-local"
141
+ className={INPUT_CLASS}
142
+ value={dateRangeEnd}
143
+ onChange={(e) => setDateRangeEnd(e.target.value)}
144
+ />
145
+ </FormField>
146
+ </div>
147
+ </div>
148
+
149
+ <div className="bg-surface border border-border rounded-md p-6">
150
+ <StringListField
151
+ label="Custodian mailbox uids"
152
+ id="custodianMailboxUids"
153
+ values={custodianMailboxUids}
154
+ onChange={setCustodianMailboxUids}
155
+ placeholder="Mailbox uid to add"
156
+ emptyMessage="No custodian mailboxes added yet."
157
+ />
158
+ </div>
159
+
160
+ <div className="flex gap-3">
161
+ <Button type="submit" loading={saving} disabled={saving} className="!w-auto">
162
+ Create matter
163
+ </Button>
164
+ <a href="/escrow">
165
+ <Button type="button" variant="secondary" className="!w-auto">
166
+ Cancel
167
+ </Button>
168
+ </a>
169
+ </div>
170
+ </form>
171
+ </div>
172
+ );
173
+ }
@@ -0,0 +1,171 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import React from "react";
6
+ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
7
+ import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
8
+ import StringListField from "../../forms/StringListField.js";
9
+
10
+ const INPUT_CLASS =
11
+ "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
12
+
13
+ /** The part of an `EscrowScope` shared by the "new" and "[uid]" pages — everything except `name`/
14
+ * `description`, which stay plain inline `FormField`s on each page (same split `distribution-lists`
15
+ * already uses: simple top-level fields inline, the more involved sub-structure factored out). An admin
16
+ * pastes in the fields of an already-issued certificate here; nothing in this app generates a keypair —
17
+ * see `EscrowScopePublicKey`'s own doc comment in `escrowScopesApi.ts`. */
18
+ export interface EscrowScopeKeyAndHoldersValue {
19
+ publicKey: string;
20
+ keyType: string;
21
+ fingerprint: string;
22
+ /** `<input type="datetime-local">` value — see `toDatetimeLocal()`. */
23
+ notBefore: string;
24
+ notAfter: string;
25
+ holderUserUids: string[];
26
+ requiredHolders: number;
27
+ notifySubjectOnAccess: boolean;
28
+ }
29
+
30
+ export interface EscrowScopeKeyAndHoldersFieldsProps {
31
+ value: EscrowScopeKeyAndHoldersValue;
32
+ onChange: (value: EscrowScopeKeyAndHoldersValue) => void;
33
+ disabled?: boolean;
34
+ }
35
+
36
+ /** Builds an all-zeroes starting point for the "new" page — `notBefore`/`notAfter` default to "now" so an
37
+ * admin pasting in a cert's own dates only has to touch what actually differs. */
38
+ export function emptyEscrowScopeKeyAndHoldersValue(): EscrowScopeKeyAndHoldersValue {
39
+ const now = toDatetimeLocal(new Date().toISOString());
40
+ return {
41
+ publicKey: "",
42
+ keyType: "x509",
43
+ fingerprint: "",
44
+ notBefore: now,
45
+ notAfter: now,
46
+ holderUserUids: [],
47
+ requiredHolders: 1,
48
+ notifySubjectOnAccess: false,
49
+ };
50
+ }
51
+
52
+ export default function EscrowScopeKeyAndHoldersFields({ value, onChange, disabled }: EscrowScopeKeyAndHoldersFieldsProps) {
53
+ function set<K extends keyof EscrowScopeKeyAndHoldersValue>(key: K, next: EscrowScopeKeyAndHoldersValue[K]) {
54
+ onChange({ ...value, [key]: next });
55
+ }
56
+
57
+ return (
58
+ <>
59
+ <div className="bg-surface border border-border rounded-md p-6">
60
+ <h2 className="text-base font-bold uppercase tracking-wide mb-1">Public key</h2>
61
+ <p className="text-sm text-text-muted mb-4">
62
+ The fields of an already-issued certificate — this server never generates or holds the
63
+ corresponding private key.
64
+ </p>
65
+
66
+ <FormField label="Public key (base64)" htmlFor="publicKey">
67
+ <textarea
68
+ id="publicKey"
69
+ className={`${INPUT_CLASS} font-mono text-xs`}
70
+ rows={4}
71
+ value={value.publicKey}
72
+ disabled={disabled}
73
+ onChange={(e) => set("publicKey", e.target.value)}
74
+ />
75
+ </FormField>
76
+
77
+ <FormField label="Key type" htmlFor="keyType">
78
+ <input
79
+ id="keyType"
80
+ type="text"
81
+ className={INPUT_CLASS}
82
+ value={value.keyType}
83
+ disabled={disabled}
84
+ onChange={(e) => set("keyType", e.target.value)}
85
+ placeholder="x509"
86
+ />
87
+ </FormField>
88
+
89
+ <FormField label="Fingerprint (hex SHA-256)" htmlFor="fingerprint">
90
+ <input
91
+ id="fingerprint"
92
+ type="text"
93
+ className={`${INPUT_CLASS} font-mono`}
94
+ value={value.fingerprint}
95
+ disabled={disabled}
96
+ onChange={(e) => set("fingerprint", e.target.value)}
97
+ />
98
+ </FormField>
99
+
100
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4">
101
+ <FormField label="Not before" htmlFor="notBefore">
102
+ <input
103
+ id="notBefore"
104
+ type="datetime-local"
105
+ className={INPUT_CLASS}
106
+ value={value.notBefore}
107
+ disabled={disabled}
108
+ onChange={(e) => set("notBefore", e.target.value)}
109
+ />
110
+ </FormField>
111
+
112
+ <FormField label="Not after" htmlFor="notAfter">
113
+ <input
114
+ id="notAfter"
115
+ type="datetime-local"
116
+ className={INPUT_CLASS}
117
+ value={value.notAfter}
118
+ disabled={disabled}
119
+ onChange={(e) => set("notAfter", e.target.value)}
120
+ />
121
+ </FormField>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="bg-surface border border-border rounded-md p-6">
126
+ <h2 className="text-base font-bold uppercase tracking-wide mb-1">Holders &amp; dual control</h2>
127
+ <p className="text-sm text-text-muted mb-4">
128
+ Every uid below is granted the eDiscovery/compliance holder role for this scope — a
129
+ deliberately separate role from server administration. At least one holder is required.
130
+ </p>
131
+
132
+ <StringListField
133
+ label="Holder user uids"
134
+ id="holderUserUids"
135
+ values={value.holderUserUids}
136
+ onChange={(holderUserUids) => set("holderUserUids", holderUserUids)}
137
+ placeholder="User uid to add"
138
+ emptyMessage="No holders added yet."
139
+ disabled={disabled}
140
+ />
141
+
142
+ <FormField label="Required holders (M-of-N dual control)" htmlFor="requiredHolders">
143
+ {/* Deliberately no HTML `min`/`max` attributes: the true bound (1..holderUserUids.length)
144
+ moves as holders are added/removed, and jsdom (like a real browser) blocks native
145
+ form submission outright once a number input's value falls outside `min`/`max` —
146
+ which would silently swallow the Save/Create click with no error shown at all while
147
+ a holder is being removed. The page's own submit-time check (and restapi's own
148
+ `validateEscrowScope()` server-side) enforce the real bound instead. */}
149
+ <input
150
+ id="requiredHolders"
151
+ type="number"
152
+ className={INPUT_CLASS}
153
+ value={value.requiredHolders}
154
+ disabled={disabled}
155
+ onChange={(e) => set("requiredHolders", Number(e.target.value) || 1)}
156
+ />
157
+ </FormField>
158
+
159
+ <label className="flex items-center gap-2 text-sm">
160
+ <input
161
+ type="checkbox"
162
+ checked={value.notifySubjectOnAccess}
163
+ disabled={disabled}
164
+ onChange={(e) => set("notifySubjectOnAccess", e.target.checked)}
165
+ />
166
+ Notify subject on access
167
+ </label>
168
+ </div>
169
+ </>
170
+ );
171
+ }
@@ -6,8 +6,11 @@ import "../../../styles/app.css";
6
6
  import React, { PropsWithChildren, ReactNode, useEffect, useState } from "react";
7
7
  import {
8
8
  HiOutlineClipboardDocumentList,
9
+ HiOutlineClock,
10
+ HiOutlineDocumentArrowDown,
9
11
  HiOutlineGlobeAlt,
10
12
  HiOutlineInboxStack,
13
+ HiOutlineKey,
11
14
  HiOutlinePaintBrush,
12
15
  HiOutlineQueueList,
13
16
  HiOutlineShieldCheck,
@@ -30,6 +33,9 @@ export type AdminSection =
30
33
  | "auditLog"
31
34
  | "distributionLists"
32
35
  | "transportRules"
36
+ | "escrowScopes"
37
+ | "retentionPolicy"
38
+ | "dataRequests"
33
39
  | "branding";
34
40
 
35
41
  export interface AdminShellProps {
@@ -69,6 +75,24 @@ const NAV_ITEMS: NavItem[] = [
69
75
  label: "Transport Rules",
70
76
  icon: HiOutlineShieldCheck,
71
77
  },
78
+ {
79
+ id: "escrowScopes",
80
+ href: "/admin/escrow-scopes",
81
+ label: "Escrow Scopes",
82
+ icon: HiOutlineKey,
83
+ },
84
+ {
85
+ id: "retentionPolicy",
86
+ href: "/admin/retention-policy",
87
+ label: "Retention Policy",
88
+ icon: HiOutlineClock,
89
+ },
90
+ {
91
+ id: "dataRequests",
92
+ href: "/admin/data-requests",
93
+ label: "Data Requests",
94
+ icon: HiOutlineDocumentArrowDown,
95
+ },
72
96
  { id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
73
97
  ];
74
98
 
@@ -5,8 +5,16 @@
5
5
  import React from "react";
6
6
  import { Contact } from "@rapidmx/react-shared/contacts/contactsApi.js";
7
7
  import Button from "@rapidmx/react-shared/components/buttons/Button.js";
8
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
8
9
  import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
9
10
 
11
+ /** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
12
+ * the spec's own use case ("compare this over the phone") is materially easier with a grouped string
13
+ * than one 64-character run. */
14
+ function formatFingerprint(fingerprint: string): string {
15
+ return fingerprint.match(/.{1,4}/g)?.join(" ") ?? fingerprint;
16
+ }
17
+
10
18
  export interface ContactDetailPaneProps {
11
19
  contact: Contact;
12
20
  onEdit: () => void;
@@ -93,6 +101,42 @@ export default function ContactDetailPane({ contact, onEdit, onDelete, backHref
93
101
  <div>{contact.notes}</div>
94
102
  </div>
95
103
  )}
104
+ {(contact.keys && contact.keys.length > 0) || contact.encryptPreference || contact.keyConflict ? (
105
+ <div>
106
+ <div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Encryption</div>
107
+ {contact.keyConflict && (
108
+ <div className="mb-2">
109
+ <Alert>
110
+ <p className="mb-2">
111
+ A different encryption key was observed for this contact on{" "}
112
+ {new Date(contact.keyConflict.observedAt).toLocaleDateString()}. The
113
+ previously verified key below is still the one in use — this is routine
114
+ after a device change or reinstall, but it&rsquo;s also what a real attack
115
+ looks like, so verify the new fingerprint with {contact.displayName}{" "}
116
+ directly (e.g. by phone) before trusting it. There is no automatic way to
117
+ accept or reject this yet.
118
+ </p>
119
+ <p className="font-mono text-xs">
120
+ Newly observed: {formatFingerprint(contact.keyConflict.observedFingerprint)}
121
+ </p>
122
+ </Alert>
123
+ </div>
124
+ )}
125
+ {contact.encryptPreference && (
126
+ <div className="mb-1">
127
+ {contact.encryptPreference.preferEncrypt === "mutual"
128
+ ? "This contact also encrypts to you — messages to them can be encrypted."
129
+ : "This contact has not indicated a mutual encryption preference."}
130
+ </div>
131
+ )}
132
+ {contact.keys?.map((key) => (
133
+ <div key={key.fingerprint} className="font-mono text-xs py-0.5">
134
+ {key.useType === "sign" ? "Signing" : "Encryption"} key: {formatFingerprint(key.fingerprint)}
135
+ {key.revokedAt && <span className="text-danger"> (revoked)</span>}
136
+ </div>
137
+ ))}
138
+ </div>
139
+ ) : null}
96
140
  </div>
97
141
  </div>
98
142
  );
@@ -0,0 +1,134 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ import "../../../styles/app.css";
6
+ import React, { PropsWithChildren, ReactNode, useEffect, useState } from "react";
7
+ import { HiOutlineClipboardDocumentList, HiOutlineFolderOpen } from "react-icons/hi2";
8
+ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
9
+ import { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
10
+ import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
11
+ import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
12
+ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
13
+ import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
14
+ import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
15
+ import UserMenu from "../../layout/UserMenu.js";
16
+
17
+ export type EscrowSection = "matters" | "auditLog";
18
+
19
+ export interface EscrowShellProps {
20
+ active: EscrowSection;
21
+ userUid?: string;
22
+ authServerUrl?: string;
23
+ }
24
+
25
+ type Status = "checking" | "error" | "authorized";
26
+
27
+ const NAV_ITEMS: NavItem[] = [
28
+ { id: "matters", href: "/escrow", label: "Matters", icon: HiOutlineFolderOpen },
29
+ { id: "auditLog", href: "/escrow/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
30
+ ];
31
+
32
+ /**
33
+ * Gates every `apps/escrow` page behind "is this an authenticated caller at all" — deliberately **not**
34
+ * the same "does a canary endpoint 403 me" pattern `AdminShell` uses for the trusted `admin` role.
35
+ * `specs/end-to-end_encryption.md`'s "Separation of duties" makes holder-ness a per-`EscrowScope` grant
36
+ * (`EscrowScope.holderUserUids`), checked directly against `holderUserUids` in every restapi route that
37
+ * needs it (`requireEscrowHolder()`) — never a role, and never global. There is no canary endpoint a
38
+ * non-holder gets a clean 403 from: `GET /escrow/matters` (`BaseMatterRoute.find()`) returns `200 []` for
39
+ * *any* authenticated caller who holds no scope at all, the same as it would for a holder of zero matters
40
+ * — so unlike `AdminShell`, this shell cannot distinguish "holds nothing" from "holds no scope at all" up
41
+ * front, and does not try to. It only confirms the caller is signed in and the API is reachable; every
42
+ * actual holder-gated action (create a Matter, approve/deny a request, read material) is enforced
43
+ * server-side and surfaces its own 403 `ApiRequestError` on the specific page that attempted it, same as
44
+ * any other gated action elsewhere in this codebase. A non-holder who reaches `/escrow` simply sees an
45
+ * empty Matters list and gets a real error if they try to act on something they don't hold — this is a
46
+ * deliberate, documented scope trim rather than an oversight (see this session's own report on it).
47
+ */
48
+ export default function EscrowShell({ active, userUid, authServerUrl, children }: PropsWithChildren<EscrowShellProps>) {
49
+ const [status, setStatus] = useState<Status>("checking");
50
+ const [error, setError] = useState<string | null>(null);
51
+ const { branding, iconSrc } = useBranding();
52
+
53
+ useRedirectIfUnauthenticated(userUid, authServerUrl);
54
+
55
+ useEffect(() => {
56
+ if (!userUid) {
57
+ return;
58
+ }
59
+ // limit=1 - this call exists only to confirm the API is reachable for this signed-in caller, not
60
+ // to read any real data (every page below fetches its own data itself).
61
+ listMatters({ limit: 1 })
62
+ .then(() => setStatus("authorized"))
63
+ .catch((err) => {
64
+ setError(err instanceof ApiRequestError ? err.message : "Could not verify escrow console access.");
65
+ setStatus("error");
66
+ });
67
+ }, [userUid]);
68
+
69
+ function handleSignOut() {
70
+ window.location.href = authServerUrl ?? "/";
71
+ }
72
+
73
+ let content: ReactNode;
74
+ if (!userUid || status === "checking") {
75
+ content = <div className="min-h-screen" />;
76
+ } else if (status === "error") {
77
+ content = (
78
+ <div className="min-h-screen flex items-center justify-center p-8">
79
+ <div className="w-full max-w-md">
80
+ <Alert>{error}</Alert>
81
+ </div>
82
+ </div>
83
+ );
84
+ } else {
85
+ const activeItem = NAV_ITEMS.find((item) => item.id === active);
86
+ content = (
87
+ <div className="min-h-screen flex flex-col bg-surface-alt">
88
+ <div className="flex-1 flex min-h-0">
89
+ <nav
90
+ aria-label="Escrow sections"
91
+ className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
92
+ >
93
+ <img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
94
+ {NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (
95
+ <a
96
+ key={id}
97
+ href={href}
98
+ aria-label={label}
99
+ aria-current={id === active ? "page" : undefined}
100
+ title={label}
101
+ className={[
102
+ "w-10 h-10 flex items-center justify-center rounded-sm",
103
+ id === active
104
+ ? "bg-primary/10 text-primary-dark"
105
+ : "text-text-muted hover:bg-surface-alt hover:text-text",
106
+ ].join(" ")}
107
+ >
108
+ <Icon size={20} aria-hidden="true" />
109
+ </a>
110
+ ))}
111
+ </nav>
112
+ <BottomTabBar apps={NAV_ITEMS} active={active} />
113
+ <div className="flex-1 flex flex-col min-w-0">
114
+ <header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
115
+ <span className="font-display font-bold text-lg uppercase tracking-wide">{activeItem?.label}</span>
116
+ <UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} />
117
+ </header>
118
+ <div className="flex-1 pb-14 md:pb-0">
119
+ <main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ return (
128
+ <>
129
+ <BrandingHeader branding={branding} />
130
+ {content}
131
+ <BrandingFooter branding={branding} />
132
+ </>
133
+ );
134
+ }