@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.
- 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,132 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
MIN_AUDIT_LOG_RETENTION_DAYS,
|
|
9
|
+
RetentionPolicy,
|
|
10
|
+
getRetentionPolicy,
|
|
11
|
+
updateRetentionPolicy,
|
|
12
|
+
} from "@rapidmx/react-shared/admin/retentionPolicyApi.js";
|
|
13
|
+
import AdminShell, { AdminShellProps } from "../../shared/components/admin/layout/AdminShell.js";
|
|
14
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
15
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
16
|
+
|
|
17
|
+
const INPUT_CLASS =
|
|
18
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
19
|
+
|
|
20
|
+
export default function RetentionPolicyPage(props: Omit<AdminShellProps, "active">) {
|
|
21
|
+
return (
|
|
22
|
+
<AdminShell {...props} active="retentionPolicy">
|
|
23
|
+
<RetentionPolicyContent />
|
|
24
|
+
</AdminShell>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function RetentionPolicyContent() {
|
|
29
|
+
const [policy, setPolicy] = useState<RetentionPolicy | null>(null);
|
|
30
|
+
const [loading, setLoading] = useState(true);
|
|
31
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
getRetentionPolicy()
|
|
35
|
+
.then(setPolicy)
|
|
36
|
+
.catch((err) => setLoadError(err instanceof ApiRequestError ? err.message : "Could not load the retention policy."))
|
|
37
|
+
.finally(() => setLoading(false));
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
if (loading) {
|
|
41
|
+
return <p className="text-sm text-text-muted">Loading…</p>;
|
|
42
|
+
}
|
|
43
|
+
if (loadError || !policy) {
|
|
44
|
+
return <Alert>{loadError}</Alert>;
|
|
45
|
+
}
|
|
46
|
+
return <RetentionPolicyForm policy={policy} onChange={setPolicy} />;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function RetentionPolicyForm({ policy, onChange }: { policy: RetentionPolicy; onChange: (p: RetentionPolicy) => void }) {
|
|
50
|
+
const [messageRetentionDays, setMessageRetentionDays] = useState(policy.messageRetentionDays?.toString() ?? "");
|
|
51
|
+
const [auditLogRetentionDays, setAuditLogRetentionDays] = useState(policy.auditLogRetentionDays?.toString() ?? "");
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [saving, setSaving] = useState(false);
|
|
54
|
+
const [saved, setSaved] = useState(false);
|
|
55
|
+
|
|
56
|
+
async function handleSubmit(e: FormEvent) {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
setError(null);
|
|
59
|
+
setSaved(false);
|
|
60
|
+
setSaving(true);
|
|
61
|
+
try {
|
|
62
|
+
const patch: Partial<RetentionPolicy> = {};
|
|
63
|
+
if (messageRetentionDays.trim() !== "") {
|
|
64
|
+
patch.messageRetentionDays = Number(messageRetentionDays);
|
|
65
|
+
}
|
|
66
|
+
if (auditLogRetentionDays.trim() !== "") {
|
|
67
|
+
patch.auditLogRetentionDays = Number(auditLogRetentionDays);
|
|
68
|
+
}
|
|
69
|
+
const updated = await updateRetentionPolicy(patch);
|
|
70
|
+
onChange(updated);
|
|
71
|
+
setSaved(true);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save the retention policy.");
|
|
74
|
+
} finally {
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="max-w-xl">
|
|
81
|
+
<h1 className="text-xl font-bold uppercase tracking-wide mb-1">Retention Policy</h1>
|
|
82
|
+
<p className="text-sm text-text-muted mb-5">
|
|
83
|
+
Automatically and permanently deletes mail/audit-log entries older than the configured age.
|
|
84
|
+
Leave a field blank for no automatic purge. This does not apply to a mailbox that is a
|
|
85
|
+
custodian on an active legal hold — a held message is skipped and retried on a later run once
|
|
86
|
+
the hold is lifted.
|
|
87
|
+
</p>
|
|
88
|
+
|
|
89
|
+
{error && <Alert>{error}</Alert>}
|
|
90
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
91
|
+
|
|
92
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
93
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
94
|
+
<span className="font-semibold">Message retention (days)</span>
|
|
95
|
+
<input
|
|
96
|
+
aria-label="Message retention (days)"
|
|
97
|
+
type="number"
|
|
98
|
+
min={1}
|
|
99
|
+
className={INPUT_CLASS}
|
|
100
|
+
value={messageRetentionDays}
|
|
101
|
+
onChange={(e) => setMessageRetentionDays(e.target.value)}
|
|
102
|
+
placeholder="No automatic purge"
|
|
103
|
+
/>
|
|
104
|
+
<span className="text-xs text-text-muted">
|
|
105
|
+
Applies to any message, in any folder, regardless of age.
|
|
106
|
+
</span>
|
|
107
|
+
</label>
|
|
108
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
109
|
+
<span className="font-semibold">Audit log retention (days)</span>
|
|
110
|
+
<input
|
|
111
|
+
aria-label="Audit log retention (days)"
|
|
112
|
+
type="number"
|
|
113
|
+
min={MIN_AUDIT_LOG_RETENTION_DAYS}
|
|
114
|
+
className={INPUT_CLASS}
|
|
115
|
+
value={auditLogRetentionDays}
|
|
116
|
+
onChange={(e) => setAuditLogRetentionDays(e.target.value)}
|
|
117
|
+
placeholder="Keep forever"
|
|
118
|
+
/>
|
|
119
|
+
<span className="text-xs text-text-muted">
|
|
120
|
+
Cannot be set below {MIN_AUDIT_LOG_RETENTION_DAYS} days. Never applies to the
|
|
121
|
+
tamper-evident escrow audit chain.
|
|
122
|
+
</span>
|
|
123
|
+
</label>
|
|
124
|
+
<div>
|
|
125
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
126
|
+
Save
|
|
127
|
+
</Button>
|
|
128
|
+
</div>
|
|
129
|
+
</form>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { PropsWithChildren } from "react";
|
|
6
|
+
import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
7
|
+
import { CUSTOM_STYLESHEET_LINK_ID } from "@rapidmx/react-shared/branding/useBranding.js";
|
|
8
|
+
|
|
9
|
+
export interface LayoutProps {
|
|
10
|
+
/** See `apps/www/_layout.tsx`'s `LayoutProps` doc comment — identical mechanism, supplied by
|
|
11
|
+
* `EscrowConsoleRoute`'s `fetchProps()`. */
|
|
12
|
+
branding?: Branding;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Layout({ children, branding }: PropsWithChildren<LayoutProps>) {
|
|
16
|
+
const title = branding?.title || branding?.companyName
|
|
17
|
+
? `${branding?.title || branding?.companyName}: Escrow Console`
|
|
18
|
+
: "RapidMX: Escrow Console";
|
|
19
|
+
const iconHref = branding?.iconUrl || branding?.logoUrl || "/images/logo.svg";
|
|
20
|
+
const stylesheetHref = branding?.stylesheetUrl;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<html lang="en">
|
|
24
|
+
<head>
|
|
25
|
+
<meta charSet="utf-8" />
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
27
|
+
<title>{title}</title>
|
|
28
|
+
<link rel="icon" type="image/svg+xml" href={iconHref} />
|
|
29
|
+
<link rel="alternate icon" href="/favicon.ico" />
|
|
30
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
31
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
32
|
+
<link
|
|
33
|
+
rel="stylesheet"
|
|
34
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
|
|
35
|
+
/>
|
|
36
|
+
{stylesheetHref && <link rel="stylesheet" href={stylesheetHref} id={CUSTOM_STYLESHEET_LINK_ID} />}
|
|
37
|
+
</head>
|
|
38
|
+
<body>{children}</body>
|
|
39
|
+
</html>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
EscrowAuditLogEntry,
|
|
9
|
+
EscrowAuditVerificationResult,
|
|
10
|
+
listAuditLogEntries,
|
|
11
|
+
verifyAuditChain,
|
|
12
|
+
} from "@rapidmx/react-shared/admin/escrowAuditLogApi.js";
|
|
13
|
+
import EscrowShell, { EscrowShellProps } from "../../shared/components/escrow/layout/EscrowShell.js";
|
|
14
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
15
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 25;
|
|
18
|
+
|
|
19
|
+
const ACTION_LABELS: Record<EscrowAuditLogEntry["action"], string> = {
|
|
20
|
+
"escrow_access_request.created": "Access request created",
|
|
21
|
+
"escrow_access_request.approved": "Access request approved",
|
|
22
|
+
"escrow_access_request.material_read": "Material read",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default function EscrowAuditLogPage(props: Omit<EscrowShellProps, "active">) {
|
|
26
|
+
return (
|
|
27
|
+
<EscrowShell {...props} active="auditLog">
|
|
28
|
+
<EscrowAuditLogContent />
|
|
29
|
+
</EscrowShell>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function EscrowAuditLogContent() {
|
|
34
|
+
const [page, setPage] = useState(0);
|
|
35
|
+
const [entries, setEntries] = useState<EscrowAuditLogEntry[]>([]);
|
|
36
|
+
const [loading, setLoading] = useState(true);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
// `GET /admin/release-notes` is the same side-effect-free trusted-role canary `AdminShell` itself
|
|
40
|
+
// uses (any `BaseAdminRoute` endpoint works — see that route's own doc comment) — reused here only to
|
|
41
|
+
// decide whether to show the "Verify chain integrity" button at all, not to gate this whole page the
|
|
42
|
+
// way `AdminShell` gates `apps/admin`. `EscrowAuditLogRoute.verify()` is `@RequiresTrustedRole()`-only
|
|
43
|
+
// server-side regardless of what this probe finds; hiding the button for a non-trusted holder just
|
|
44
|
+
// avoids an inevitable, confusing 403 on click, it isn't itself the enforcement.
|
|
45
|
+
const [isTrustedAdmin, setIsTrustedAdmin] = useState(false);
|
|
46
|
+
|
|
47
|
+
const [verifying, setVerifying] = useState(false);
|
|
48
|
+
const [verifyError, setVerifyError] = useState<string | null>(null);
|
|
49
|
+
const [verifyResult, setVerifyResult] = useState<EscrowAuditVerificationResult | null>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
apiFetch("/admin/release-notes")
|
|
53
|
+
.then(() => setIsTrustedAdmin(true))
|
|
54
|
+
.catch(() => setIsTrustedAdmin(false));
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
setLoading(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
listAuditLogEntries({ page, limit: PAGE_SIZE })
|
|
61
|
+
.then(setEntries)
|
|
62
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load the audit log."))
|
|
63
|
+
.finally(() => setLoading(false));
|
|
64
|
+
}, [page]);
|
|
65
|
+
|
|
66
|
+
async function handleVerify() {
|
|
67
|
+
setVerifying(true);
|
|
68
|
+
setVerifyError(null);
|
|
69
|
+
setVerifyResult(null);
|
|
70
|
+
try {
|
|
71
|
+
setVerifyResult(await verifyAuditChain());
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setVerifyError(err instanceof ApiRequestError ? err.message : "Could not verify the audit chain.");
|
|
74
|
+
} finally {
|
|
75
|
+
setVerifying(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const hasNextPage = entries.length === PAGE_SIZE;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<div className="flex items-center justify-between mb-5">
|
|
84
|
+
<h1 className="text-xl font-bold uppercase tracking-wide">Escrow audit log</h1>
|
|
85
|
+
{isTrustedAdmin && (
|
|
86
|
+
<Button type="button" className="!w-auto" loading={verifying} disabled={verifying} onClick={handleVerify}>
|
|
87
|
+
Verify chain integrity
|
|
88
|
+
</Button>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<p className="text-sm text-text-muted mb-5">
|
|
93
|
+
Every escrow access lifecycle event visible to you — matters under a scope you hold. A trusted
|
|
94
|
+
administrator sees every entry across every scope instead.
|
|
95
|
+
</p>
|
|
96
|
+
|
|
97
|
+
{verifyError && <Alert>{verifyError}</Alert>}
|
|
98
|
+
{verifyResult &&
|
|
99
|
+
(verifyResult.valid ? (
|
|
100
|
+
<div className="mb-5 py-3 px-3.5 rounded-sm text-sm bg-success/10 text-success font-medium">
|
|
101
|
+
Chain verified — no tampering detected.
|
|
102
|
+
</div>
|
|
103
|
+
) : (
|
|
104
|
+
<Alert>Chain integrity broken at sequence {verifyResult.brokenAtSequence}.</Alert>
|
|
105
|
+
))}
|
|
106
|
+
|
|
107
|
+
{error && <Alert>{error}</Alert>}
|
|
108
|
+
|
|
109
|
+
{loading ? (
|
|
110
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
111
|
+
) : entries.length === 0 ? (
|
|
112
|
+
<p className="text-sm text-text-muted">No audit log entries visible to you yet.</p>
|
|
113
|
+
) : (
|
|
114
|
+
<div className="overflow-x-auto">
|
|
115
|
+
<table className="w-full text-sm border-collapse">
|
|
116
|
+
<thead>
|
|
117
|
+
<tr>
|
|
118
|
+
{["Sequence", "Action", "Holder", "Mailbox", "Occurred"].map((h) => (
|
|
119
|
+
<th
|
|
120
|
+
key={h}
|
|
121
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
122
|
+
>
|
|
123
|
+
{h}
|
|
124
|
+
</th>
|
|
125
|
+
))}
|
|
126
|
+
</tr>
|
|
127
|
+
</thead>
|
|
128
|
+
<tbody>
|
|
129
|
+
{entries.map((entry) => (
|
|
130
|
+
<tr key={entry.uid}>
|
|
131
|
+
<td className="py-2.5 px-2.5 border-b border-border">{entry.sequence}</td>
|
|
132
|
+
<td className="py-2.5 px-2.5 border-b border-border">{ACTION_LABELS[entry.action]}</td>
|
|
133
|
+
<td className="py-2.5 px-2.5 border-b border-border">{entry.holderUserUid}</td>
|
|
134
|
+
<td className="py-2.5 px-2.5 border-b border-border">{entry.mailboxUid}</td>
|
|
135
|
+
<td className="py-2.5 px-2.5 border-b border-border">
|
|
136
|
+
{new Date(entry.occurredAt).toLocaleString()}
|
|
137
|
+
</td>
|
|
138
|
+
</tr>
|
|
139
|
+
))}
|
|
140
|
+
</tbody>
|
|
141
|
+
</table>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<div className="flex gap-3 items-center mt-4">
|
|
146
|
+
<Button
|
|
147
|
+
variant="secondary"
|
|
148
|
+
type="button"
|
|
149
|
+
className="!w-auto"
|
|
150
|
+
disabled={page === 0 || loading}
|
|
151
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
152
|
+
>
|
|
153
|
+
Previous
|
|
154
|
+
</Button>
|
|
155
|
+
<span className="text-sm text-text-muted">Page {page + 1}</span>
|
|
156
|
+
<Button
|
|
157
|
+
variant="secondary"
|
|
158
|
+
type="button"
|
|
159
|
+
className="!w-auto"
|
|
160
|
+
disabled={!hasNextPage || loading}
|
|
161
|
+
onClick={() => setPage((p) => p + 1)}
|
|
162
|
+
>
|
|
163
|
+
Next
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
</>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { listMatters, Matter } from "@rapidmx/react-shared/admin/mattersApi.js";
|
|
8
|
+
import EscrowShell, { EscrowShellProps } from "../shared/components/escrow/layout/EscrowShell.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
|
|
12
|
+
const PAGE_SIZE = 25;
|
|
13
|
+
|
|
14
|
+
export default function MattersPage(props: Omit<EscrowShellProps, "active">) {
|
|
15
|
+
return (
|
|
16
|
+
<EscrowShell {...props} active="matters">
|
|
17
|
+
<MattersContent />
|
|
18
|
+
</EscrowShell>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function MattersContent() {
|
|
23
|
+
const [page, setPage] = useState(0);
|
|
24
|
+
const [matters, setMatters] = useState<Matter[]>([]);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setLoading(true);
|
|
30
|
+
setError(null);
|
|
31
|
+
listMatters({ page, limit: PAGE_SIZE })
|
|
32
|
+
.then(setMatters)
|
|
33
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load matters."))
|
|
34
|
+
.finally(() => setLoading(false));
|
|
35
|
+
}, [page]);
|
|
36
|
+
|
|
37
|
+
const hasNextPage = matters.length === PAGE_SIZE;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<div className="flex items-center justify-between mb-5">
|
|
42
|
+
<h1 className="text-xl font-bold uppercase tracking-wide">Matters</h1>
|
|
43
|
+
<a href="/escrow/matters/new">
|
|
44
|
+
<Button type="button" className="!w-auto">
|
|
45
|
+
+ New matter
|
|
46
|
+
</Button>
|
|
47
|
+
</a>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<p className="text-sm text-text-muted mb-5">
|
|
51
|
+
Only matters under an escrow scope you hold are ever shown here — this list is already scoped to
|
|
52
|
+
you server-side.
|
|
53
|
+
</p>
|
|
54
|
+
|
|
55
|
+
{error && <Alert>{error}</Alert>}
|
|
56
|
+
|
|
57
|
+
{loading ? (
|
|
58
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
59
|
+
) : matters.length === 0 ? (
|
|
60
|
+
<p className="text-sm text-text-muted">
|
|
61
|
+
No matters yet — either none exist under a scope you hold, or you don't currently hold any
|
|
62
|
+
escrow scope.
|
|
63
|
+
</p>
|
|
64
|
+
) : (
|
|
65
|
+
<div className="overflow-x-auto">
|
|
66
|
+
<table className="w-full text-sm border-collapse">
|
|
67
|
+
<thead>
|
|
68
|
+
<tr>
|
|
69
|
+
{["Name", "Custodians", "Date range", "Status", ""].map((h) => (
|
|
70
|
+
<th
|
|
71
|
+
key={h}
|
|
72
|
+
className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border"
|
|
73
|
+
>
|
|
74
|
+
{h}
|
|
75
|
+
</th>
|
|
76
|
+
))}
|
|
77
|
+
</tr>
|
|
78
|
+
</thead>
|
|
79
|
+
<tbody>
|
|
80
|
+
{matters.map((matter) => (
|
|
81
|
+
<tr key={matter.uid}>
|
|
82
|
+
<td className="py-2.5 px-2.5 border-b border-border">{matter.name}</td>
|
|
83
|
+
<td className="py-2.5 px-2.5 border-b border-border">
|
|
84
|
+
{matter.custodianMailboxUids.length}
|
|
85
|
+
</td>
|
|
86
|
+
<td className="py-2.5 px-2.5 border-b border-border">
|
|
87
|
+
{new Date(matter.dateRangeStart).toLocaleDateString()} –{" "}
|
|
88
|
+
{new Date(matter.dateRangeEnd).toLocaleDateString()}
|
|
89
|
+
</td>
|
|
90
|
+
<td className="py-2.5 px-2.5 border-b border-border">
|
|
91
|
+
{matter.closedAt ? (
|
|
92
|
+
<span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
|
|
93
|
+
Closed
|
|
94
|
+
</span>
|
|
95
|
+
) : (
|
|
96
|
+
<span className="text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-success text-white">
|
|
97
|
+
Open
|
|
98
|
+
</span>
|
|
99
|
+
)}
|
|
100
|
+
</td>
|
|
101
|
+
<td className="py-2.5 px-2.5 border-b border-border text-right">
|
|
102
|
+
<a
|
|
103
|
+
href={`/escrow/matters/${encodeURIComponent(matter.uid)}`}
|
|
104
|
+
className="text-primary-dark hover:underline font-medium"
|
|
105
|
+
>
|
|
106
|
+
View
|
|
107
|
+
</a>
|
|
108
|
+
</td>
|
|
109
|
+
</tr>
|
|
110
|
+
))}
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
<div className="flex gap-3 items-center mt-4">
|
|
117
|
+
<Button
|
|
118
|
+
variant="secondary"
|
|
119
|
+
type="button"
|
|
120
|
+
className="!w-auto"
|
|
121
|
+
disabled={page === 0 || loading}
|
|
122
|
+
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
123
|
+
>
|
|
124
|
+
Previous
|
|
125
|
+
</Button>
|
|
126
|
+
<span className="text-sm text-text-muted">Page {page + 1}</span>
|
|
127
|
+
<Button
|
|
128
|
+
variant="secondary"
|
|
129
|
+
type="button"
|
|
130
|
+
className="!w-auto"
|
|
131
|
+
disabled={!hasNextPage || loading}
|
|
132
|
+
onClick={() => setPage((p) => p + 1)}
|
|
133
|
+
>
|
|
134
|
+
Next
|
|
135
|
+
</Button>
|
|
136
|
+
</div>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
}
|