@rapidmx/web-client 0.2.0 → 0.3.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/apps/admin/data-requests/index.tsx +485 -0
- package/apps/admin/escrow-scopes/[uid].tsx +237 -0
- package/apps/admin/escrow-scopes/index.tsx +124 -0
- package/apps/admin/escrow-scopes/new/index.tsx +128 -0
- package/apps/admin/mailboxes/[uid].tsx +66 -8
- package/apps/admin/retention-policy/index.tsx +132 -0
- package/apps/escrow/_layout.tsx +41 -0
- package/apps/escrow/audit-log/index.tsx +168 -0
- package/apps/escrow/index.tsx +139 -0
- package/apps/escrow/matters/[uid].tsx +521 -0
- package/apps/escrow/matters/new/index.tsx +173 -0
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +171 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +24 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +44 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +134 -0
- package/apps/shared/components/forms/StringListField.tsx +104 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +265 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +8 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +214 -9
- package/apps/shared/components/mail/compose/ComposeContext.tsx +10 -1
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +271 -24
- package/apps/shared/components/mail/layout/MailShell.tsx +302 -287
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -0
- package/apps/www/index.tsx +122 -11
- package/apps/www/messages/[uid].tsx +15 -1
- package/apps/www/settings/encryption/index.tsx +664 -0
- package/apps/www/settings/labels/index.tsx +201 -0
- package/apps/www/settings/privacy/index.tsx +436 -0
- package/dist/apps/admin/data-requests/index.js +197 -0
- package/dist/apps/admin/escrow-scopes/[uid].js +141 -0
- package/dist/apps/admin/escrow-scopes/index.js +31 -0
- package/dist/apps/admin/escrow-scopes/new/index.js +69 -0
- package/dist/apps/admin/mailboxes/[uid].js +26 -2
- package/dist/apps/admin/retention-policy/index.js +65 -0
- package/dist/apps/escrow/_layout.js +10 -0
- package/dist/apps/escrow/audit-log/index.js +66 -0
- package/dist/apps/escrow/index.js +31 -0
- package/dist/apps/escrow/matters/[uid].js +234 -0
- package/dist/apps/escrow/matters/new/index.js +75 -0
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +26 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +19 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +10 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +75 -0
- package/dist/apps/shared/components/forms/StringListField.js +39 -0
- package/dist/apps/shared/components/layout/AppShell.js +4 -0
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +142 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +2 -2
- package/dist/apps/shared/components/mail/MessageDetailPane.js +105 -6
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +184 -21
- package/dist/apps/shared/components/mail/layout/MailShell.js +11 -2
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -0
- package/dist/apps/www/index.js +110 -10
- package/dist/apps/www/messages/[uid].js +12 -2
- package/dist/apps/www/settings/encryption/index.js +330 -0
- package/dist/apps/www/settings/labels/index.js +95 -0
- package/dist/apps/www/settings/privacy/index.js +167 -0
- 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 & 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’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
|
+
}
|