@rapidmx/web-client 0.2.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/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { DistributionList, updateDistributionList } from "@rapidmx/react-shared/admin/distributionListsApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
|
|
11
|
+
export interface MemberListCardProps {
|
|
12
|
+
list: DistributionList;
|
|
13
|
+
/** Called with the freshly-saved list (new `version`, updated `memberAddresses`) after a successful
|
|
14
|
+
* add/remove, so the parent detail page can keep its own copy of the list in sync — mirrors
|
|
15
|
+
* `ShareAccessCard`'s `reload()` pattern, just pushed up rather than re-fetched, since the caller
|
|
16
|
+
* already has everything the response would return. */
|
|
17
|
+
onUpdate: (list: DistributionList) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lets an admin view and edit a distribution list's members — same load/inline-add-row/per-row-remove
|
|
22
|
+
* interaction shape as `ShareAccessCard`, but against a plain `memberAddresses: string[]` field on the
|
|
23
|
+
* `DistributionList` itself via read-modify-write `PUT`, not a separate ACL endpoint (a distribution
|
|
24
|
+
* list has no per-record ACL to speak of — see `BaseDistributionListRoute`'s own doc comment).
|
|
25
|
+
*/
|
|
26
|
+
export default function MemberListCard({ list, onUpdate }: MemberListCardProps) {
|
|
27
|
+
const [newAddress, setNewAddress] = useState("");
|
|
28
|
+
const [saving, setSaving] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
async function handleAdd(e: FormEvent) {
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
const address = newAddress.trim();
|
|
34
|
+
if (!address || list.memberAddresses.includes(address)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setSaving(true);
|
|
38
|
+
setError(null);
|
|
39
|
+
try {
|
|
40
|
+
const updated = await updateDistributionList({
|
|
41
|
+
uid: list.uid,
|
|
42
|
+
version: list.version,
|
|
43
|
+
memberAddresses: [...list.memberAddresses, address],
|
|
44
|
+
});
|
|
45
|
+
setNewAddress("");
|
|
46
|
+
onUpdate(updated);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not add this member.");
|
|
49
|
+
} finally {
|
|
50
|
+
setSaving(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function handleRemove(address: string) {
|
|
55
|
+
setSaving(true);
|
|
56
|
+
setError(null);
|
|
57
|
+
try {
|
|
58
|
+
const updated = await updateDistributionList({
|
|
59
|
+
uid: list.uid,
|
|
60
|
+
version: list.version,
|
|
61
|
+
memberAddresses: list.memberAddresses.filter((a) => a !== address),
|
|
62
|
+
});
|
|
63
|
+
onUpdate(updated);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not remove this member.");
|
|
66
|
+
} finally {
|
|
67
|
+
setSaving(false);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
73
|
+
<h2 className="text-base font-bold uppercase tracking-wide mb-1">Members</h2>
|
|
74
|
+
<p className="text-sm text-text-muted mb-4">
|
|
75
|
+
Each address may resolve to an internal mailbox, a nested distribution list, or a genuinely
|
|
76
|
+
external address relayed out.
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
{error && <Alert>{error}</Alert>}
|
|
80
|
+
|
|
81
|
+
<ul className="flex flex-col gap-2 mb-4">
|
|
82
|
+
{list.memberAddresses.length === 0 && (
|
|
83
|
+
<li className="text-sm text-text-muted">No members yet.</li>
|
|
84
|
+
)}
|
|
85
|
+
{list.memberAddresses.map((address) => (
|
|
86
|
+
<li
|
|
87
|
+
key={address}
|
|
88
|
+
className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm"
|
|
89
|
+
>
|
|
90
|
+
<div className="text-sm font-medium">{address}</div>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => handleRemove(address)}
|
|
94
|
+
disabled={saving}
|
|
95
|
+
className="text-sm text-danger hover:underline disabled:opacity-55"
|
|
96
|
+
>
|
|
97
|
+
Remove
|
|
98
|
+
</button>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
|
|
103
|
+
<form onSubmit={handleAdd} className="flex gap-2">
|
|
104
|
+
<input
|
|
105
|
+
type="text"
|
|
106
|
+
value={newAddress}
|
|
107
|
+
onChange={(e) => setNewAddress(e.target.value)}
|
|
108
|
+
placeholder="Member address to add"
|
|
109
|
+
className="flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
|
|
110
|
+
/>
|
|
111
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
112
|
+
Add
|
|
113
|
+
</Button>
|
|
114
|
+
</form>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import "../../../styles/app.css";
|
|
6
|
+
import React, { PropsWithChildren, ReactNode, useEffect, useState } from "react";
|
|
7
|
+
import {
|
|
8
|
+
HiOutlineClipboardDocumentList,
|
|
9
|
+
HiOutlineGlobeAlt,
|
|
10
|
+
HiOutlineInboxStack,
|
|
11
|
+
HiOutlinePaintBrush,
|
|
12
|
+
HiOutlineQueueList,
|
|
13
|
+
HiOutlineShieldCheck,
|
|
14
|
+
HiOutlineShieldExclamation,
|
|
15
|
+
HiOutlineUserGroup,
|
|
16
|
+
} from "react-icons/hi2";
|
|
17
|
+
import { apiFetch, ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
18
|
+
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
19
|
+
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
20
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
21
|
+
import BottomTabBar, { NavItem } from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
22
|
+
import { BrandingFooter, BrandingHeader } from "../../layout/BrandingChrome.js";
|
|
23
|
+
import UserMenu from "../../layout/UserMenu.js";
|
|
24
|
+
|
|
25
|
+
export type AdminSection =
|
|
26
|
+
| "mailboxes"
|
|
27
|
+
| "quarantine"
|
|
28
|
+
| "ingestQueue"
|
|
29
|
+
| "domains"
|
|
30
|
+
| "auditLog"
|
|
31
|
+
| "distributionLists"
|
|
32
|
+
| "transportRules"
|
|
33
|
+
| "branding";
|
|
34
|
+
|
|
35
|
+
export interface AdminShellProps {
|
|
36
|
+
/** Which icon in the rail is highlighted as the current section. */
|
|
37
|
+
active: AdminSection;
|
|
38
|
+
/** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
|
|
39
|
+
userUid?: string;
|
|
40
|
+
/** auth-server's base URL, injected via the route's `fetchProps` — see `src/mongo/routes/AdminConsoleRoute.ts`. */
|
|
41
|
+
authServerUrl?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Where `mailApi.ts`'s `impersonateUser()`/`stopImpersonating()` should call: the real auth-server in
|
|
44
|
+
* production, or `""` under `yarn dev` to call this app's own local dev-only impersonation endpoint
|
|
45
|
+
* instead — see `AdminConsoleRoute`'s `fetchProps` and `src/dev/DevImpersonationRoute.ts`.
|
|
46
|
+
*/
|
|
47
|
+
impersonationBaseUrl?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Status = "checking" | "denied" | "error" | "authorized";
|
|
51
|
+
|
|
52
|
+
/** Sections shown in the persistent icon rail / mobile tab bar — every admin area reachable from
|
|
53
|
+
* anywhere in the console. Deliberately excludes `quarantine`/`ingestQueue`: those are scoped to a
|
|
54
|
+
* single mailbox and only ever reached via links on that mailbox's own detail page (see
|
|
55
|
+
* `apps/admin/mailboxes/[uid].tsx`), not global navigation destinations. */
|
|
56
|
+
const NAV_ITEMS: NavItem[] = [
|
|
57
|
+
{ id: "mailboxes", href: "/admin", label: "Mailboxes", icon: HiOutlineInboxStack },
|
|
58
|
+
{ id: "domains", href: "/admin/domains", label: "Domains", icon: HiOutlineGlobeAlt },
|
|
59
|
+
{ id: "auditLog", href: "/admin/audit-log", label: "Audit Log", icon: HiOutlineClipboardDocumentList },
|
|
60
|
+
{
|
|
61
|
+
id: "distributionLists",
|
|
62
|
+
href: "/admin/distribution-lists",
|
|
63
|
+
label: "Distribution Lists",
|
|
64
|
+
icon: HiOutlineUserGroup,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: "transportRules",
|
|
68
|
+
href: "/admin/transport-rules",
|
|
69
|
+
label: "Transport Rules",
|
|
70
|
+
icon: HiOutlineShieldCheck,
|
|
71
|
+
},
|
|
72
|
+
{ id: "branding", href: "/admin/branding", label: "Branding", icon: HiOutlinePaintBrush },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Mailbox-scoped sections — not part of the global nav rail/tab bar (see `NAV_ITEMS` above), but
|
|
76
|
+
* still need an entry here so the header can resolve a label for them when they're the active page. */
|
|
77
|
+
const MAILBOX_SCOPED_ITEMS: NavItem[] = [
|
|
78
|
+
{ id: "quarantine", href: "/admin/quarantine", label: "Quarantine", icon: HiOutlineShieldExclamation },
|
|
79
|
+
{ id: "ingestQueue", href: "/admin/ingest-queue", label: "Ingest Queue", icon: HiOutlineQueueList },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const ALL_ITEMS: NavItem[] = [...NAV_ITEMS, ...MAILBOX_SCOPED_ITEMS];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Gates every `apps/admin` page behind the `admin` trusted role. Uses `GET /api/admin/release-notes` (any
|
|
86
|
+
* `BaseAdminRoute` endpoint works — this one is side-effect-free) purely as a canary: a 200 means the
|
|
87
|
+
* caller's JWT carries a trusted role, a 403 means it doesn't. There is no local step-up/elevation flow
|
|
88
|
+
* (that would need a cross-origin call to auth-server's own elevation endpoint — not wired up yet).
|
|
89
|
+
*/
|
|
90
|
+
export default function AdminShell({ active, userUid, authServerUrl, children }: PropsWithChildren<AdminShellProps>) {
|
|
91
|
+
const [status, setStatus] = useState<Status>("checking");
|
|
92
|
+
const [error, setError] = useState<string | null>(null);
|
|
93
|
+
const { branding, iconSrc } = useBranding();
|
|
94
|
+
|
|
95
|
+
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!userUid) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
apiFetch("/admin/release-notes")
|
|
102
|
+
.then(() => setStatus("authorized"))
|
|
103
|
+
.catch((err) => {
|
|
104
|
+
if (err instanceof ApiRequestError && (err.status === 403 || err.status === 401)) {
|
|
105
|
+
setStatus("denied");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not verify administrator access.");
|
|
109
|
+
setStatus("error");
|
|
110
|
+
});
|
|
111
|
+
}, [userUid]);
|
|
112
|
+
|
|
113
|
+
function handleSignOut() {
|
|
114
|
+
window.location.href = authServerUrl ?? "/";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let content: ReactNode;
|
|
118
|
+
if (!userUid || status === "checking") {
|
|
119
|
+
content = <div className="min-h-screen" />;
|
|
120
|
+
} else if (status === "denied") {
|
|
121
|
+
content = (
|
|
122
|
+
<div className="min-h-screen flex items-center justify-center p-8">
|
|
123
|
+
<div className="w-full max-w-md">
|
|
124
|
+
<Alert>You do not have administrator access.</Alert>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
} else if (status === "error") {
|
|
129
|
+
content = (
|
|
130
|
+
<div className="min-h-screen flex items-center justify-center p-8">
|
|
131
|
+
<div className="w-full max-w-md">
|
|
132
|
+
<Alert>{error}</Alert>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
const activeItem = ALL_ITEMS.find((item) => item.id === active);
|
|
138
|
+
content = (
|
|
139
|
+
<div className="min-h-screen flex flex-col bg-surface-alt">
|
|
140
|
+
<div className="flex-1 flex min-h-0">
|
|
141
|
+
<nav
|
|
142
|
+
aria-label="Admin sections"
|
|
143
|
+
className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
|
|
144
|
+
>
|
|
145
|
+
<img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
|
|
146
|
+
{NAV_ITEMS.map(({ id, href, label, icon: Icon }) => (
|
|
147
|
+
<a
|
|
148
|
+
key={id}
|
|
149
|
+
href={href}
|
|
150
|
+
aria-label={label}
|
|
151
|
+
aria-current={id === active ? "page" : undefined}
|
|
152
|
+
title={label}
|
|
153
|
+
className={[
|
|
154
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
155
|
+
id === active
|
|
156
|
+
? "bg-primary/10 text-primary-dark"
|
|
157
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
158
|
+
].join(" ")}
|
|
159
|
+
>
|
|
160
|
+
<Icon size={20} aria-hidden="true" />
|
|
161
|
+
</a>
|
|
162
|
+
))}
|
|
163
|
+
</nav>
|
|
164
|
+
<BottomTabBar apps={NAV_ITEMS} active={active} />
|
|
165
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
166
|
+
<header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
|
|
167
|
+
<span className="font-display font-bold text-lg uppercase tracking-wide">{activeItem?.label}</span>
|
|
168
|
+
<UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} />
|
|
169
|
+
</header>
|
|
170
|
+
<div className="flex-1 pb-14 md:pb-0">
|
|
171
|
+
<main className="max-w-6xl mx-auto px-6 py-8">{children}</main>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<>
|
|
181
|
+
<BrandingHeader branding={branding} />
|
|
182
|
+
{content}
|
|
183
|
+
<BrandingFooter branding={branding} />
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
7
|
+
|
|
8
|
+
function formatBytes(bytes: number): string {
|
|
9
|
+
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
|
|
10
|
+
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
11
|
+
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)} KB`;
|
|
12
|
+
return `${bytes} B`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MailboxTableProps {
|
|
16
|
+
mailboxes: Mailbox[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default function MailboxTable({ mailboxes }: MailboxTableProps) {
|
|
20
|
+
if (mailboxes.length === 0) {
|
|
21
|
+
return <p className="text-sm text-text-muted">No mailboxes found.</p>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="overflow-x-auto">
|
|
26
|
+
<table className="w-full text-sm border-collapse">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
|
|
30
|
+
Address
|
|
31
|
+
</th>
|
|
32
|
+
<th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
|
|
33
|
+
Display name
|
|
34
|
+
</th>
|
|
35
|
+
<th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
|
|
36
|
+
Owner
|
|
37
|
+
</th>
|
|
38
|
+
<th className="text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border">
|
|
39
|
+
Quota used
|
|
40
|
+
</th>
|
|
41
|
+
<th className="border-b border-border" />
|
|
42
|
+
</tr>
|
|
43
|
+
</thead>
|
|
44
|
+
<tbody>
|
|
45
|
+
{mailboxes.map((mailbox) => (
|
|
46
|
+
<tr key={mailbox.uid}>
|
|
47
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.primarySmtpAddress}</td>
|
|
48
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle">{mailbox.displayName}</td>
|
|
49
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle">
|
|
50
|
+
{mailbox.ownerUserUid ?? (
|
|
51
|
+
<span className="inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted">
|
|
52
|
+
Shared
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</td>
|
|
56
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle">
|
|
57
|
+
{formatBytes(mailbox.usedBytes)} / {formatBytes(mailbox.quotaBytes)}
|
|
58
|
+
</td>
|
|
59
|
+
<td className="py-2.5 px-2.5 border-b border-border align-middle text-right">
|
|
60
|
+
<a
|
|
61
|
+
href={`/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`}
|
|
62
|
+
className="text-primary-dark font-medium hover:underline"
|
|
63
|
+
>
|
|
64
|
+
View
|
|
65
|
+
</a>
|
|
66
|
+
</td>
|
|
67
|
+
</tr>
|
|
68
|
+
))}
|
|
69
|
+
</tbody>
|
|
70
|
+
</table>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { Mailbox, updateMailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
|
|
11
|
+
const INPUT_CLASS =
|
|
12
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
13
|
+
const SELECT_CLASS =
|
|
14
|
+
"text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
15
|
+
|
|
16
|
+
export interface ResourceSettingsCardProps {
|
|
17
|
+
mailbox: Mailbox;
|
|
18
|
+
/** Called with the freshly-saved mailbox after a successful save — mirrors `MemberListCard`'s
|
|
19
|
+
* identical push-up-rather-than-reload pattern, since the caller already has everything the
|
|
20
|
+
* response would return. */
|
|
21
|
+
onUpdate: (mailbox: Mailbox) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Lets an admin view and edit a resource mailbox's booking settings — same load/edit/PUT/reload
|
|
26
|
+
* interaction shape as `ShareAccessCard`, adapted the same way `MemberListCard` already adapted it:
|
|
27
|
+
* these seven fields live directly on the `Mailbox` the parent detail page already loaded (not a
|
|
28
|
+
* separate resource fetched by its own endpoint, unlike a mailbox's ACL), so this receives the
|
|
29
|
+
* already-loaded `mailbox` and pushes the `PUT` response back up rather than fetching independently.
|
|
30
|
+
* Only rendered by the detail page when `mailbox.isResource` is already `true` — a mailbox becomes a
|
|
31
|
+
* resource at creation time (`apps/admin/mailboxes/new`), not retroactively from here.
|
|
32
|
+
*/
|
|
33
|
+
export default function ResourceSettingsCard({ mailbox, onUpdate }: ResourceSettingsCardProps) {
|
|
34
|
+
const [resourceType, setResourceType] = useState<"room" | "equipment">(mailbox.resourceType ?? "room");
|
|
35
|
+
const [resourceCapacity, setResourceCapacity] = useState(mailbox.resourceCapacity?.toString() ?? "");
|
|
36
|
+
const [autoAcceptBookings, setAutoAcceptBookings] = useState(!!mailbox.autoAcceptBookings);
|
|
37
|
+
const [allowConflicts, setAllowConflicts] = useState(!!mailbox.allowConflicts);
|
|
38
|
+
const [bookingWindowDays, setBookingWindowDays] = useState(mailbox.bookingWindowDays?.toString() ?? "");
|
|
39
|
+
const [maxDurationMinutes, setMaxDurationMinutes] = useState(mailbox.maxDurationMinutes?.toString() ?? "");
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
const [saving, setSaving] = useState(false);
|
|
42
|
+
const [saved, setSaved] = useState(false);
|
|
43
|
+
|
|
44
|
+
async function handleSubmit(e: FormEvent) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
setError(null);
|
|
47
|
+
setSaved(false);
|
|
48
|
+
setSaving(true);
|
|
49
|
+
try {
|
|
50
|
+
const updated = await updateMailbox({
|
|
51
|
+
uid: mailbox.uid,
|
|
52
|
+
version: mailbox.version,
|
|
53
|
+
resourceType,
|
|
54
|
+
resourceCapacity: resourceCapacity ? Number(resourceCapacity) : undefined,
|
|
55
|
+
autoAcceptBookings,
|
|
56
|
+
allowConflicts,
|
|
57
|
+
bookingWindowDays: bookingWindowDays ? Number(bookingWindowDays) : undefined,
|
|
58
|
+
maxDurationMinutes: maxDurationMinutes ? Number(maxDurationMinutes) : undefined,
|
|
59
|
+
});
|
|
60
|
+
onUpdate(updated);
|
|
61
|
+
setSaved(true);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save resource settings.");
|
|
64
|
+
} finally {
|
|
65
|
+
setSaving(false);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
71
|
+
<h2 className="text-base font-bold uppercase tracking-wide mb-4">Resource settings</h2>
|
|
72
|
+
|
|
73
|
+
{error && <Alert>{error}</Alert>}
|
|
74
|
+
{saved && !error && <div className="mb-4 text-sm text-success font-medium">Saved.</div>}
|
|
75
|
+
|
|
76
|
+
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
77
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
78
|
+
<span className="font-semibold">Resource type</span>
|
|
79
|
+
<select
|
|
80
|
+
aria-label="Resource type"
|
|
81
|
+
className={SELECT_CLASS}
|
|
82
|
+
value={resourceType}
|
|
83
|
+
onChange={(e) => setResourceType(e.target.value as "room" | "equipment")}
|
|
84
|
+
>
|
|
85
|
+
<option value="room">Room</option>
|
|
86
|
+
<option value="equipment">Equipment</option>
|
|
87
|
+
</select>
|
|
88
|
+
</label>
|
|
89
|
+
|
|
90
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
91
|
+
<span className="font-semibold">Capacity (optional)</span>
|
|
92
|
+
<input
|
|
93
|
+
type="number"
|
|
94
|
+
min={0}
|
|
95
|
+
step={1}
|
|
96
|
+
aria-label="Capacity"
|
|
97
|
+
className={INPUT_CLASS}
|
|
98
|
+
value={resourceCapacity}
|
|
99
|
+
onChange={(e) => setResourceCapacity(e.target.value)}
|
|
100
|
+
placeholder="Informational only"
|
|
101
|
+
/>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
<label className="flex items-center gap-2 text-sm">
|
|
105
|
+
<input
|
|
106
|
+
type="checkbox"
|
|
107
|
+
checked={autoAcceptBookings}
|
|
108
|
+
onChange={(e) => setAutoAcceptBookings(e.target.checked)}
|
|
109
|
+
/>
|
|
110
|
+
Automatically accept booking requests
|
|
111
|
+
</label>
|
|
112
|
+
|
|
113
|
+
<label className="flex items-center gap-2 text-sm">
|
|
114
|
+
<input type="checkbox" checked={allowConflicts} onChange={(e) => setAllowConflicts(e.target.checked)} />
|
|
115
|
+
Allow conflicting bookings (skip conflict checking entirely)
|
|
116
|
+
</label>
|
|
117
|
+
|
|
118
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
119
|
+
<span className="font-semibold">Booking window, in days (optional)</span>
|
|
120
|
+
<input
|
|
121
|
+
type="number"
|
|
122
|
+
min={0}
|
|
123
|
+
step={1}
|
|
124
|
+
aria-label="Booking window, in days"
|
|
125
|
+
className={INPUT_CLASS}
|
|
126
|
+
value={bookingWindowDays}
|
|
127
|
+
onChange={(e) => setBookingWindowDays(e.target.value)}
|
|
128
|
+
placeholder="No limit"
|
|
129
|
+
/>
|
|
130
|
+
</label>
|
|
131
|
+
|
|
132
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
133
|
+
<span className="font-semibold">Maximum duration, in minutes (optional)</span>
|
|
134
|
+
<input
|
|
135
|
+
type="number"
|
|
136
|
+
min={0}
|
|
137
|
+
step={1}
|
|
138
|
+
aria-label="Maximum duration, in minutes"
|
|
139
|
+
className={INPUT_CLASS}
|
|
140
|
+
value={maxDurationMinutes}
|
|
141
|
+
onChange={(e) => setMaxDurationMinutes(e.target.value)}
|
|
142
|
+
placeholder="No limit"
|
|
143
|
+
/>
|
|
144
|
+
</label>
|
|
145
|
+
|
|
146
|
+
<div>
|
|
147
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
148
|
+
Save resource settings
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</form>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { AccessControlList, getMailboxAcl, grantMailboxAccess, revokeMailboxAccess } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
8
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DELEGATE_ACTIONS = ["read", "list", "count", "exists"];
|
|
12
|
+
|
|
13
|
+
export interface ShareAccessCardProps {
|
|
14
|
+
mailboxUid: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lets an admin (or, eventually, the mailbox owner) view and edit a mailbox's delegate access — the
|
|
19
|
+
* mechanism behind Exchange-style shared mailboxes. `records` on a mailbox's own ACL doubles as both the
|
|
20
|
+
* owner's grant (if any) and every delegate's — see `BaseMailboxRoute`'s doc comment in `@rapidmx/restapi`.
|
|
21
|
+
*/
|
|
22
|
+
export default function ShareAccessCard({ mailboxUid }: ShareAccessCardProps) {
|
|
23
|
+
const [acl, setAcl] = useState<AccessControlList | null>(null);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
const [newUserUid, setNewUserUid] = useState("");
|
|
27
|
+
const [saving, setSaving] = useState(false);
|
|
28
|
+
|
|
29
|
+
function reload() {
|
|
30
|
+
setLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
getMailboxAcl(mailboxUid)
|
|
33
|
+
.then(setAcl)
|
|
34
|
+
.catch((err) => setError(err instanceof ApiRequestError ? err.message : "Could not load share settings."))
|
|
35
|
+
.finally(() => setLoading(false));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
useEffect(reload, [mailboxUid]);
|
|
39
|
+
|
|
40
|
+
async function handleGrant(e: FormEvent) {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
const userUid = newUserUid.trim();
|
|
43
|
+
if (!userUid) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
setSaving(true);
|
|
47
|
+
setError(null);
|
|
48
|
+
try {
|
|
49
|
+
await grantMailboxAccess(mailboxUid, userUid, DEFAULT_DELEGATE_ACTIONS);
|
|
50
|
+
setNewUserUid("");
|
|
51
|
+
reload();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not grant access.");
|
|
54
|
+
} finally {
|
|
55
|
+
setSaving(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function handleRevoke(userOrRoleId: string) {
|
|
60
|
+
setSaving(true);
|
|
61
|
+
setError(null);
|
|
62
|
+
try {
|
|
63
|
+
await revokeMailboxAccess(mailboxUid, userOrRoleId);
|
|
64
|
+
reload();
|
|
65
|
+
} catch (err) {
|
|
66
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not revoke access.");
|
|
67
|
+
} finally {
|
|
68
|
+
setSaving(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="bg-surface border border-border rounded-md p-6">
|
|
74
|
+
<h2 className="text-base font-bold uppercase tracking-wide mb-1">Shared access</h2>
|
|
75
|
+
<p className="text-sm text-text-muted mb-4">
|
|
76
|
+
Grants read access to this mailbox for another user, independent of ownership.
|
|
77
|
+
</p>
|
|
78
|
+
|
|
79
|
+
{error && <Alert>{error}</Alert>}
|
|
80
|
+
|
|
81
|
+
{loading ? (
|
|
82
|
+
<p className="text-sm text-text-muted">Loading…</p>
|
|
83
|
+
) : (
|
|
84
|
+
<ul className="flex flex-col gap-2 mb-4">
|
|
85
|
+
{(acl?.records ?? []).length === 0 && (
|
|
86
|
+
<li className="text-sm text-text-muted">No grants on this mailbox yet.</li>
|
|
87
|
+
)}
|
|
88
|
+
{acl?.records.map((record) => (
|
|
89
|
+
<li
|
|
90
|
+
key={record.userOrRoleId}
|
|
91
|
+
className="flex items-center justify-between gap-3 py-2 px-3 border border-border rounded-sm"
|
|
92
|
+
>
|
|
93
|
+
<div>
|
|
94
|
+
<div className="text-sm font-medium">{record.userOrRoleId}</div>
|
|
95
|
+
<div className="text-xs text-text-muted">{record.actions.join(", ")}</div>
|
|
96
|
+
</div>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => handleRevoke(record.userOrRoleId)}
|
|
100
|
+
disabled={saving}
|
|
101
|
+
className="text-sm text-danger hover:underline disabled:opacity-55"
|
|
102
|
+
>
|
|
103
|
+
Revoke
|
|
104
|
+
</button>
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<form onSubmit={handleGrant} className="flex gap-2">
|
|
111
|
+
<input
|
|
112
|
+
type="text"
|
|
113
|
+
value={newUserUid}
|
|
114
|
+
onChange={(e) => setNewUserUid(e.target.value)}
|
|
115
|
+
placeholder="User uid to grant access to"
|
|
116
|
+
className="flex-1 text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary"
|
|
117
|
+
/>
|
|
118
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
119
|
+
Grant
|
|
120
|
+
</Button>
|
|
121
|
+
</form>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|