@rapidmx/web-client 0.23.0 → 0.25.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/mailboxes/[uid].tsx +2 -1
- package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
- package/apps/shared/components/admin/layout/railPreference.ts +25 -0
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +2 -1
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
- package/apps/shared/components/calendar/EventDescriptionView.tsx +37 -5
- package/apps/shared/components/calendar/EventDetails.tsx +15 -6
- package/apps/shared/components/calendar/EventEditor.tsx +5 -2
- package/apps/shared/components/calendar/EventFormParts.tsx +45 -39
- package/apps/shared/components/calendar/EventQuickForm.tsx +3 -2
- package/apps/shared/components/calendar/FindATime.tsx +3 -2
- package/apps/shared/components/calendar/TimeComboBox.tsx +143 -0
- package/apps/shared/components/calendar/eventFormat.ts +15 -0
- package/apps/shared/components/calendar/timePicker.ts +106 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
- package/apps/shared/components/layout/UserMenu.tsx +30 -10
- package/apps/shared/components/mail/invite/inviteFormat.ts +6 -5
- package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
- package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
- package/apps/shared/navigation/appHrefs.ts +3 -3
- package/apps/www/calendar/index.tsx +5 -2
- package/apps/www/settings/profile/index.tsx +8 -12
- package/dist/apps/admin/mailboxes/[uid].js +2 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
- package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
- package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +2 -2
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +6 -1
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +34 -4
- package/dist/apps/shared/components/calendar/EventDetails.js +4 -4
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -2
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +9 -3
- package/dist/apps/shared/components/calendar/EventQuickForm.js +3 -2
- package/dist/apps/shared/components/calendar/FindATime.js +3 -2
- package/dist/apps/shared/components/calendar/TimeComboBox.d.ts +25 -0
- package/dist/apps/shared/components/calendar/TimeComboBox.js +77 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +4 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +13 -0
- package/dist/apps/shared/components/calendar/timePicker.d.ts +31 -0
- package/dist/apps/shared/components/calendar/timePicker.js +89 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
- package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
- package/dist/apps/shared/components/layout/UserMenu.js +4 -4
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +6 -5
- package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
- package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
- package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
- package/dist/apps/shared/navigation/appHrefs.js +3 -3
- package/dist/apps/www/calendar/index.js +5 -2
- package/dist/apps/www/settings/profile/index.js +6 -5
- package/dist/lib/auth/session.d.ts +14 -1
- package/dist/lib/auth/session.js +40 -3
- package/dist/lib/components/pickers/TimeZonePicker.d.ts +20 -0
- package/dist/lib/components/pickers/TimeZonePicker.js +109 -0
- package/dist/lib/util/api.d.ts +6 -0
- package/dist/lib/util/api.js +22 -1
- package/dist/lib/util/timeZone.d.ts +19 -1
- package/dist/lib/util/timeZone.js +59 -2
- package/lib/auth/session.ts +48 -3
- package/lib/components/pickers/TimeZonePicker.tsx +185 -0
- package/lib/util/api.ts +24 -1
- package/lib/util/timeZone.ts +65 -2
- package/package.json +1 -1
|
@@ -187,6 +187,21 @@ export function formatStoredWhen(startDate: string, endDate: string, allDay: boo
|
|
|
187
187
|
return formatWhen(new Date(startDate), new Date(endDate), false, now);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** The date the form's start falls on, for reading a zone's offset as it will be for the event (daylight saving); now while there is no date. */
|
|
191
|
+
export function zoneDate(start: string): Date {
|
|
192
|
+
const day = new Date(`${start.slice(0, 10)}T12:00:00Z`);
|
|
193
|
+
return Number.isNaN(day.getTime()) ? new Date() : day;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `formatWhen()` for a stored timed event as `zone`'s clock reads it, whatever zone the viewer is in. */
|
|
197
|
+
export function formatStoredWhenInZone(startDate: string, endDate: string, zone: string, now: Date = new Date()): string {
|
|
198
|
+
const onZoneClock = (iso: string) => {
|
|
199
|
+
const wall = new Date(toEventWallClock(new Date(iso).getTime(), zone, false));
|
|
200
|
+
return new Date(wall.getUTCFullYear(), wall.getUTCMonth(), wall.getUTCDate(), wall.getUTCHours(), wall.getUTCMinutes());
|
|
201
|
+
};
|
|
202
|
+
return formatWhen(onZoneClock(startDate), onZoneClock(endDate), false, now);
|
|
203
|
+
}
|
|
204
|
+
|
|
190
205
|
/** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
|
|
191
206
|
* `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
|
|
192
207
|
* `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { format } from "date-fns";
|
|
6
|
+
|
|
7
|
+
/** The step between the times a time field offers. */
|
|
8
|
+
export const TIME_STEP_MINUTES = 15;
|
|
9
|
+
const MS_PER_MINUTE = 60_000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The start a new event gets when nothing was clicked: the next half hour (11:12 becomes 11:30; a time already on the half hour stays), with the
|
|
13
|
+
* event lasting an hour from there.
|
|
14
|
+
*/
|
|
15
|
+
export function nextHalfHour(now: Date): Date {
|
|
16
|
+
const slot = 30 * MS_PER_MINUTE;
|
|
17
|
+
return new Date(Math.ceil(now.getTime() / slot) * slot);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `HH:mm` (24 hour) as a 12 hour clock reading: "13:05" is "1:05 PM". */
|
|
21
|
+
export function formatTimeOfDay(time: string): string {
|
|
22
|
+
// A cleared date leaves no time to read: the field is empty, not an error.
|
|
23
|
+
if (!/^\d\d:\d\d$/.test(time)) {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
27
|
+
return format(new Date(2000, 0, 1, hours, minutes), "h:mm a");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* What was typed into a time field as `HH:mm`, or `null` when it isn't a time. Takes 24 hour and 12 hour readings, with or without the colon and
|
|
32
|
+
* the space: "15:45", "3:45 pm", "3:45p", "345pm", "1130" and a bare hour such as "9" (9:00) are all times.
|
|
33
|
+
*/
|
|
34
|
+
export function parseTimeInput(text: string): string | null {
|
|
35
|
+
const match = /^\s*(\d{1,2})(?::?(\d{2}))?\s*(?:([ap])\.?m?\.?)?\s*$/i.exec(text);
|
|
36
|
+
if (!match) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
let hours = Number(match[1]);
|
|
40
|
+
const minutes = match[2] === undefined ? 0 : Number(match[2]);
|
|
41
|
+
const meridiem = match[3]?.toLowerCase();
|
|
42
|
+
if (minutes > 59) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (meridiem) {
|
|
46
|
+
if (hours < 1 || hours > 12) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
hours = (hours % 12) + (meridiem === "p" ? 12 : 0);
|
|
50
|
+
} else if (hours > 23) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every time of a day, a quarter hour apart, as `HH:mm`. */
|
|
57
|
+
export function dayTimes(): string[] {
|
|
58
|
+
return Array.from({ length: (24 * 60) / TIME_STEP_MINUTES }, (_, i) => {
|
|
59
|
+
const minutes = i * TIME_STEP_MINUTES;
|
|
60
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A length of time as a menu says it: "15 mins", "1 hr", "1.5 hrs", "2 hrs". */
|
|
65
|
+
export function formatDuration(minutes: number): string {
|
|
66
|
+
if (minutes < 60) {
|
|
67
|
+
return `${minutes} min${minutes === 1 ? "" : "s"}`;
|
|
68
|
+
}
|
|
69
|
+
const hours = Math.round((minutes / 60) * 100) / 100;
|
|
70
|
+
return `${hours} ${hours === 1 ? "hr" : "hrs"}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `YYYY-MM-DDTHH:mm` read as a wall clock, in minutes since the epoch (no zone: the daylight saving changes of a zone are not this field's business). */
|
|
74
|
+
function wallMinutes(value: string): number {
|
|
75
|
+
return Date.parse(`${value.slice(0, 16)}:00Z`) / MS_PER_MINUTE;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function wallString(minutes: number): string {
|
|
79
|
+
return new Date(minutes * MS_PER_MINUTE).toISOString().slice(0, 16);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface EndTimeOption {
|
|
83
|
+
/** The end as `YYYY-MM-DDTHH:mm`. */
|
|
84
|
+
value: string;
|
|
85
|
+
/** The time of day, as a 12 hour clock reading. */
|
|
86
|
+
label: string;
|
|
87
|
+
/** How long the event would last, "(1.5 hrs)". */
|
|
88
|
+
hint: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The ends a time field offers for an event starting at `start` (`YYYY-MM-DDTHH:mm`): each quarter hour on the clock after the start, up to a
|
|
93
|
+
* day later, with the length of the event that end would make.
|
|
94
|
+
*/
|
|
95
|
+
export function endTimeOptions(start: string): EndTimeOption[] {
|
|
96
|
+
if (!/^\d{4}-\d\d-\d\dT\d\d:\d\d/.test(start)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const startMinutes = wallMinutes(start);
|
|
100
|
+
const options: EndTimeOption[] = [];
|
|
101
|
+
for (let at = Math.floor(startMinutes / TIME_STEP_MINUTES + 1) * TIME_STEP_MINUTES; at <= startMinutes + 24 * 60; at += TIME_STEP_MINUTES) {
|
|
102
|
+
const value = wallString(at);
|
|
103
|
+
options.push({ value, label: formatTimeOfDay(value.slice(11, 16)), hint: `(${formatDuration(at - startMinutes)})` });
|
|
104
|
+
}
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { useContext, useId, useRef } from "react";
|
|
6
|
-
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlineSun } from "react-icons/hi2";
|
|
6
|
+
import { HiOutlineComputerDesktop, HiOutlineMoon, HiOutlinePaintBrush, HiOutlineSun } from "react-icons/hi2";
|
|
7
7
|
import type { AppearanceMode } from "../../../../lib/appearance/preferencesApi.js";
|
|
8
8
|
import { AppearanceContext, INERT_APPEARANCE } from "../../appearance/appearanceContext.js";
|
|
9
9
|
|
|
@@ -49,7 +49,8 @@ export default function ThemeSwitch() {
|
|
|
49
49
|
|
|
50
50
|
return (
|
|
51
51
|
<div className="flex items-center justify-between gap-3 px-3.5 py-2 border-b border-border">
|
|
52
|
-
<span id={labelId} className="text-sm text-text">
|
|
52
|
+
<span id={labelId} className="flex items-center gap-2 text-sm text-text">
|
|
53
|
+
<HiOutlinePaintBrush size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
53
54
|
Theme
|
|
54
55
|
</span>
|
|
55
56
|
<div role="radiogroup" aria-labelledby={labelId} className="inline-flex rounded-sm border border-border bg-surface-alt p-0.5">
|
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
import React, { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
6
|
import { createPortal } from "react-dom";
|
|
7
7
|
import useIsMobile from "../../../../lib/util/useIsMobile.js";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
HiOutlineArrowRightOnRectangle,
|
|
10
|
+
HiOutlineArrowUturnLeft,
|
|
11
|
+
HiOutlineBell,
|
|
12
|
+
HiOutlineBellAlert,
|
|
13
|
+
HiOutlineClock,
|
|
14
|
+
HiOutlineCog6Tooth,
|
|
15
|
+
HiOutlineCommandLine,
|
|
16
|
+
HiOutlineShieldCheck,
|
|
17
|
+
HiOutlineUserCircle,
|
|
18
|
+
} from "react-icons/hi2";
|
|
9
19
|
import { formatProfileName, getMyProfile, getMyUsername, Profile, profileInitials } from "../../../../lib/auth/profileApi.js";
|
|
10
20
|
import { listMailboxes, Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
11
21
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
@@ -46,8 +56,7 @@ export interface UserMenuProps {
|
|
|
46
56
|
trustedRoles?: readonly string[];
|
|
47
57
|
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
48
58
|
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
49
|
-
* `/settings/
|
|
50
|
-
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
59
|
+
* `/settings/profile`, the first settings section; repoint this at a real `/settings` landing page once one is worth building. */
|
|
51
60
|
showSettingsLink?: boolean;
|
|
52
61
|
/** Shows the notification controls, above "Admin Console"/"Sign Out": a "Notifications" on/off switch for every pop-up and,
|
|
53
62
|
* while the browser hasn't been asked, "Turn on desktop notifications". Both are per browser (see `notifications/preferences.ts`
|
|
@@ -346,8 +355,9 @@ export default function UserMenu({
|
|
|
346
355
|
<a
|
|
347
356
|
role="menuitem"
|
|
348
357
|
href={accountUrl}
|
|
349
|
-
className="
|
|
358
|
+
className="flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
350
359
|
>
|
|
360
|
+
<HiOutlineUserCircle size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
351
361
|
Account
|
|
352
362
|
</a>
|
|
353
363
|
)}
|
|
@@ -355,8 +365,9 @@ export default function UserMenu({
|
|
|
355
365
|
<a
|
|
356
366
|
role="menuitem"
|
|
357
367
|
href={SETTINGS_HREF}
|
|
358
|
-
className="
|
|
368
|
+
className="flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
359
369
|
>
|
|
370
|
+
<HiOutlineCog6Tooth size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
360
371
|
Settings
|
|
361
372
|
</a>
|
|
362
373
|
)}
|
|
@@ -368,7 +379,10 @@ export default function UserMenu({
|
|
|
368
379
|
onClick={togglePopups}
|
|
369
380
|
className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
370
381
|
>
|
|
371
|
-
<span>
|
|
382
|
+
<span className="flex items-center gap-2">
|
|
383
|
+
<HiOutlineBell size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
384
|
+
Notifications
|
|
385
|
+
</span>
|
|
372
386
|
<span className="text-xs font-semibold text-text-muted">{popups ? "On" : "Off"}</span>
|
|
373
387
|
</button>
|
|
374
388
|
)}
|
|
@@ -377,8 +391,9 @@ export default function UserMenu({
|
|
|
377
391
|
role="menuitem"
|
|
378
392
|
type="button"
|
|
379
393
|
onClick={() => void enableDesktopNotifications()}
|
|
380
|
-
className="
|
|
394
|
+
className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
381
395
|
>
|
|
396
|
+
<HiOutlineBellAlert size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
382
397
|
Turn on desktop notifications
|
|
383
398
|
</button>
|
|
384
399
|
)}
|
|
@@ -392,7 +407,10 @@ export default function UserMenu({
|
|
|
392
407
|
}}
|
|
393
408
|
className="flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
394
409
|
>
|
|
395
|
-
<span
|
|
410
|
+
<span className="flex items-center gap-2">
|
|
411
|
+
<HiOutlineClock size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
412
|
+
Recent notifications
|
|
413
|
+
</span>
|
|
396
414
|
{unseenErrors > 0 && (
|
|
397
415
|
<span className="rounded-pill bg-danger px-1.5 text-xs font-bold text-white">
|
|
398
416
|
<span aria-hidden="true">{unseenErrors}</span>
|
|
@@ -410,8 +428,9 @@ export default function UserMenu({
|
|
|
410
428
|
setOpen(false);
|
|
411
429
|
onShowShortcuts();
|
|
412
430
|
}}
|
|
413
|
-
className="
|
|
431
|
+
className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt"
|
|
414
432
|
>
|
|
433
|
+
<HiOutlineCommandLine size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
415
434
|
Keyboard shortcuts
|
|
416
435
|
</button>
|
|
417
436
|
)}
|
|
@@ -429,8 +448,9 @@ export default function UserMenu({
|
|
|
429
448
|
role="menuitem"
|
|
430
449
|
type="button"
|
|
431
450
|
onClick={onSignOut}
|
|
432
|
-
className="
|
|
451
|
+
className="flex w-full items-center gap-2 text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
433
452
|
>
|
|
453
|
+
<HiOutlineArrowRightOnRectangle size={16} aria-hidden="true" className="shrink-0 text-text-muted" />
|
|
434
454
|
Sign Out
|
|
435
455
|
</button>
|
|
436
456
|
</div>,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import type { Attachment } from "../../../../../lib/mail/mailApi.js";
|
|
6
6
|
import type { InviteResponse, MessageInvite } from "../../../../../lib/calendar/inviteApi.js";
|
|
7
|
+
import { describeTimeZone, deviceTimeZone } from "../../../../../lib/util/timeZone.js";
|
|
7
8
|
import { APP_HREFS } from "../../../navigation/appHrefs.js";
|
|
8
9
|
|
|
9
10
|
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
@@ -53,15 +54,15 @@ export function formatInviteWhen(invite: Pick<MessageInvite, "startDate" | "endD
|
|
|
53
54
|
}
|
|
54
55
|
const date = new Intl.DateTimeFormat(undefined, DAY_FORMAT);
|
|
55
56
|
const time = new Intl.DateTimeFormat(undefined, CLOCK_FORMAT);
|
|
56
|
-
// The zone is named once, after the last time shown: the times are the reader's own, not the organizer's.
|
|
57
|
-
const
|
|
57
|
+
// The zone is named once, in full, after the last time shown: the times are the reader's own, not the organizer's.
|
|
58
|
+
const zone = describeTimeZone(deviceTimeZone(), end ?? start);
|
|
58
59
|
const startDay = date.format(start);
|
|
59
60
|
if (!end) {
|
|
60
|
-
return `${startDay}, ${
|
|
61
|
+
return `${startDay}, ${time.format(start)} • ${zone}`;
|
|
61
62
|
}
|
|
62
63
|
return startDay === date.format(end)
|
|
63
|
-
? `${startDay}, ${time.format(start)} - ${
|
|
64
|
-
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${
|
|
64
|
+
? `${startDay}, ${time.format(start)} - ${time.format(end)} • ${zone}`
|
|
65
|
+
: `${startDay}, ${time.format(start)} - ${date.format(end)}, ${time.format(end)} • ${zone}`;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "../../../mail/folderCounts.js";
|
|
31
31
|
import { ALL_MAILBOXES_SECTION, useSidebarSections } from "../../../mail/useCollapsedSections.js";
|
|
32
32
|
import FolderBadgeChip from "./FolderBadgeChip.js";
|
|
33
|
+
import { FolderTypeIcon, SharedMailboxMark } from "./folderIcons.js";
|
|
33
34
|
import SidebarSection from "./SidebarSection.js";
|
|
34
35
|
import { useUnreadTitle } from "../../../mail/useUnreadTitle.js";
|
|
35
36
|
import { pendingCountFor, usePendingSends } from "../../../mail/outbox/pendingSends.js";
|
|
@@ -391,7 +392,10 @@ export default function MailShell({
|
|
|
391
392
|
: "text-text hover:bg-surface-alt",
|
|
392
393
|
].join(" ")}
|
|
393
394
|
>
|
|
394
|
-
<span className={badge?.kind === "unread" ? "font-semibold" :
|
|
395
|
+
<span className={["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" ")}>
|
|
396
|
+
<FolderTypeIcon type={type} />
|
|
397
|
+
<span className="truncate">{FOLDER_LABELS[type]}</span>
|
|
398
|
+
</span>
|
|
395
399
|
{badge && <FolderBadgeChip badge={badge} />}
|
|
396
400
|
</a>
|
|
397
401
|
);
|
|
@@ -405,7 +409,7 @@ export default function MailShell({
|
|
|
405
409
|
label={
|
|
406
410
|
<>
|
|
407
411
|
{mailbox.displayName}
|
|
408
|
-
{mailbox.ownerUserUid
|
|
412
|
+
{!mailbox.ownerUserUid && <SharedMailboxMark />}
|
|
409
413
|
</>
|
|
410
414
|
}
|
|
411
415
|
collapsible={collapsible}
|
|
@@ -431,7 +435,10 @@ export default function MailShell({
|
|
|
431
435
|
data-folder-placeholder={row.type}
|
|
432
436
|
className="flex items-center justify-between text-sm rounded-sm py-1.5 px-2.5 text-text-muted"
|
|
433
437
|
>
|
|
434
|
-
<span
|
|
438
|
+
<span className="flex items-center gap-2.5 min-w-0">
|
|
439
|
+
<FolderTypeIcon type={row.type} />
|
|
440
|
+
<span className="truncate">{FOLDER_LABELS[row.type]}</span>
|
|
441
|
+
</span>
|
|
435
442
|
{row.type === "outbox" && <OutboxBadge total={sending} pendingHere={sending} />}
|
|
436
443
|
</div>
|
|
437
444
|
);
|
|
@@ -451,8 +458,9 @@ export default function MailShell({
|
|
|
451
458
|
: "text-text hover:bg-surface-alt",
|
|
452
459
|
].join(" ")}
|
|
453
460
|
>
|
|
454
|
-
<span className={badge?.kind === "unread" ? "font-semibold" :
|
|
455
|
-
{
|
|
461
|
+
<span className={["flex items-center gap-2.5 min-w-0", badge?.kind === "unread" ? "font-semibold" : ""].join(" ")}>
|
|
462
|
+
<FolderTypeIcon type={folder.type} />
|
|
463
|
+
<span className="truncate">{FOLDER_LABELS[folder.type] ?? folder.name}</span>
|
|
456
464
|
</span>
|
|
457
465
|
{badge &&
|
|
458
466
|
(folder.type === "outbox" ? (
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import type { IconType } from "react-icons";
|
|
7
|
+
import {
|
|
8
|
+
HiOutlineArchiveBox,
|
|
9
|
+
HiOutlineArrowUpTray,
|
|
10
|
+
HiOutlineBookOpen,
|
|
11
|
+
HiOutlineCalendarDays,
|
|
12
|
+
HiOutlineClipboardDocumentList,
|
|
13
|
+
HiOutlineFolder,
|
|
14
|
+
HiOutlineInbox,
|
|
15
|
+
HiOutlinePaperAirplane,
|
|
16
|
+
HiOutlinePencilSquare,
|
|
17
|
+
HiOutlineShieldExclamation,
|
|
18
|
+
HiOutlineTrash,
|
|
19
|
+
HiOutlineUsers,
|
|
20
|
+
HiUserGroup,
|
|
21
|
+
} from "react-icons/hi2";
|
|
22
|
+
import type { FolderType } from "../../../../../lib/mail/mailApi.js";
|
|
23
|
+
|
|
24
|
+
const FOLDER_ICONS: Record<FolderType, IconType> = {
|
|
25
|
+
inbox: HiOutlineInbox,
|
|
26
|
+
drafts: HiOutlinePencilSquare,
|
|
27
|
+
sent_items: HiOutlinePaperAirplane,
|
|
28
|
+
outbox: HiOutlineArrowUpTray,
|
|
29
|
+
junk: HiOutlineShieldExclamation,
|
|
30
|
+
archive: HiOutlineArchiveBox,
|
|
31
|
+
deleted_items: HiOutlineTrash,
|
|
32
|
+
calendar: HiOutlineCalendarDays,
|
|
33
|
+
contacts: HiOutlineUsers,
|
|
34
|
+
tasks: HiOutlineClipboardDocumentList,
|
|
35
|
+
notes: HiOutlineBookOpen,
|
|
36
|
+
user: HiOutlineFolder,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The icon drawn to the left of a folder's name: one per well-known folder, a plain folder for the user's own. */
|
|
40
|
+
export function FolderTypeIcon({ type }: { type: FolderType | string }) {
|
|
41
|
+
const Icon = FOLDER_ICONS[type as FolderType] ?? HiOutlineFolder;
|
|
42
|
+
return <Icon size={18} aria-hidden="true" className="shrink-0 text-text-muted" />;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What marks a mailbox other people share with this user, beside its name: the group icon, and for a screen reader the word it stands for. */
|
|
46
|
+
export function SharedMailboxMark() {
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<HiUserGroup size={14} aria-hidden="true" className="inline-block ml-1.5 align-[-2px] shrink-0 text-text-muted" />
|
|
50
|
+
{" "}
|
|
51
|
+
<span className="sr-only">(shared)</span>
|
|
52
|
+
</>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -3,7 +3,22 @@
|
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
6
|
-
import {
|
|
6
|
+
import type { IconType } from "react-icons";
|
|
7
|
+
import {
|
|
8
|
+
HiOutlineArrowUturnLeft,
|
|
9
|
+
HiOutlineBars3,
|
|
10
|
+
HiOutlineEnvelopeOpen,
|
|
11
|
+
HiOutlineFunnel,
|
|
12
|
+
HiOutlineLockClosed,
|
|
13
|
+
HiOutlineNoSymbol,
|
|
14
|
+
HiOutlinePaintBrush,
|
|
15
|
+
HiOutlinePencilSquare,
|
|
16
|
+
HiOutlinePuzzlePiece,
|
|
17
|
+
HiOutlineShieldCheck,
|
|
18
|
+
HiOutlineTag,
|
|
19
|
+
HiOutlineUserCircle,
|
|
20
|
+
HiOutlineUsers,
|
|
21
|
+
} from "react-icons/hi2";
|
|
7
22
|
import { ApiRequestError } from "../../../../../lib/util/api.js";
|
|
8
23
|
import Drawer from "../../../../../lib/components/overlays/Drawer.js";
|
|
9
24
|
import { Mailbox, listMailboxes } from "../../../../../lib/mail/mailApi.js";
|
|
@@ -20,21 +35,23 @@ export interface SettingsSectionDef {
|
|
|
20
35
|
id: string;
|
|
21
36
|
href: string;
|
|
22
37
|
label: string;
|
|
38
|
+
/** Drawn beside the label in the sidebar. */
|
|
39
|
+
icon: IconType;
|
|
23
40
|
}
|
|
24
41
|
|
|
25
42
|
/** Every settings section with its own sidebar entry. */
|
|
26
43
|
export const SETTINGS_SECTIONS: SettingsSectionDef[] = [
|
|
27
|
-
{ id: "profile", href: "/settings/profile", label: "Profile" },
|
|
28
|
-
{ id: "appearance", href: "/settings/appearance", label: "Appearance" },
|
|
29
|
-
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
|
|
30
|
-
{ id: "filters", href: "/settings/filters", label: "Mail Filters" },
|
|
31
|
-
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
|
|
32
|
-
{ id: "signatures", href: "/settings/signatures", label: "Signatures" },
|
|
33
|
-
{ id: "labels", href: "/settings/labels", label: "Labels" },
|
|
34
|
-
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
|
|
35
|
-
{ id: "encryption", href: "/settings/encryption", label: "Encryption" },
|
|
36
|
-
{ id: "sharing", href: "/settings/sharing", label: "Sharing" },
|
|
37
|
-
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
|
|
44
|
+
{ id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
|
|
45
|
+
{ id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
|
|
46
|
+
{ id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
|
|
47
|
+
{ id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
|
|
48
|
+
{ id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
|
|
49
|
+
{ id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
|
|
50
|
+
{ id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
|
|
51
|
+
{ id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
|
|
52
|
+
{ id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
|
|
53
|
+
{ id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
|
|
54
|
+
{ id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
|
|
38
55
|
];
|
|
39
56
|
|
|
40
57
|
/** A `SETTINGS_SECTIONS` id, or a plugin's `settingsSections` item id (see `PluginNav`). */
|
|
@@ -47,6 +64,7 @@ export function settingsSections(pluginNav?: PluginNav): SettingsSectionDef[] {
|
|
|
47
64
|
id,
|
|
48
65
|
href,
|
|
49
66
|
label,
|
|
67
|
+
icon: HiOutlinePuzzlePiece,
|
|
50
68
|
}));
|
|
51
69
|
}
|
|
52
70
|
|
|
@@ -207,10 +225,11 @@ export default function SettingsShell({
|
|
|
207
225
|
href={`${section.href}?mailboxUid=${encodeURIComponent(mailboxUid!)}`}
|
|
208
226
|
aria-current={section.id === active ? "page" : undefined}
|
|
209
227
|
className={[
|
|
210
|
-
"
|
|
228
|
+
"flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
|
|
211
229
|
section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
|
|
212
230
|
].join(" ")}
|
|
213
231
|
>
|
|
232
|
+
<section.icon size={18} aria-hidden="true" className="shrink-0" />
|
|
214
233
|
{section.label}
|
|
215
234
|
</a>
|
|
216
235
|
))}
|
|
@@ -12,10 +12,10 @@ export const APP_HREFS = {
|
|
|
12
12
|
} as const;
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Settings is reached from the account menu, not the rail. It links straight to the
|
|
16
|
-
* exists - repoint this at a real `/settings` landing page once there is one.
|
|
15
|
+
* Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
|
|
16
|
+
* always exists - repoint this at a real `/settings` landing page once there is one.
|
|
17
17
|
*/
|
|
18
|
-
export const SETTINGS_HREF = "/settings/
|
|
18
|
+
export const SETTINGS_HREF = "/settings/profile";
|
|
19
19
|
|
|
20
20
|
/** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
|
|
21
21
|
export function isSettingsPath(pathname: string): boolean {
|
|
@@ -43,6 +43,7 @@ import TimeGridView from "../../shared/components/calendar/TimeGridView.js";
|
|
|
43
43
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
44
44
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
45
45
|
import { SWIPE_PERIOD_SHIFT } from "../../shared/components/calendar/swipeNavigation.js";
|
|
46
|
+
import { nextHalfHour } from "../../shared/components/calendar/timePicker.js";
|
|
46
47
|
import { useSwipeSlide } from "../../shared/gestures/useSwipeSlide.js";
|
|
47
48
|
import { useEnterSlide } from "../../shared/gestures/useEnterSlide.js";
|
|
48
49
|
import { useWheelPaging, type WheelEdge } from "../../shared/gestures/useWheelPaging.js";
|
|
@@ -357,10 +358,12 @@ function CalendarContent({ userUid, bookingHref }: { userUid?: string; bookingHr
|
|
|
357
358
|
if (!mailboxUid || !folderUid) {
|
|
358
359
|
return;
|
|
359
360
|
}
|
|
361
|
+
// With nothing clicked, the next half hour, for an hour.
|
|
362
|
+
const from = start ?? nextHalfHour(new Date());
|
|
360
363
|
setModal({
|
|
361
364
|
occurrence: null,
|
|
362
|
-
initialStart:
|
|
363
|
-
initialEnd: end ?? new Date(
|
|
365
|
+
initialStart: from,
|
|
366
|
+
initialEnd: end ?? new Date(from.getTime() + 60 * 60_000),
|
|
364
367
|
targetFolderUid,
|
|
365
368
|
anchor,
|
|
366
369
|
initialAllDay,
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { pageTitle } from "../../../shared/navigation/pageTitle.js";
|
|
6
6
|
import React, { FormEvent, useMemo, useState } from "react";
|
|
7
7
|
import { FreeBusyVisibility, freeBusyVisibilityOf, isSharedWithMe, updateMailbox } from "../../../../lib/mail/mailApi.js";
|
|
8
|
-
import
|
|
8
|
+
import TimeZonePicker from "../../../../lib/components/pickers/TimeZonePicker.js";
|
|
9
|
+
import { DEFAULT_TIME_ZONE, describeTimeZone, deviceTimeZone, timeZoneOptions } from "../../../../lib/util/timeZone.js";
|
|
9
10
|
import SettingsShell, { SettingsShellProps, useSettingsShell } from "../../../shared/components/settings/layout/SettingsShell.js";
|
|
10
11
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
11
12
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
@@ -169,22 +170,17 @@ function ProfileContent() {
|
|
|
169
170
|
|
|
170
171
|
<FormField label="Time zone" htmlFor="profile-time-zone">
|
|
171
172
|
<div className="flex flex-wrap items-center gap-2">
|
|
172
|
-
<
|
|
173
|
+
<TimeZonePicker
|
|
173
174
|
id="profile-time-zone"
|
|
174
175
|
className={SELECT_CLASS}
|
|
175
176
|
value={timezone}
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
zones={zones}
|
|
178
|
+
onChange={(zone) => {
|
|
179
|
+
setTimezone(zone);
|
|
178
180
|
setSaved(false);
|
|
179
181
|
}}
|
|
180
182
|
aria-describedby="profile-time-zone-help"
|
|
181
|
-
|
|
182
|
-
{zones.map((zone) => (
|
|
183
|
-
<option key={zone} value={zone}>
|
|
184
|
-
{zone}
|
|
185
|
-
</option>
|
|
186
|
-
))}
|
|
187
|
-
</select>
|
|
183
|
+
/>
|
|
188
184
|
{timezone !== deviceZone && (
|
|
189
185
|
<Button
|
|
190
186
|
type="button"
|
|
@@ -195,7 +191,7 @@ function ProfileContent() {
|
|
|
195
191
|
setSaved(false);
|
|
196
192
|
}}
|
|
197
193
|
>
|
|
198
|
-
Use this device’s time zone ({deviceZone})
|
|
194
|
+
Use this device’s time zone ({describeTimeZone(deviceZone)})
|
|
199
195
|
</Button>
|
|
200
196
|
)}
|
|
201
197
|
</div>
|
|
@@ -13,6 +13,7 @@ import ResourceSettingsCard from "../../shared/components/admin/mailboxes/Resour
|
|
|
13
13
|
import EscrowScopeCard from "../../shared/components/admin/mailboxes/EscrowScopeCard.js";
|
|
14
14
|
import Alert from "../../../lib/components/feedback/Alert.js";
|
|
15
15
|
import Button from "../../../lib/components/buttons/Button.js";
|
|
16
|
+
import { describeTimeZone } from "../../../lib/util/timeZone.js";
|
|
16
17
|
import Modal from "../../../lib/components/overlays/Modal.js";
|
|
17
18
|
function formatBytes(bytes) {
|
|
18
19
|
if (bytes >= 1000000000)
|
|
@@ -107,7 +108,7 @@ function MailboxDetailContent({ uid, impersonationBaseUrl, currentUserUid, }) {
|
|
|
107
108
|
const ingestQueueHref = `/admin/ingest-queue?mailboxUid=${encodeURIComponent(mailbox.uid)}`;
|
|
108
109
|
return (_jsxs("div", { className: "max-w-3xl flex flex-col gap-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { children: [_jsx("a", { href: "/admin", className: "text-sm text-primary-dark hover:underline", children: "\u2190 All mailboxes" }), _jsx("h1", { className: "text-xl font-bold tracking-tight mt-1", children: mailbox.primarySmtpAddress })] }), _jsxs("div", { className: "flex gap-3 shrink-0", children: [mailbox.ownerUserUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setConfirmingAccess(true), children: "Impersonate this user" })), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !border-danger !text-danger hover:!border-danger hover:!text-danger", onClick: () => setConfirmingDelete(true), children: "Delete mailbox" })] })] }), _jsxs("p", { className: "text-sm text-text-muted", children: ["The console shows administrative details only - never a mailbox\u2019s mail or settings.", mailbox.ownerUserUid
|
|
109
110
|
? " To see this mailbox as its owner sees it, impersonate them: that is recorded, and you can stop at any time."
|
|
110
|
-
: " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: mailbox.timezone }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
|
|
111
|
+
: " This shared mailbox has no owner: add yourself under Shared access to open it in the mail client."] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6", children: [_jsxs("dl", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm", children: [_jsx("dt", { className: "text-text-muted", children: "Display name" }), _jsx("dd", { children: mailbox.displayName }), _jsx("dt", { className: "text-text-muted", children: "Owner" }), _jsx("dd", { children: mailbox.ownerUserUid ?? "None (shared mailbox)" }), _jsx("dt", { className: "text-text-muted", children: "Timezone" }), _jsx("dd", { children: describeTimeZone(mailbox.timezone) }), _jsx("dt", { className: "text-text-muted", children: "Quota" }), _jsxs("dd", { children: [formatBytes(mailbox.usedBytes), " / ", formatBytes(mailbox.quotaBytes)] }), _jsx("dt", { className: "text-text-muted", children: "Alias addresses" }), _jsx("dd", { children: mailbox.aliasAddresses.length > 0 ? mailbox.aliasAddresses.join(", ") : "None" }), mailbox.isResource && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Resource type" }), _jsx("dd", { className: "capitalize", children: mailbox.resourceType ?? "room" })] })), _jsx("dt", { className: "text-text-muted", children: "Created" }), _jsx("dd", { children: new Date(mailbox.dateCreated).toLocaleString() })] }), _jsxs("div", { className: "flex gap-4 mt-5 pt-5 border-t border-border text-sm font-medium", children: [_jsx("a", { href: quarantineHref, className: "text-primary-dark hover:underline", children: "View quarantine" }), _jsx("a", { href: ingestQueueHref, className: "text-primary-dark hover:underline", children: "View ingest queue" })] })] }), _jsx(ShareAccessCard, { mailboxUid: mailbox.uid, ownerUserUid: mailbox.ownerUserUid, currentUserUid: currentUserUid }), _jsx(EscrowScopeCard, { mailbox: mailbox, onUpdate: setMailbox }), mailbox.isResource && _jsx(ResourceSettingsCard, { mailbox: mailbox, onUpdate: setMailbox }), _jsxs(Modal, { open: confirmingAccess, onClose: closeAccessModal, title: "Impersonate this user", children: [_jsxs("p", { className: "text-sm mb-3", children: ["You\u2019ll be signed in as ", _jsx("strong", { className: "break-all", children: mailbox.ownerUserUid }), ", the owner of ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), ", and see everything they can - and act as them, until you stop impersonating. This is the only way an administrator sees another user\u2019s mail."] }), accessError && _jsx(Alert, { children: accessError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: impersonating, onClick: closeAccessModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: impersonating, disabled: impersonating, onClick: handleAccessMailbox, children: "Impersonate" })] })] }), _jsxs(Modal, { open: confirmingDelete, onClose: closeDeleteModal, title: "Delete mailbox", children: [_jsxs("p", { className: "text-sm mb-3", children: ["Are you sure you want to delete ", _jsx("strong", { className: "break-all", children: mailbox.primarySmtpAddress }), "? This cannot be undone. It fails if this mailbox is a custodian on an active legal hold."] }), _jsxs("p", { className: "text-sm mb-3", children: ["Deleting removes the mailbox itself. Its mail, contacts, calendars and everything else in it are", " ", _jsx("strong", { children: "kept" }), " until they are erased, and until then the address can\u2019t be used for a new mailbox. You can erase them later from the Mailboxes page."] }), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-3", children: [_jsx("input", { type: "checkbox", className: "mt-0.5", checked: eraseData, disabled: deleting, onChange: (e) => {
|
|
111
112
|
setEraseData(e.target.checked);
|
|
112
113
|
setTypedAddress("");
|
|
113
114
|
} }), _jsx("span", { children: "Also erase all of its data now (permanent)" })] }), eraseData && (_jsxs("div", { className: "mb-3", children: [_jsx("p", { className: "text-sm font-semibold text-danger mb-2", children: "Everything in this mailbox will be permanently erased, and this cannot be undone." }), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsxs("span", { className: "font-semibold", children: ["Type ", _jsx("span", { className: "break-all", children: mailbox.primarySmtpAddress }), " to confirm"] }), _jsx("input", { "aria-label": "Type the address to confirm", className: "w-full text-sm py-2.5 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary", value: typedAddress, disabled: deleting, autoComplete: "off", spellCheck: false, onChange: (e) => setTypedAddress(e.target.value) })] })] })), deleteError && _jsx(Alert, { children: deleteError }), _jsxs("div", { className: "flex gap-3 justify-end mt-5", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: deleting, onClick: closeDeleteModal, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto !bg-none !bg-danger !border-danger hover:!bg-danger", loading: deleting, disabled: deleting || (eraseData && typedAddress.trim().toLowerCase() !== mailbox.primarySmtpAddress.trim().toLowerCase()), onClick: handleDelete, children: eraseData ? "Delete and erase all its data" : "Delete" })] })] })] }));
|