@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.
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,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&hellip;</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&hellip;</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&hellip;</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()} &ndash;{" "}
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
+ }