@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,165 @@
|
|
|
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, useState } from "react";
|
|
7
|
+
import type { IconType } from "react-icons";
|
|
8
|
+
import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers } from "react-icons/hi2";
|
|
9
|
+
import { useRedirectIfUnauthenticated } from "@rapidmx/react-shared/auth/session.js";
|
|
10
|
+
import { stopImpersonating } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
11
|
+
import useBranding from "@rapidmx/react-shared/branding/useBranding.js";
|
|
12
|
+
import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
13
|
+
import BottomTabBar from "@rapidmx/react-shared/components/navigation/BottomTabBar.js";
|
|
14
|
+
import { BrandingFooter, BrandingHeader } from "./BrandingChrome.js";
|
|
15
|
+
import UserMenu from "./UserMenu.js";
|
|
16
|
+
|
|
17
|
+
export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
|
|
18
|
+
|
|
19
|
+
/** `"settings"` is a valid `active` value but deliberately has no entry in `APPS` below — Settings is
|
|
20
|
+
* reached via a `UserMenu` item, not a 5th rail icon (see `SettingsShell.tsx`), so it highlights no
|
|
21
|
+
* rail/tab icon at all; only the header title (`ACTIVE_LABELS` below) needs to account for it. */
|
|
22
|
+
export type AppShellActive = AppShellApp | "settings";
|
|
23
|
+
|
|
24
|
+
export interface AppShellProps {
|
|
25
|
+
/** Which icon in the rail is highlighted as the current app — `"settings"` highlights none. */
|
|
26
|
+
active: AppShellActive;
|
|
27
|
+
/** Populated automatically by the framework from an authenticated request (e.g. a valid `jwt` cookie). */
|
|
28
|
+
userUid?: string;
|
|
29
|
+
/** auth-server's base URL, injected via the route's `fetchProps`. */
|
|
30
|
+
authServerUrl?: string;
|
|
31
|
+
/**
|
|
32
|
+
* `true` when this session is an admin "log in as user" impersonation (a `jwt_impersonator` cookie is
|
|
33
|
+
* present) — see `MailShell`'s original doc comment, unchanged now that this lives here. Drives the
|
|
34
|
+
* "you are viewing as this user — stop impersonating" banner below.
|
|
35
|
+
*/
|
|
36
|
+
impersonating?: boolean;
|
|
37
|
+
/** Where `mailApi.ts`'s `stopImpersonating()` should call — see `MailShell`'s original doc comment. */
|
|
38
|
+
impersonationBaseUrl?: string;
|
|
39
|
+
/** `true` when the caller's JWT carries a trusted role — shows an "Admin" item in the user menu below. */
|
|
40
|
+
trusted?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AppDef {
|
|
44
|
+
id: AppShellApp;
|
|
45
|
+
href: string;
|
|
46
|
+
label: string;
|
|
47
|
+
icon: IconType;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const APPS: AppDef[] = [
|
|
51
|
+
{ id: "mail", href: "/", label: "Mail", icon: HiOutlineEnvelope },
|
|
52
|
+
{ id: "calendar", href: "/calendar", label: "Calendar", icon: HiOutlineCalendarDays },
|
|
53
|
+
{ id: "contacts", href: "/contacts", label: "Contacts", icon: HiOutlineUsers },
|
|
54
|
+
{ id: "tasks", href: "/tasks", label: "Tasks", icon: HiOutlineClipboardDocumentList },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/** The header title for every valid `active` value — a superset of `APPS`' own labels since `"settings"`
|
|
58
|
+
* has no rail icon (and so no `AppDef`) but still needs a header title. */
|
|
59
|
+
const ACTIVE_LABELS: Record<AppShellActive, string> = {
|
|
60
|
+
mail: "Mail",
|
|
61
|
+
calendar: "Calendar",
|
|
62
|
+
contacts: "Contacts",
|
|
63
|
+
tasks: "Tasks",
|
|
64
|
+
settings: "Settings",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
|
|
69
|
+
* switching apps (full page loads — see `MailShell`'s original doc comment on this framework having no
|
|
70
|
+
* client-side router), a header with the current app's name and `UserMenu`, and the impersonation banner.
|
|
71
|
+
* Each app's own shell (e.g. `MailShell`) renders its own contextual sidebar + content as `children`, inside
|
|
72
|
+
* the area to the right of the icon rail and below the header.
|
|
73
|
+
*/
|
|
74
|
+
export default function AppShell({
|
|
75
|
+
active,
|
|
76
|
+
userUid,
|
|
77
|
+
authServerUrl,
|
|
78
|
+
impersonating,
|
|
79
|
+
impersonationBaseUrl,
|
|
80
|
+
trusted,
|
|
81
|
+
children,
|
|
82
|
+
}: PropsWithChildren<AppShellProps>) {
|
|
83
|
+
const [stoppingImpersonation, setStoppingImpersonation] = useState(false);
|
|
84
|
+
const { branding, iconSrc } = useBranding();
|
|
85
|
+
|
|
86
|
+
useRedirectIfUnauthenticated(userUid, authServerUrl);
|
|
87
|
+
|
|
88
|
+
function handleSignOut() {
|
|
89
|
+
window.location.href = authServerUrl ?? "/";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handleStopImpersonating() {
|
|
93
|
+
setStoppingImpersonation(true);
|
|
94
|
+
try {
|
|
95
|
+
await stopImpersonating(impersonationBaseUrl ?? "");
|
|
96
|
+
} catch {
|
|
97
|
+
// Navigate either way: a failed call leaves the impersonator cookie (and this banner) exactly as
|
|
98
|
+
// they were, so there's nothing else useful to show — matching this app's other network-error
|
|
99
|
+
// handling, which surfaces via a full reload rather than an inline retry affordance.
|
|
100
|
+
} finally {
|
|
101
|
+
window.location.href = "/admin";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!userUid) {
|
|
106
|
+
return <div className="min-h-screen" />;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<ComposeProvider>
|
|
111
|
+
<div className="min-h-screen flex flex-col bg-surface-alt">
|
|
112
|
+
<BrandingHeader branding={branding} />
|
|
113
|
+
{impersonating && (
|
|
114
|
+
<div className="h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4">
|
|
115
|
+
<span>
|
|
116
|
+
You are viewing as <strong>{userUid}</strong>.
|
|
117
|
+
</span>
|
|
118
|
+
<button
|
|
119
|
+
type="button"
|
|
120
|
+
onClick={handleStopImpersonating}
|
|
121
|
+
disabled={stoppingImpersonation}
|
|
122
|
+
className="underline hover:no-underline disabled:opacity-60"
|
|
123
|
+
>
|
|
124
|
+
{stoppingImpersonation ? "Returning to admin…" : "Return to admin"}
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
<div className="flex-1 flex min-h-0">
|
|
129
|
+
<nav
|
|
130
|
+
aria-label="Apps"
|
|
131
|
+
className="hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center py-3 gap-1"
|
|
132
|
+
>
|
|
133
|
+
<img src={iconSrc} width="96" height="96" alt="" className="mb-3" />
|
|
134
|
+
{APPS.map(({ id, href, label, icon: Icon }) => (
|
|
135
|
+
<a
|
|
136
|
+
key={id}
|
|
137
|
+
href={href}
|
|
138
|
+
aria-label={label}
|
|
139
|
+
aria-current={id === active ? "page" : undefined}
|
|
140
|
+
title={label}
|
|
141
|
+
className={[
|
|
142
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
143
|
+
id === active
|
|
144
|
+
? "bg-primary/10 text-primary-dark"
|
|
145
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
146
|
+
].join(" ")}
|
|
147
|
+
>
|
|
148
|
+
<Icon size={20} aria-hidden="true" />
|
|
149
|
+
</a>
|
|
150
|
+
))}
|
|
151
|
+
</nav>
|
|
152
|
+
<BottomTabBar apps={APPS} active={active} />
|
|
153
|
+
<div className="flex-1 flex flex-col min-w-0">
|
|
154
|
+
<header className="h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6">
|
|
155
|
+
<span className="font-display font-bold text-lg uppercase tracking-wide">{ACTIVE_LABELS[active]}</span>
|
|
156
|
+
<UserMenu userUid={userUid} authServerUrl={authServerUrl} onSignOut={handleSignOut} showAdminLink={trusted} showSettingsLink />
|
|
157
|
+
</header>
|
|
158
|
+
<div className="flex-1 flex min-h-0 pb-14 md:pb-0">{children}</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<BrandingFooter branding={branding} />
|
|
162
|
+
</div>
|
|
163
|
+
</ComposeProvider>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Branding } from "@rapidmx/react-shared/branding/brandingApi.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Renders the admin-configured `Branding.headerHtml`/`footerHtml` (see `useBranding()`) — shared by every
|
|
10
|
+
* chrome in this project (`AppShell`, `AdminShell`, and the public `apps/book` pages) so the setting applies
|
|
11
|
+
* everywhere its own admin UI copy (`apps/admin/branding/index.tsx`) claims it does.
|
|
12
|
+
*/
|
|
13
|
+
export function BrandingHeader({ branding }: { branding: Branding | null }) {
|
|
14
|
+
if (!branding?.headerHtml) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return <div dangerouslySetInnerHTML={{ __html: branding.headerHtml }} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function BrandingFooter({ branding }: { branding: Branding | null }) {
|
|
21
|
+
if (!branding?.footerHtml) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return <div dangerouslySetInnerHTML={{ __html: branding.footerHtml }} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { MailboxAutoProvisionAliasOption, autoProvisionMailbox } 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
|
+
type Status = "checking" | "needs_selection" | "creating" | "unavailable";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rendered by each app's shell (Mail/Calendar/Contacts/Tasks) *instead of* the normal `AppShell`
|
|
15
|
+
* chrome (icon rail, header, folder tree, ...) when the caller has no mailbox — a full-screen,
|
|
16
|
+
* 404-style takeover, not a small message nested inside the rest of the client UI, since there's
|
|
17
|
+
* nothing else in the app for a mailbox-less caller to do yet. Attempts self-service
|
|
18
|
+
* auto-provisioning — see `BaseMailboxRoute.autoProvision()`'s own doc comment in `@rapidmx/restapi`
|
|
19
|
+
* for the full contract — which is itself a no-op (a 404) unless an admin has both turned it on
|
|
20
|
+
* (`mail:auto_provision:enabled`) and configured at least one domain (`mail:domains`). Safe to render
|
|
21
|
+
* unconditionally in that place: any failure (disabled, no registered username, the identity service
|
|
22
|
+
* unreachable) just falls back to the same plain "no mailbox" message this replaces.
|
|
23
|
+
*/
|
|
24
|
+
export default function MailboxProvisioning() {
|
|
25
|
+
const [status, setStatus] = useState<Status>("checking");
|
|
26
|
+
const [options, setOptions] = useState<MailboxAutoProvisionAliasOption[]>([]);
|
|
27
|
+
const [selected, setSelected] = useState("");
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
autoProvisionMailbox()
|
|
32
|
+
.then((result) => {
|
|
33
|
+
if (result.status === "needs_selection") {
|
|
34
|
+
// `autoProvision()` only ever returns this status with a non-empty cross product of
|
|
35
|
+
// the caller's aliases and this server's configured domains (both are checked
|
|
36
|
+
// non-empty before it's built) — no fallback needed for an empty list here.
|
|
37
|
+
setOptions(result.options);
|
|
38
|
+
setSelected(result.options[0].primarySmtpAddress);
|
|
39
|
+
setStatus("needs_selection");
|
|
40
|
+
} else {
|
|
41
|
+
// "created" or "existing" — either way the caller now has a mailbox; a full reload is
|
|
42
|
+
// this framework's own convention for picking up new server-side state (no client
|
|
43
|
+
// router to instead re-fetch just the shell's own data in place).
|
|
44
|
+
window.location.reload();
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
.catch(() => setStatus("unavailable"));
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
async function handleConfirm() {
|
|
51
|
+
// `selected` only ever takes a value from `options` itself (the initial default, or the
|
|
52
|
+
// <select>'s own onChange, which can only report one of its own rendered <option> values), so
|
|
53
|
+
// this is always found — no defensive fallback needed.
|
|
54
|
+
const option = options.find((o) => o.primarySmtpAddress === selected)!;
|
|
55
|
+
setStatus("creating");
|
|
56
|
+
setError(null);
|
|
57
|
+
try {
|
|
58
|
+
await autoProvisionMailbox({ alias: option.alias, domain: option.domain });
|
|
59
|
+
window.location.reload();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not create your mailbox.");
|
|
62
|
+
setStatus("needs_selection");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let content: React.ReactNode;
|
|
67
|
+
if (status === "checking") {
|
|
68
|
+
content = <p className="text-sm text-text-muted">Setting up your mailbox…</p>;
|
|
69
|
+
} else if (status === "needs_selection" || status === "creating") {
|
|
70
|
+
const creating = status === "creating";
|
|
71
|
+
content = (
|
|
72
|
+
<div className="w-full flex flex-col gap-3">
|
|
73
|
+
<p className="text-sm text-text-muted text-center">Choose your mailbox address to get started:</p>
|
|
74
|
+
{error && <Alert>{error}</Alert>}
|
|
75
|
+
<select
|
|
76
|
+
aria-label="Mailbox address"
|
|
77
|
+
className="text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
78
|
+
value={selected}
|
|
79
|
+
onChange={(e) => setSelected(e.target.value)}
|
|
80
|
+
disabled={creating}
|
|
81
|
+
>
|
|
82
|
+
{options.map((option) => (
|
|
83
|
+
<option key={option.primarySmtpAddress} value={option.primarySmtpAddress}>
|
|
84
|
+
{option.primarySmtpAddress}
|
|
85
|
+
</option>
|
|
86
|
+
))}
|
|
87
|
+
</select>
|
|
88
|
+
<Button type="button" onClick={handleConfirm} loading={creating} disabled={creating} className="!w-auto self-center">
|
|
89
|
+
Continue
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
content = (
|
|
95
|
+
<>
|
|
96
|
+
<h1 className="text-lg font-bold uppercase tracking-wide">No mailbox available</h1>
|
|
97
|
+
<p className="text-sm text-text-muted">Ask an administrator to create one for you.</p>
|
|
98
|
+
</>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<div className="min-h-screen flex items-center justify-center p-8 bg-surface-alt">
|
|
104
|
+
<div className="w-full max-w-md bg-surface border border-border rounded-md p-8 flex flex-col items-center gap-4 text-center">
|
|
105
|
+
<img src="/images/wordmark.png" height="128" alt="" />
|
|
106
|
+
{content}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { formatProfileName, getMyProfile, Profile, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
7
|
+
|
|
8
|
+
export interface UserMenuProps {
|
|
9
|
+
userUid: string;
|
|
10
|
+
/** auth-server's base URL — where the caller's own profile (name/avatar) is fetched from, if configured. */
|
|
11
|
+
authServerUrl?: string;
|
|
12
|
+
onSignOut: () => void;
|
|
13
|
+
/** Shows an "Admin" item linking to `/admin`, above "Sign Out" — pass only for a trusted-role caller. */
|
|
14
|
+
showAdminLink?: boolean;
|
|
15
|
+
/** Shows a "Settings" item, above "Admin"/"Sign Out" — an account-scoped surface (like "Admin"), not
|
|
16
|
+
* a content-scoped one, so it lives here rather than as a 5th `AppShell` rail icon. Links straight to
|
|
17
|
+
* `/settings/auto-reply`, the only settings section that exists today; repoint this at a real
|
|
18
|
+
* `/settings` landing page once a second section (Mail Filters, Signatures) makes one worth building. */
|
|
19
|
+
showSettingsLink?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function Avatar({ profile, initials, large }: { profile?: Profile; initials: string; large?: boolean }) {
|
|
23
|
+
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
24
|
+
if (profile?.avatar) {
|
|
25
|
+
return (
|
|
26
|
+
<img
|
|
27
|
+
src={profile.avatar}
|
|
28
|
+
alt=""
|
|
29
|
+
className={`${sizeClass} rounded-full object-cover shrink-0`}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
className={`${sizeClass} rounded-full bg-primary text-white font-semibold flex items-center justify-center shrink-0`}
|
|
36
|
+
>
|
|
37
|
+
{initials}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The top-right "who am I" menu shared by `MailShell` and `AdminShell`: an avatar-button trigger that opens a
|
|
44
|
+
* dropdown showing the caller's avatar/name, an optional "Admin" link (trusted-role callers only), and
|
|
45
|
+
* "Sign Out". Name/avatar come from auth-server's own profile endpoint (`GET /api/profiles/me`) — this
|
|
46
|
+
* service has no local user directory (see `.claude/NOTES.md`) — and fall back to the bare uid/its first
|
|
47
|
+
* letter when unset or unreachable, which is a fully valid, expected state for most password-registered
|
|
48
|
+
* accounts (only OIDC sign-in currently populates a real avatar).
|
|
49
|
+
*/
|
|
50
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, showSettingsLink }: UserMenuProps) {
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const [profile, setProfile] = useState<Profile | undefined>(undefined);
|
|
53
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!authServerUrl) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
getMyProfile(authServerUrl)
|
|
60
|
+
.then(setProfile)
|
|
61
|
+
.catch(() => undefined);
|
|
62
|
+
}, [authServerUrl]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
function handlePointerDown(e: MouseEvent) {
|
|
69
|
+
if (containerRef.current && e.target instanceof Node && !containerRef.current.contains(e.target)) {
|
|
70
|
+
setOpen(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
74
|
+
if (e.key === "Escape") {
|
|
75
|
+
setOpen(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
document.addEventListener("mousedown", handlePointerDown);
|
|
79
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
80
|
+
return () => {
|
|
81
|
+
document.removeEventListener("mousedown", handlePointerDown);
|
|
82
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
83
|
+
};
|
|
84
|
+
}, [open]);
|
|
85
|
+
|
|
86
|
+
const name = formatProfileName(profile) ?? userUid;
|
|
87
|
+
const initials = profileInitials(profile, userUid);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div className="relative" ref={containerRef}>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => setOpen((o) => !o)}
|
|
94
|
+
aria-haspopup="true"
|
|
95
|
+
aria-expanded={open}
|
|
96
|
+
aria-label="Account menu"
|
|
97
|
+
className="flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt"
|
|
98
|
+
>
|
|
99
|
+
<Avatar profile={profile} initials={initials} />
|
|
100
|
+
<svg
|
|
101
|
+
width="14"
|
|
102
|
+
height="14"
|
|
103
|
+
viewBox="0 0 20 20"
|
|
104
|
+
fill="none"
|
|
105
|
+
className={`text-text-muted transition-transform ${open ? "rotate-180" : ""}`}
|
|
106
|
+
aria-hidden="true"
|
|
107
|
+
>
|
|
108
|
+
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
109
|
+
</svg>
|
|
110
|
+
</button>
|
|
111
|
+
{open && (
|
|
112
|
+
<div
|
|
113
|
+
role="menu"
|
|
114
|
+
className="absolute right-0 top-full mt-2 w-60 bg-surface border border-border rounded-md shadow-modal py-1.5 z-10"
|
|
115
|
+
>
|
|
116
|
+
<div className="flex items-center gap-3 px-3.5 py-2.5 border-b border-border">
|
|
117
|
+
<Avatar profile={profile} initials={initials} large />
|
|
118
|
+
<span className="text-sm font-semibold text-text truncate">{name}</span>
|
|
119
|
+
</div>
|
|
120
|
+
{showSettingsLink && (
|
|
121
|
+
<a
|
|
122
|
+
role="menuitem"
|
|
123
|
+
href="/settings/auto-reply"
|
|
124
|
+
className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
125
|
+
>
|
|
126
|
+
Settings
|
|
127
|
+
</a>
|
|
128
|
+
)}
|
|
129
|
+
{showAdminLink && (
|
|
130
|
+
<a
|
|
131
|
+
role="menuitem"
|
|
132
|
+
href="/admin"
|
|
133
|
+
className="block px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
134
|
+
>
|
|
135
|
+
Admin
|
|
136
|
+
</a>
|
|
137
|
+
)}
|
|
138
|
+
<button
|
|
139
|
+
role="menuitem"
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onSignOut}
|
|
142
|
+
className="block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt"
|
|
143
|
+
>
|
|
144
|
+
Sign Out
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { ConversationSummary } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
7
|
+
|
|
8
|
+
export interface ConversationListProps {
|
|
9
|
+
conversations: ConversationSummary[];
|
|
10
|
+
selectedId: string | null;
|
|
11
|
+
onSelect: (conversation: ConversationSummary) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The "By conversation" counterpart to the per-message list `apps/www/index.tsx` renders inline for
|
|
15
|
+
* "By date" — conversations are mailbox-wide (see `conversationsApi.ts`), so there's no per-folder
|
|
16
|
+
* empty/loading state to distinguish here beyond what the page itself already handles. */
|
|
17
|
+
export default function ConversationList({ conversations, selectedId, onSelect }: ConversationListProps) {
|
|
18
|
+
if (conversations.length === 0) {
|
|
19
|
+
return <p className="p-4 text-sm text-text-muted">No conversations in this mailbox.</p>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<ul>
|
|
24
|
+
{conversations.map((conversation) => {
|
|
25
|
+
const unread = conversation.unreadCount > 0;
|
|
26
|
+
return (
|
|
27
|
+
<li key={conversation.conversationId}>
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
onClick={() => onSelect(conversation)}
|
|
31
|
+
className={[
|
|
32
|
+
"w-full text-left px-4 py-3 border-b border-border",
|
|
33
|
+
conversation.conversationId === selectedId ? "bg-primary/10" : "hover:bg-surface-alt",
|
|
34
|
+
unread ? "font-semibold" : "",
|
|
35
|
+
].join(" ")}
|
|
36
|
+
>
|
|
37
|
+
<div className="flex items-center justify-between gap-2 text-sm">
|
|
38
|
+
<span className="truncate">
|
|
39
|
+
{conversation.participants.map((p) => p.displayName || p.address).join(", ")}
|
|
40
|
+
</span>
|
|
41
|
+
<span className="text-xs text-text-muted shrink-0">
|
|
42
|
+
{new Date(conversation.latestDate).toLocaleDateString()}
|
|
43
|
+
</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="text-sm truncate">{conversation.subject || "(no subject)"}</div>
|
|
46
|
+
<div className="flex items-center gap-2 mt-1 text-xs text-text-muted font-normal">
|
|
47
|
+
{conversation.messageCount > 1 && <span>{conversation.messageCount} messages</span>}
|
|
48
|
+
{unread && (
|
|
49
|
+
<span className="py-0.5 px-2 rounded-pill bg-primary/10 text-primary-dark font-semibold">
|
|
50
|
+
{conversation.unreadCount} unread
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
{conversation.hasAttachments && <span aria-label="Has attachments">📎</span>}
|
|
54
|
+
</div>
|
|
55
|
+
</button>
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</ul>
|
|
60
|
+
);
|
|
61
|
+
}
|