@rapidmx/web-client 0.4.0 → 0.5.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/audit-log/index.tsx +35 -8
- package/apps/admin/data-requests/index.tsx +480 -485
- package/apps/admin/distribution-lists/[uid].tsx +19 -1
- package/apps/admin/escrow-scopes/[uid].tsx +348 -237
- package/apps/admin/escrow-scopes/new/index.tsx +8 -2
- package/apps/admin/index.tsx +19 -1
- package/apps/admin/ingest-queue/index.tsx +44 -5
- package/apps/admin/mailboxes/[uid].tsx +211 -184
- package/apps/admin/quarantine/index.tsx +46 -14
- package/apps/admin/transport-rules/[uid].tsx +30 -1
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +38 -2
- package/apps/admin/transport-rules/index.tsx +24 -6
- package/apps/admin/transport-rules/new/index.tsx +30 -1
- package/apps/escrow/_layout.tsx +2 -3
- package/apps/escrow/audit-log/index.tsx +196 -168
- package/apps/escrow/matters/[uid].tsx +621 -521
- package/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +22 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -1
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +3 -3
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +55 -11
- package/apps/shared/components/admin/settings/BrandingForm.tsx +15 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +18 -7
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +10 -2
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +6 -0
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +45 -12
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1235 -595
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +174 -98
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +49 -13
- package/apps/shared/components/admin/setup/SetupWizard.tsx +150 -34
- package/apps/shared/components/admin/signOut.ts +79 -0
- package/apps/shared/components/admin/usePagedList.tsx +129 -0
- package/apps/shared/components/calendar/EventModal.tsx +717 -549
- package/apps/shared/components/calendar/MonthView.tsx +173 -139
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +222 -180
- package/apps/shared/components/calendar/SplitDayView.tsx +143 -137
- package/apps/shared/components/calendar/TimeGridView.tsx +244 -198
- package/apps/shared/components/calendar/allDay.ts +124 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +1 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +100 -21
- package/apps/shared/components/contacts/ContactForm.tsx +377 -348
- package/apps/shared/components/contacts/ContactsSidebar.tsx +6 -2
- package/apps/shared/components/contacts/KeyChangeReview.tsx +192 -0
- package/apps/shared/components/contacts/contactKeys.ts +78 -0
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +148 -134
- package/apps/shared/components/layout/AppShell.tsx +87 -15
- package/apps/shared/components/layout/BrandingChrome.tsx +48 -10
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +389 -298
- package/apps/shared/components/layout/MailboxProvisioning.tsx +19 -3
- package/apps/shared/components/layout/RecoveryCodeUnlock.tsx +350 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +257 -128
- package/apps/shared/components/mail/ConversationThreadPane.tsx +216 -181
- package/apps/shared/components/mail/MessageDetailPane.tsx +1318 -630
- package/apps/shared/components/mail/compose/ComposeContext.tsx +20 -15
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +854 -132
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +12 -2
- package/apps/shared/components/mail/pinnedSigners.ts +124 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -0
- package/apps/shared/components/mail/writableMailboxes.ts +101 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -276
- package/apps/shared/mail/listAllPages.ts +39 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +64 -55
- package/apps/shared/search/localIndexBuilder.ts +481 -179
- package/apps/shared/search/localIndexRpcClient.ts +228 -28
- package/apps/shared/search/localIndexSchema.ts +15 -6
- package/apps/shared/search/localIndexStorage.ts +98 -0
- package/apps/shared/search/localIndexVFS.ts +32 -1
- package/apps/shared/search/localIndexWorker.ts +648 -186
- package/apps/www/calendar/index.tsx +438 -417
- package/apps/www/contacts/[uid].tsx +21 -2
- package/apps/www/contacts/index.tsx +116 -12
- package/apps/www/index.tsx +1314 -1041
- package/apps/www/messages/[uid].tsx +18 -6
- package/apps/www/settings/auto-reply/index.tsx +134 -129
- package/apps/www/settings/booking-types/[uid].tsx +306 -300
- package/apps/www/settings/encryption/index.tsx +739 -216
- package/apps/www/settings/filters/[uid].tsx +171 -155
- package/apps/www/settings/filters/new/index.tsx +8 -1
- package/apps/www/settings/focused-inbox/index.tsx +150 -148
- package/apps/www/settings/privacy/index.tsx +494 -436
- package/apps/www/settings/read-receipts/index.tsx +148 -125
- package/apps/www/settings/sharing/index.tsx +17 -14
- package/apps/www/tasks/index.tsx +638 -579
- package/dist/apps/admin/audit-log/index.js +38 -8
- package/dist/apps/admin/data-requests/index.js +52 -72
- package/dist/apps/admin/distribution-lists/[uid].js +1 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +91 -12
- package/dist/apps/admin/escrow-scopes/new/index.js +8 -3
- package/dist/apps/admin/index.js +4 -1
- package/dist/apps/admin/ingest-queue/index.js +24 -6
- package/dist/apps/admin/mailboxes/[uid].js +20 -4
- package/dist/apps/admin/quarantine/index.js +20 -11
- package/dist/apps/admin/transport-rules/[uid].js +18 -2
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +19 -0
- package/dist/apps/admin/transport-rules/index.js +22 -6
- package/dist/apps/admin/transport-rules/new/index.js +18 -2
- package/dist/apps/escrow/_layout.js +3 -3
- package/dist/apps/escrow/audit-log/index.js +26 -1
- package/dist/apps/escrow/matters/[uid].js +77 -42
- package/dist/apps/shared/components/admin/escrowScopes/EscrowScopeKeyAndHoldersFields.js +11 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +3 -1
- package/dist/apps/shared/components/admin/mailboxes/EscrowScopeCard.js +72 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +3 -3
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +22 -4
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +15 -4
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +13 -7
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +7 -2
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +6 -0
- package/dist/apps/shared/components/admin/settings/MailboxPolicyForm.js +34 -12
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +390 -78
- package/dist/apps/shared/components/admin/settings/RetentionPolicyForm.js +40 -11
- package/dist/apps/shared/components/admin/setup/EscrowSetupStep.js +29 -13
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +87 -21
- package/dist/apps/shared/components/admin/signOut.js +75 -0
- package/dist/apps/shared/components/admin/usePagedList.js +99 -0
- package/dist/apps/shared/components/calendar/EventModal.js +142 -21
- package/dist/apps/shared/components/calendar/MonthView.js +12 -5
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +40 -5
- package/dist/apps/shared/components/calendar/SplitDayView.js +10 -4
- package/dist/apps/shared/components/calendar/TimeGridView.js +23 -7
- package/dist/apps/shared/components/calendar/allDay.js +111 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +1 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +43 -5
- package/dist/apps/shared/components/contacts/ContactForm.js +32 -10
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +63 -0
- package/dist/apps/shared/components/contacts/contactKeys.js +64 -0
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +24 -5
- package/dist/apps/shared/components/layout/AppShell.js +83 -14
- package/dist/apps/shared/components/layout/BrandingChrome.js +45 -10
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +49 -12
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +10 -2
- package/dist/apps/shared/components/layout/RecoveryCodeUnlock.js +174 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +73 -14
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +50 -12
- package/dist/apps/shared/components/mail/MessageDetailPane.js +441 -36
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +9 -6
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +699 -128
- package/dist/apps/shared/components/mail/compose/composeFlushRegistry.js +50 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +12 -3
- package/dist/apps/shared/components/mail/pinnedSigners.js +98 -0
- package/dist/apps/shared/components/mail/verificationSeals.js +112 -0
- package/dist/apps/shared/components/mail/writableMailboxes.js +87 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +34 -6
- package/dist/apps/shared/mail/listAllPages.js +25 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +58 -54
- package/dist/apps/shared/search/localIndexBuilder.js +292 -37
- package/dist/apps/shared/search/localIndexRpcClient.js +188 -25
- package/dist/apps/shared/search/localIndexSchema.js +12 -6
- package/dist/apps/shared/search/localIndexStorage.js +86 -0
- package/dist/apps/shared/search/localIndexVFS.js +68 -40
- package/dist/apps/shared/search/localIndexWorker.js +519 -173
- package/dist/apps/www/calendar/index.js +28 -10
- package/dist/apps/www/contacts/[uid].js +13 -2
- package/dist/apps/www/contacts/index.js +80 -10
- package/dist/apps/www/index.js +339 -108
- package/dist/apps/www/messages/[uid].js +18 -6
- package/dist/apps/www/settings/auto-reply/index.js +9 -4
- package/dist/apps/www/settings/booking-types/[uid].js +17 -12
- package/dist/apps/www/settings/encryption/index.js +514 -97
- package/dist/apps/www/settings/filters/[uid].js +22 -7
- package/dist/apps/www/settings/filters/new/index.js +8 -2
- package/dist/apps/www/settings/focused-inbox/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +57 -22
- package/dist/apps/www/settings/read-receipts/index.js +10 -3
- package/dist/apps/www/settings/sharing/index.js +7 -11
- package/dist/apps/www/tasks/index.js +50 -10
- package/package.json +2 -2
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { format } from "date-fns";
|
|
6
|
+
import { toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
7
|
+
const MS_PER_DAY = 86400000;
|
|
8
|
+
/**
|
|
9
|
+
* All-day events are stored date-only: `startDate` is UTC midnight of the first day and `endDate` is UTC
|
|
10
|
+
* midnight of the day *after* the last day (exclusive, like iCalendar's `DTEND;VALUE=DATE`). That keeps
|
|
11
|
+
* an all-day event on the same calendar date for every viewer, whatever their timezone.
|
|
12
|
+
*
|
|
13
|
+
* Older events were saved as the creator's *local* midnight converted to UTC (e.g. `...T22:00Z` for a
|
|
14
|
+
* UTC+2 creator), so reading one rounds the instant to the nearest UTC midnight rather than slicing the
|
|
15
|
+
* ISO string — which gives the intended date for both formats.
|
|
16
|
+
*/
|
|
17
|
+
export function allDayDateKey(iso) {
|
|
18
|
+
const rounded = Math.round(new Date(iso).getTime() / MS_PER_DAY) * MS_PER_DAY;
|
|
19
|
+
return new Date(rounded).toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
/** `YYYY-MM-DD` -> the stored UTC-midnight instant for that date. */
|
|
22
|
+
export function allDayInstant(dateKey) {
|
|
23
|
+
return `${dateKey}T00:00:00.000Z`;
|
|
24
|
+
}
|
|
25
|
+
/** Adds `days` to a `YYYY-MM-DD` date key. */
|
|
26
|
+
export function addDaysToKey(dateKey, days) {
|
|
27
|
+
return new Date(new Date(allDayInstant(dateKey)).getTime() + days * MS_PER_DAY).toISOString().slice(0, 10);
|
|
28
|
+
}
|
|
29
|
+
/** The local calendar date `day` (a local-midnight `Date` from a grid view) as a `YYYY-MM-DD` key. */
|
|
30
|
+
export function localDateKey(day) {
|
|
31
|
+
return format(day, "yyyy-MM-dd");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A recurrence rule's inclusive "Ends on" date -> its stored `until` instant: the end of that day in the
|
|
35
|
+
* frame the series expands in. A timed series expands in its own (normally the viewer's) timezone, so that's
|
|
36
|
+
* the end of the local day - `new Date("YYYY-MM-DD")` would be UTC midnight, the previous day west of UTC.
|
|
37
|
+
* An all-day series expands in UTC (react-shared's `expandOccurrences`), so it's the end of the UTC day: the
|
|
38
|
+
* local end of day is already the next UTC day west of UTC and would add one more occurrence.
|
|
39
|
+
*/
|
|
40
|
+
export function recurrenceUntilInstant(dateKey, allDay) {
|
|
41
|
+
if (allDay) {
|
|
42
|
+
return `${dateKey}T23:59:59.999Z`;
|
|
43
|
+
}
|
|
44
|
+
const [y, m, d] = dateKey.split("-").map(Number);
|
|
45
|
+
return new Date(y, m - 1, d, 23, 59, 59, 999).toISOString();
|
|
46
|
+
}
|
|
47
|
+
/** The stored form of an all-day series' "Ends on" date - the last millisecond of that UTC day. */
|
|
48
|
+
const ALL_DAY_UNTIL_PATTERN = /^(\d{4}-\d{2}-\d{2})T23:59:59\.999Z$/;
|
|
49
|
+
/** A bare UTC midnight - how the oldest editor (and date-only `UNTIL` values) stored the chosen date itself. */
|
|
50
|
+
const UTC_MIDNIGHT_PATTERN = /^(\d{4}-\d{2}-\d{2})T00:00:00(?:\.000)?Z$/;
|
|
51
|
+
/**
|
|
52
|
+
* Inverse of `recurrenceUntilInstant()`. An all-day series' value is recognized explicitly in its stored
|
|
53
|
+
* `T23:59:59.999Z` form (and as a bare UTC midnight, the oldest form). Anything else is a legacy value stored
|
|
54
|
+
* as the creator's *local* end of day, which can land anywhere from just before 10:00Z on the chosen date (UTC+14)
|
|
55
|
+
* to just before 12:00Z the day after (UTC-12) - a 26-hour spread no UTC-only rounding can resolve (UTC+14 and UTC-10
|
|
56
|
+
* creators' values are exactly 24h apart for the same date), so it reads back on the local calendar, the
|
|
57
|
+
* frame it was written in.
|
|
58
|
+
*/
|
|
59
|
+
export function recurrenceUntilDateKey(until, allDay) {
|
|
60
|
+
if (allDay) {
|
|
61
|
+
const stored = ALL_DAY_UNTIL_PATTERN.exec(until) ?? UTC_MIDNIGHT_PATTERN.exec(until);
|
|
62
|
+
if (stored) {
|
|
63
|
+
return stored[1];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return format(new Date(until), "yyyy-MM-dd");
|
|
67
|
+
}
|
|
68
|
+
/** `Date#getUTCDay()` index -> RFC 5545 weekday code. */
|
|
69
|
+
const JS_WEEKDAY_CODES = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
|
|
70
|
+
/** The weekday an event starts on, in the frame its series expands in: the UTC date for an all-day event
|
|
71
|
+
* (`start`'s date part), the event's own timezone otherwise (`start` being a local `datetime-local` value).
|
|
72
|
+
* `undefined` while the start input is empty/invalid. */
|
|
73
|
+
export function startWeekdayCode(start, allDay, timezone) {
|
|
74
|
+
const ms = allDay ? new Date(allDayInstant(start.slice(0, 10))).getTime() : new Date(start).getTime();
|
|
75
|
+
if (Number.isNaN(ms)) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return JS_WEEKDAY_CODES[new Date(toEventWallClock(ms, timezone, allDay)).getUTCDay()];
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Whether `occurrence` should be shown on the grid's local calendar day `day`. All-day events cover every
|
|
82
|
+
* date from their start date up to (not including) their exclusive end date — at least their start date.
|
|
83
|
+
* Timed events cover every local day their [start, end) interval overlaps, so a multi-day event shows on
|
|
84
|
+
* each day rather than only on its start day.
|
|
85
|
+
*/
|
|
86
|
+
export function occursOnDay(occurrence, day) {
|
|
87
|
+
if (occurrence.allDay) {
|
|
88
|
+
const key = localDateKey(day);
|
|
89
|
+
const startKey = allDayDateKey(occurrence.startDate);
|
|
90
|
+
const endKey = allDayDateKey(occurrence.endDate);
|
|
91
|
+
return key === startKey || (key > startKey && key < endKey);
|
|
92
|
+
}
|
|
93
|
+
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate());
|
|
94
|
+
const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1);
|
|
95
|
+
const start = new Date(occurrence.startDate);
|
|
96
|
+
const end = new Date(occurrence.endDate);
|
|
97
|
+
if (start.getTime() === end.getTime()) {
|
|
98
|
+
return start >= dayStart && start < dayEnd;
|
|
99
|
+
}
|
|
100
|
+
return start < dayEnd && end > dayStart;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Whether `day` is the first day `occurrence` covers. Grid views only make that day's chip/block
|
|
104
|
+
* draggable — a drag id must be unique, so the continuation copies on later days are display-only.
|
|
105
|
+
*/
|
|
106
|
+
export function startsOnDay(occurrence, day) {
|
|
107
|
+
if (occurrence.allDay) {
|
|
108
|
+
return allDayDateKey(occurrence.startDate) === localDateKey(day);
|
|
109
|
+
}
|
|
110
|
+
return localDateKey(new Date(occurrence.startDate)) === localDateKey(day);
|
|
111
|
+
}
|
|
@@ -62,7 +62,7 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
|
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
let cancelled = false;
|
|
65
|
-
Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
|
|
65
|
+
void Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid)
|
|
66
66
|
.then((folders) => ({ mailbox, calendarFolders: folders.filter((f) => f.type === "calendar") }))
|
|
67
67
|
.catch((err) => ({
|
|
68
68
|
mailbox,
|
|
@@ -1,20 +1,58 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
2
7
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
3
|
-
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
4
8
|
import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
|
|
9
|
+
import KeyChangeReview from "./KeyChangeReview.js";
|
|
10
|
+
import { KEY_CHANGE_STALE_MESSAGE, formatDate, keyPinnedSince, revocationLabel } from "./contactKeys.js";
|
|
11
|
+
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
5
12
|
/** Groups a hex fingerprint into 4-character blocks (`ab12 cd34 ...`) for out-of-band verification -
|
|
6
13
|
* the spec's own use case ("compare this over the phone") is materially easier with a grouped string
|
|
7
14
|
* than one 64-character run. */
|
|
8
15
|
function formatFingerprint(fingerprint) {
|
|
9
16
|
return fingerprint.match(/.{1,4}/g)?.join(" ") ?? fingerprint;
|
|
10
17
|
}
|
|
18
|
+
/** A revoked key's reason: a superseded key was routinely replaced, a revoked one must not be trusted. */
|
|
19
|
+
function RevocationBadge({ label }) {
|
|
20
|
+
if (!label) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return _jsxs("span", { className: label === "superseded" ? "text-text-muted" : "text-danger", children: [" (", label, ")"] });
|
|
24
|
+
}
|
|
11
25
|
/**
|
|
12
26
|
* A contact's read-only detail view. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`,
|
|
13
27
|
* always visible alongside the contact list) and the mobile detail route
|
|
14
28
|
* (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
|
|
15
29
|
*/
|
|
16
|
-
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref }) {
|
|
17
|
-
|
|
30
|
+
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }) {
|
|
31
|
+
// The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
|
|
32
|
+
const [keyNotice, setKeyNotice] = useState(null);
|
|
33
|
+
const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
|
|
34
|
+
const keyConflicts = contact.keyConflicts ?? [];
|
|
35
|
+
const previousKeys = contact.previousKeys ?? [];
|
|
36
|
+
// Pinned signing keys vouch for signatures, so the pinned-signer cache is dropped along with re-reading the contact.
|
|
37
|
+
function refresh(text) {
|
|
38
|
+
setKeyNotice({ contactUid: contact.uid, text });
|
|
39
|
+
clearPinnedSignerCache();
|
|
40
|
+
onKeysChanged?.();
|
|
41
|
+
}
|
|
42
|
+
return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-bold tracking-tight", children: contact.displayName }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
|
|
43
|
+
contact.encryptPreference ||
|
|
44
|
+
keyConflicts.length > 0 ||
|
|
45
|
+
previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {
|
|
46
|
+
const pinned = contact.keys?.find((key) => key.useType === conflict.useType);
|
|
47
|
+
const kind = conflict.useType === "sign" ? "signing" : "encryption";
|
|
48
|
+
return (_jsxs("section", { "aria-label": `${conflict.useType === "sign" ? "Signing" : "Encryption"} key change`, className: "mb-3 py-3 px-3 rounded-sm bg-warning/15 text-text flex flex-col gap-2", children: [_jsxs("h2", { className: "font-semibold", children: ["A different ", kind, " key was seen for ", contact.displayName] }), _jsx("p", { children: "The current key stays in use until you decide what to do with the new one." }), _jsx(KeyChangeReview, { mailboxUid: contact.mailboxUid, address: contact.emails[0]?.address, useType: conflict.useType, ownerName: contact.displayName, current: pinned && { fingerprint: pinned.fingerprint, since: keyPinnedSince(contact, pinned) }, proposed: {
|
|
49
|
+
fingerprint: conflict.observedKey.fingerprint,
|
|
50
|
+
observedAt: conflict.observedAt,
|
|
51
|
+
source: conflict.source,
|
|
52
|
+
}, canReject: true, canResolve: canResolveKeys, onResolved: (action) => refresh(action === "accept"
|
|
53
|
+
? `The new ${kind} key is now trusted for ${contact.displayName}.`
|
|
54
|
+
: `You kept the current ${kind} key for ${contact.displayName}.`), onPinnedKeyChanged: () => refresh(KEY_CHANGE_STALE_MESSAGE) })] }, conflict.useType));
|
|
55
|
+
}), contact.encryptPreference && (_jsx("div", { className: "mb-1", children: contact.encryptPreference.preferEncrypt === "mutual"
|
|
18
56
|
? "This contact also encrypts to you — messages to them can be encrypted."
|
|
19
|
-
: "This contact has not indicated a mutual encryption preference." })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint), key.
|
|
57
|
+
: "This contact has not indicated a mutual encryption preference." })), contact.keys?.map((key) => (_jsxs("div", { className: "font-mono text-xs py-0.5", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint), _jsx(RevocationBadge, { label: revocationLabel(key) })] }, key.fingerprint))), previousKeys.length > 0 && (_jsxs("div", { className: "mt-3", children: [_jsx("div", { className: "text-text-muted text-xs font-bold mb-1", children: "Key history" }), _jsx("ul", { "aria-label": "Key history", children: previousKeys.map((key) => (_jsxs("li", { className: "text-xs py-0.5", children: [_jsxs("span", { className: "font-mono", children: [key.useType === "sign" ? "Signing" : "Encryption", " key: ", formatFingerprint(key.fingerprint)] }), _jsx(RevocationBadge, { label: revocationLabel(key) }), _jsxs("span", { className: "text-text-muted", children: [" ", "\u00B7", " ", key.replacement === "automatic" ? "Renewed automatically" : "Replaced by you", " on", " ", formatDate(key.replacedAt)] })] }, `${key.useType}:${key.fingerprint}:${key.replacedAt}`))) })] }))] })) : null] })] }));
|
|
20
58
|
}
|
|
@@ -10,6 +10,7 @@ import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
|
10
10
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
11
|
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
12
12
|
import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
|
|
13
|
+
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
13
14
|
const INPUT_CLASS = "w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
14
15
|
const SELECT_CLASS = "text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
15
16
|
const ADDRESS_KINDS = ["home", "work", "other"];
|
|
@@ -30,7 +31,9 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
30
31
|
const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
|
|
31
32
|
const [emails, setEmails] = useState(contact?.emails ?? []);
|
|
32
33
|
const [phones, setPhones] = useState(contact?.phones ?? []);
|
|
33
|
-
|
|
34
|
+
// Every stored address is kept and editable - editing a contact must never silently drop addresses
|
|
35
|
+
// beyond the first.
|
|
36
|
+
const [addresses, setAddresses] = useState(contact?.addresses ?? []);
|
|
34
37
|
const [error, setError] = useState(null);
|
|
35
38
|
const [saving, setSaving] = useState(false);
|
|
36
39
|
function updateEmail(index, patch) {
|
|
@@ -45,6 +48,12 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
45
48
|
function removePhone(index) {
|
|
46
49
|
setPhones((prev) => prev.filter((_, i) => i !== index));
|
|
47
50
|
}
|
|
51
|
+
function updateAddress(index, patch) {
|
|
52
|
+
setAddresses((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a)));
|
|
53
|
+
}
|
|
54
|
+
function removeAddress(index) {
|
|
55
|
+
setAddresses((prev) => prev.filter((_, i) => i !== index));
|
|
56
|
+
}
|
|
48
57
|
async function handleSubmit(e) {
|
|
49
58
|
e.preventDefault();
|
|
50
59
|
setError(null);
|
|
@@ -58,22 +67,29 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
58
67
|
.split(",")
|
|
59
68
|
.map((c) => c.trim())
|
|
60
69
|
.filter(Boolean);
|
|
70
|
+
// An update merges only the fields in its body, so a field the user cleared is sent as an
|
|
71
|
+
// explicit `null` (an omitted/undefined one would keep its stored value); a create just omits it.
|
|
72
|
+
const cleared = contact ? null : undefined;
|
|
61
73
|
const input = {
|
|
62
74
|
displayName: displayName.trim(),
|
|
63
|
-
givenName: givenName.trim() ||
|
|
64
|
-
surname: surname.trim() ||
|
|
65
|
-
company: company.trim() ||
|
|
66
|
-
jobTitle: jobTitle.trim() ||
|
|
67
|
-
notes: notes.trim() ||
|
|
75
|
+
givenName: givenName.trim() || cleared,
|
|
76
|
+
surname: surname.trim() || cleared,
|
|
77
|
+
company: company.trim() || cleared,
|
|
78
|
+
jobTitle: jobTitle.trim() || cleared,
|
|
79
|
+
notes: notes.trim() || cleared,
|
|
68
80
|
favorite,
|
|
69
81
|
categories,
|
|
70
82
|
emails,
|
|
71
83
|
phones,
|
|
72
|
-
addresses
|
|
84
|
+
addresses,
|
|
73
85
|
};
|
|
74
86
|
let saved;
|
|
75
87
|
if (contact) {
|
|
76
|
-
saved = await updateContact({
|
|
88
|
+
saved = await updateContact({
|
|
89
|
+
uid: contact.uid,
|
|
90
|
+
version: contact.version,
|
|
91
|
+
...input,
|
|
92
|
+
});
|
|
77
93
|
}
|
|
78
94
|
else {
|
|
79
95
|
const targetFolderUid = targetMailboxUid === mailboxUid ? folderUid : await findWellKnownFolderUid(targetMailboxUid, "contacts");
|
|
@@ -81,8 +97,14 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
81
97
|
setError("That mailbox has no Contacts folder.");
|
|
82
98
|
return;
|
|
83
99
|
}
|
|
84
|
-
saved = await createContact({
|
|
100
|
+
saved = await createContact({
|
|
101
|
+
mailboxUid: targetMailboxUid,
|
|
102
|
+
folderUid: targetFolderUid,
|
|
103
|
+
...input,
|
|
104
|
+
});
|
|
85
105
|
}
|
|
106
|
+
// A message opened later in this page load must see the changed contact's signing keys.
|
|
107
|
+
clearPinnedSignerCache();
|
|
86
108
|
onSaved(saved);
|
|
87
109
|
}
|
|
88
110
|
catch (err) {
|
|
@@ -92,5 +114,5 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
92
114
|
setSaving(false);
|
|
93
115
|
}
|
|
94
116
|
}
|
|
95
|
-
return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: address
|
|
117
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "max-w-xl flex flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsxs("label", { className: "flex items-center gap-2 text-sm mb-4", children: [_jsx("input", { type: "checkbox", checked: favorite, onChange: (e) => setFavorite(e.target.checked) }), "Favorite"] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) }), _jsxs("div", { className: "flex gap-3 mt-2", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
|
|
96
118
|
}
|
|
@@ -43,7 +43,7 @@ function NavItem({ label, count, active, onClick, swatch, }) {
|
|
|
43
43
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
44
44
|
* `ContactsShellContext` itself minimal (mailbox/folder resolution only).
|
|
45
45
|
*/
|
|
46
|
-
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken }) {
|
|
46
|
+
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true }) {
|
|
47
47
|
const [lists, setLists] = useState([]);
|
|
48
48
|
const [listsError, setListsError] = useState(null);
|
|
49
49
|
const [addingList, setAddingList] = useState(false);
|
|
@@ -100,6 +100,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
100
100
|
// A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
|
|
101
101
|
// `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
|
|
102
102
|
// behind the `idPrefix`).
|
|
103
|
-
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
103
|
+
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
104
104
|
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden md:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
105
105
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { PinnedKeyChangedError, resolveKeyConflict } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
8
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
9
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
10
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
11
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
12
|
+
import { conflictSourceLabel, formatDate, groupFingerprint, keyChangeErrorMessage } from "./contactKeys.js";
|
|
13
|
+
/**
|
|
14
|
+
* The old-versus-new comparison for a changed contact key, with Accept new key (confirmed in a dialog) and Keep current
|
|
15
|
+
* key, calling `resolveKeyConflict()` against the pinned fingerprint shown. Shared by a message's "signing key changed"
|
|
16
|
+
* notice and a contact's recorded key conflicts.
|
|
17
|
+
*/
|
|
18
|
+
export default function KeyChangeReview({ mailboxUid, address, useType, ownerName, current, proposed, certificate, canReject, canResolve, onResolved, onPinnedKeyChanged, }) {
|
|
19
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
20
|
+
const [busy, setBusy] = useState(null);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
const [forbidden, setForbidden] = useState(false);
|
|
23
|
+
const kind = useType === "sign" ? "signing" : "encryption";
|
|
24
|
+
const actionable = !!address && !!current && canResolve !== false && !forbidden;
|
|
25
|
+
async function resolve(action) {
|
|
26
|
+
setBusy(action);
|
|
27
|
+
setError(null);
|
|
28
|
+
try {
|
|
29
|
+
await resolveKeyConflict(mailboxUid, {
|
|
30
|
+
address: address,
|
|
31
|
+
useType,
|
|
32
|
+
action,
|
|
33
|
+
expectedPinnedFingerprint: current.fingerprint,
|
|
34
|
+
...(action === "accept" && certificate !== undefined ? { certificate } : {}),
|
|
35
|
+
});
|
|
36
|
+
setConfirmOpen(false);
|
|
37
|
+
onResolved(action);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
setConfirmOpen(false);
|
|
41
|
+
if (err instanceof PinnedKeyChangedError) {
|
|
42
|
+
onPinnedKeyChanged();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (err instanceof ApiRequestError && err.status === 403) {
|
|
46
|
+
setForbidden(true);
|
|
47
|
+
}
|
|
48
|
+
setError(keyChangeErrorMessage(err));
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
setBusy(null);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const proposedSeen = proposed.observedAt !== undefined
|
|
55
|
+
? `First seen ${formatDate(proposed.observedAt)}${proposed.source ? `, ${conflictSourceLabel(proposed.source)}` : ""}`
|
|
56
|
+
: "Only seen in this message";
|
|
57
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-2", children: [_jsxs("div", { className: "rounded-sm bg-surface p-2", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Current key" }), current ? (_jsxs("dd", { children: [_jsx("span", { className: "font-mono text-xs break-all", children: groupFingerprint(current.fingerprint) }), _jsxs("span", { className: "block text-xs text-text-muted", children: ["First seen ", formatDate(current.since)] })] })) : (_jsx("dd", { className: "text-xs text-text-muted", children: "The key you trust couldn\u2019t be loaded." }))] }), _jsxs("div", { className: "rounded-sm bg-surface p-2", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "New key" }), _jsxs("dd", { children: [_jsx("span", { className: "font-mono text-xs break-all", children: groupFingerprint(proposed.fingerprint) }), proposed.emails && (_jsxs("span", { className: "block text-xs text-text-muted", children: ["Certificate for ", proposed.emails.length > 0 ? proposed.emails.join(", ") : "no email address"] })), _jsx("span", { className: "block text-xs text-text-muted", children: proposedSeen })] })] })] }), _jsxs("p", { children: ["This is routine when ", ownerName, " renews a certificate, but it can also mean someone is impersonating them. Confirm the new fingerprint with ", ownerName, " another way, such as a phone call, before accepting it."] }), error && _jsx(Alert, { children: error }), actionable && (_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { type: "button", className: "!w-auto", disabled: busy !== null, onClick: () => {
|
|
58
|
+
setError(null);
|
|
59
|
+
setConfirmOpen(true);
|
|
60
|
+
}, children: "Accept new key" }), canReject && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: busy === "reject", disabled: busy !== null, onClick: () => resolve("reject"), children: "Keep current key" }))] })), _jsx(Modal, { open: confirmOpen, onClose: () => busy === null && setConfirmOpen(false), title: "Accept the new key?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: [useType === "sign"
|
|
61
|
+
? `Mail from ${address} signed with this key will show as verified.`
|
|
62
|
+
: `Mail you send to ${address} will be encrypted to this key.`, " ", "The current ", kind, " key moves to this contact\u2019s key history."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "New key fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: groupFingerprint(proposed.fingerprint) })] }), _jsxs("p", { className: "text-text-muted", children: ["Only accept it once you\u2019ve confirmed this fingerprint with ", ownerName, "."] }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: busy === "accept", disabled: busy !== null, onClick: () => resolve("accept"), children: "Accept new key" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy !== null, onClick: () => setConfirmOpen(false), children: "Cancel" })] })] }) })] }));
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** Display and error helpers for a contact's pinned keys and key changes (`KeyChangeReview`, `ContactDetailPane`,
|
|
6
|
+
* `MessageDetailPane`). */
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import { PinnedKeyChangedError } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
9
|
+
export const KEY_CHANGE_STALE_MESSAGE = "This contact's keys changed while you were looking; reload to see the latest.";
|
|
10
|
+
export const KEY_CHANGE_INVALID_MESSAGE = "The new key isn't a valid, current certificate for this address, so it can't be accepted. Nothing was changed.";
|
|
11
|
+
export const KEY_CHANGE_FORBIDDEN_MESSAGE = "You don't have permission to change this contact's keys.";
|
|
12
|
+
export const KEY_CHANGE_NOT_FOUND_MESSAGE = "There's no key change to resolve for this address any more. It may already have been resolved, or the contact was removed.";
|
|
13
|
+
export const KEY_CHANGE_GENERIC_MESSAGE = "Couldn't update this contact's keys. Try again.";
|
|
14
|
+
/** The error text for a failed `resolveKeyConflict()` other than `PinnedKeyChangedError` (which the caller reloads
|
|
15
|
+
* on and reports with `KEY_CHANGE_STALE_MESSAGE`). */
|
|
16
|
+
export function keyChangeErrorMessage(err) {
|
|
17
|
+
if (err instanceof PinnedKeyChangedError) {
|
|
18
|
+
return KEY_CHANGE_STALE_MESSAGE;
|
|
19
|
+
}
|
|
20
|
+
if (err instanceof ApiRequestError && err.status === 400) {
|
|
21
|
+
return KEY_CHANGE_INVALID_MESSAGE;
|
|
22
|
+
}
|
|
23
|
+
if (err instanceof ApiRequestError && err.status === 403) {
|
|
24
|
+
return KEY_CHANGE_FORBIDDEN_MESSAGE;
|
|
25
|
+
}
|
|
26
|
+
if (err instanceof ApiRequestError && err.status === 404) {
|
|
27
|
+
return KEY_CHANGE_NOT_FOUND_MESSAGE;
|
|
28
|
+
}
|
|
29
|
+
return KEY_CHANGE_GENERIC_MESSAGE;
|
|
30
|
+
}
|
|
31
|
+
/** Groups a fingerprint into 4-character blocks (`ab12 cd34 ...`), ignoring any separators it came with, so it can be
|
|
32
|
+
* read out and compared over another channel. */
|
|
33
|
+
export function groupFingerprint(fingerprint) {
|
|
34
|
+
const compact = fingerprint.replace(/[\s:]/g, "").toLowerCase();
|
|
35
|
+
return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
|
|
36
|
+
}
|
|
37
|
+
/** Whether two fingerprints name the same key (case and separators ignored). */
|
|
38
|
+
export function sameFingerprint(a, b) {
|
|
39
|
+
return a !== undefined && b !== undefined && a.replace(/[\s:]/g, "").toLowerCase() === b.replace(/[\s:]/g, "").toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
/** A revoked key's label: `"superseded"` for a routine replacement, `"revoked"` for a compromised key or one revoked
|
|
42
|
+
* with no reason; `undefined` when the key isn't revoked. */
|
|
43
|
+
export function revocationLabel(key) {
|
|
44
|
+
if (!key.revokedAt) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return key.revocationReason === "superseded" ? "superseded" : "revoked";
|
|
48
|
+
}
|
|
49
|
+
/** Where a key conflict was observed, for display. */
|
|
50
|
+
export function conflictSourceLabel(source) {
|
|
51
|
+
return source === "header" ? "from an incoming message" : "by key discovery";
|
|
52
|
+
}
|
|
53
|
+
/** When `key` (one of `contact`'s pinned keys) started being trusted: when it replaced the newest previous key of its
|
|
54
|
+
* use, else the contact's `keysFirstSeen`, else the key's own `notBefore`. */
|
|
55
|
+
export function keyPinnedSince(contact, key) {
|
|
56
|
+
const replaced = (contact.previousKeys ?? []).filter((previous) => previous.useType === key.useType).map((previous) => previous.replacedAt);
|
|
57
|
+
if (replaced.length > 0) {
|
|
58
|
+
return Math.max(...replaced);
|
|
59
|
+
}
|
|
60
|
+
return contact.keysFirstSeen ?? key.notBefore;
|
|
61
|
+
}
|
|
62
|
+
export function formatDate(ms) {
|
|
63
|
+
return new Date(ms).toLocaleDateString();
|
|
64
|
+
}
|
|
@@ -9,11 +9,12 @@ import { HiOutlineClipboardDocumentList, HiOutlineFolderOpen } from "react-icons
|
|
|
9
9
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
10
10
|
import { listMatters } from "@rapidmx/react-shared/admin/mattersApi.js";
|
|
11
11
|
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
12
|
-
import
|
|
12
|
+
import { getBranding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
13
13
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
14
14
|
import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
15
|
-
import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
|
|
16
15
|
import UserMenu from "../../layout/UserMenu.js";
|
|
16
|
+
import { signOutOfConsole } from "../../admin/signOut.js";
|
|
17
|
+
const DEFAULT_ICON_SRC = "/images/logo.svg";
|
|
17
18
|
const NAV_ITEMS = [
|
|
18
19
|
{ id: "matters", href: "/escrow", label: "Matters", icon: HiOutlineFolderOpen },
|
|
19
20
|
{ id: "auditLog", href: "/escrow/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
|
|
@@ -37,8 +38,25 @@ const NAV_ITEMS = [
|
|
|
37
38
|
export default function EscrowShell({ active, userUid, authServerUrl, children }) {
|
|
38
39
|
const [status, setStatus] = useState("checking");
|
|
39
40
|
const [error, setError] = useState(null);
|
|
40
|
-
const
|
|
41
|
+
const [iconSrc, setIconSrc] = useState(DEFAULT_ICON_SRC);
|
|
41
42
|
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
43
|
+
// Only the icon is taken from branding here. The escrow console deliberately skips `useBranding()`, which
|
|
44
|
+
// would inject the custom stylesheet, and never renders the admin-configured header/footer HTML - this
|
|
45
|
+
// console handles escrow key material, so no admin-supplied markup or CSS runs on it.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
let cancelled = false;
|
|
48
|
+
getBranding()
|
|
49
|
+
.then((branding) => {
|
|
50
|
+
if (!cancelled)
|
|
51
|
+
setIconSrc(branding.iconUrl || branding.logoUrl || DEFAULT_ICON_SRC);
|
|
52
|
+
})
|
|
53
|
+
.catch(() => {
|
|
54
|
+
// Decorative only - keep the default icon.
|
|
55
|
+
});
|
|
56
|
+
return () => {
|
|
57
|
+
cancelled = true;
|
|
58
|
+
};
|
|
59
|
+
}, []);
|
|
42
60
|
useEffect(() => {
|
|
43
61
|
if (!userUid) {
|
|
44
62
|
return;
|
|
@@ -53,7 +71,8 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
|
|
|
53
71
|
});
|
|
54
72
|
}, [userUid]);
|
|
55
73
|
function handleSignOut() {
|
|
56
|
-
|
|
74
|
+
// Ends the auth-server session and tells other tabs, not just navigates - see `signOutOfConsole()`.
|
|
75
|
+
void signOutOfConsole(authServerUrl);
|
|
57
76
|
}
|
|
58
77
|
let content;
|
|
59
78
|
if (!userUid || status === "checking") {
|
|
@@ -71,5 +90,5 @@ export default function EscrowShell({ active, userUid, authServerUrl, children }
|
|
|
71
90
|
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
72
91
|
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] }), _jsx(BottomTabBar, { apps: NAV_ITEMS, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [_jsxs("header", { className: "h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeItem?.label }), _jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut })] }), _jsx("div", { className: "flex-1 pb-14 md:pb-0", children: _jsx("main", { className: "max-w-6xl mx-auto px-6 py-8", children: children }) })] })] }) }));
|
|
73
92
|
}
|
|
74
|
-
return (
|
|
93
|
+
return _jsx(_Fragment, { children: content });
|
|
75
94
|
}
|