Add ACL roles, form sharing, and microsite v2 improvements.

Merge remote document upload changes while keeping permission-gated forms UI and share workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-07-13 15:11:25 +05:30
parent 248caa7029
commit bad7d38777
35 changed files with 2196 additions and 362 deletions

2
dist/index.html vendored
View File

@ -12,7 +12,7 @@
rel="stylesheet" rel="stylesheet"
/> />
<script src="https://accounts.google.com/gsi/client" async defer></script> <script src="https://accounts.google.com/gsi/client" async defer></script>
<script type="module" crossorigin src="/assets/index-D03jbJi-.js"></script> <script type="module" crossorigin src="/assets/index-DJFhkFks.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css"> <link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
</head> </head>

View File

@ -2,6 +2,7 @@ import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from "./contexts/AuthContext";
import { PermissionProvider } from "./contexts/PermissionContext";
import { ErrorBoundary, GlobalMutationLoadingOverlay } from "./components/shared"; import { ErrorBoundary, GlobalMutationLoadingOverlay } from "./components/shared";
import { LoginView, RegisterView } from "./views/AuthViews"; import { LoginView, RegisterView } from "./views/AuthViews";
import { AcceptEventInviteView } from "./views/AcceptEventInviteView"; import { AcceptEventInviteView } from "./views/AcceptEventInviteView";
@ -19,6 +20,7 @@ export default function App() {
<ErrorBoundary> <ErrorBoundary>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<PermissionProvider>
<BrowserRouter> <BrowserRouter>
<GlobalMutationLoadingOverlay /> <GlobalMutationLoadingOverlay />
<Routes> <Routes>
@ -41,6 +43,7 @@ export default function App() {
<Route path="*" element={<Navigate to="/home" replace />} /> <Route path="*" element={<Navigate to="/home" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</PermissionProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@ -30,6 +30,7 @@ import {
} from "./global/GlobalStubPage"; } from "./global/GlobalStubPage";
import { PaymentSettingsPage } from "./global/PaymentSettingsPage"; import { PaymentSettingsPage } from "./global/PaymentSettingsPage";
import { SettingsPage } from "./global/SettingsPage"; import { SettingsPage } from "./global/SettingsPage";
import { RolesPage } from "./roles/RolesPage";
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage"; import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage"; import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
import { SearchResultsPage } from "./search/SearchResultsPage"; import { SearchResultsPage } from "./search/SearchResultsPage";
@ -127,6 +128,7 @@ export function MainContent() {
return <MessagingPage subPage={activeNav} />; return <MessagingPage subPage={activeNav} />;
if (activeNav === "payment") return <PaymentSettingsPage />; if (activeNav === "payment") return <PaymentSettingsPage />;
if (activeNav === "user-management") return <UserManagementPage />; if (activeNav === "user-management") return <UserManagementPage />;
if (activeNav === "roles-management") return <RolesPage />;
if (activeNav === "settings") if (activeNav === "settings")
return ( return (
<SettingsPage <SettingsPage

View File

@ -4,6 +4,19 @@ import {
BarChart2, Trophy, ClipboardCheck, Zap, CalendarDays, BarChart2, Trophy, ClipboardCheck, Zap, CalendarDays,
} from "lucide-react"; } from "lucide-react";
import { useBreakpoint } from "../hooks/useBreakpoint"; import { useBreakpoint } from "../hooks/useBreakpoint";
import { navItemAllowed, usePermission } from "../contexts/PermissionContext";
/** Event nav tab → ACL module for read access */
const EVENT_TAB_MODULES: Record<string, string> = {
dashboard: "events",
agenda: "sessions",
participants: "participants",
attendance: "participants",
"event-activities": "live_polls",
rewards: "gamification",
analytics: "events",
permissions: "user_management",
};
/* ───────────────────────────────────── /* ─────────────────────────────────────
Event-specific nav items Event-specific nav items
@ -37,6 +50,12 @@ interface NavBarProps {
} }
export function NavBar({ activeItem, onNavigate }: NavBarProps) { export function NavBar({ activeItem, onNavigate }: NavBarProps) {
const { canModule } = usePermission();
const visibleTabs = EVENT_TABS.filter((tab) => {
const mod = EVENT_TAB_MODULES[tab.id];
if (!mod) return true;
return canModule(mod, "read");
});
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
@ -93,7 +112,7 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
msOverflowStyle: "none" as any, msOverflowStyle: "none" as any,
}} }}
> >
{EVENT_TABS.map(tab => { {visibleTabs.map(tab => {
const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz")); const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz"));
return ( return (
<button <button

View File

@ -8,6 +8,7 @@ import { useBreakpoint } from "../hooks/useBreakpoint";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTheme } from "../contexts/ThemeContext"; import { useTheme } from "../contexts/ThemeContext";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { navItemAllowed, usePermission } from "../contexts/PermissionContext";
/* ───────────────────────────────────── /* ─────────────────────────────────────
Types Types
@ -135,7 +136,9 @@ function NavList({
selectedEventId?: string | null; selectedEventId?: string | null;
onEventSelect?: (id: string) => void; onEventSelect?: (id: string) => void;
}) { }) {
const defaultOpen = PORTFOLIO_NAV.filter(n => n.children).reduce<Record<string, boolean>>((acc, item) => { const { canModule } = usePermission();
const visibleNav = PORTFOLIO_NAV.filter((item) => navItemAllowed(item.id, canModule));
const defaultOpen = visibleNav.filter(n => n.children).reduce<Record<string, boolean>>((acc, item) => {
acc[item.id] = isChildActive(item, activeItem); acc[item.id] = isChildActive(item, activeItem);
return acc; return acc;
}, {}); }, {});
@ -166,7 +169,7 @@ function NavList({
return ( return (
<> <>
{PORTFOLIO_NAV.map(item => { {visibleNav.map(item => {
/* ── Special: All Events accordion ── */ /* ── Special: All Events accordion ── */
if (item.id === "events") { if (item.id === "events") {
@ -256,7 +259,7 @@ function NavList({
{isOpen && ( {isOpen && (
<div style={{ paddingLeft: "14px", marginBottom: "2px" }}> <div style={{ paddingLeft: "14px", marginBottom: "2px" }}>
<div style={{ borderLeft: "1px solid var(--sidebar-border)", paddingLeft: "8px" }}> <div style={{ borderLeft: "1px solid var(--sidebar-border)", paddingLeft: "8px" }}>
{item.children!.map(child => { {item.children!.filter((child) => navItemAllowed(child.id, canModule)).map(child => {
const cActive = activeItem === child.id; const cActive = activeItem === child.id;
return ( return (
<button key={child.id} onClick={() => onNavigate(child.id)} <button key={child.id} onClick={() => onNavigate(child.id)}

View File

@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react"; import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../hooks/usePermission";
import { eventsApi } from "../../../services/api/events"; import { eventsApi } from "../../../services/api/events";
import { eventStaffApi } from "../../../services/api/eventStaff"; import { eventStaffApi } from "../../../services/api/eventStaff";
import { quizPollsApi } from "../../../services/api/quizPolls"; import { quizPollsApi } from "../../../services/api/quizPolls";
@ -11,6 +13,7 @@ import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel"; import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
import { CreateFormPanel } from "../panels/CreateFormPanel"; import { CreateFormPanel } from "../panels/CreateFormPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel"; import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
import { ShareFormPanel } from "../forms/ShareFormPanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel"; import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel"; import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel"; import { EventDocumentsPanel } from "./EventDocumentsPanel";
@ -129,9 +132,9 @@ function moduleButtonStyle(primary = false): React.CSSProperties {
minWidth: 0, minWidth: 0,
padding: "6px 8px", padding: "6px 8px",
borderRadius: "var(--radius-button)", borderRadius: "var(--radius-button)",
border: primary ? "none" : "1px solid var(--border)", border: primary ? "1px solid var(--primary)" : "1px solid var(--border)",
background: primary ? "var(--primary)" : "var(--card)", background: "var(--card)",
color: primary ? "var(--primary-foreground)" : "var(--card-foreground)", color: primary ? "var(--primary)" : "var(--card-foreground)",
fontSize: "var(--text-xs)", fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)", fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)", fontWeight: "var(--font-weight-medium)",
@ -148,6 +151,13 @@ function formDisplayType(apiType: string): string {
return apiType.charAt(0).toUpperCase() + apiType.slice(1); return apiType.charAt(0).toUpperCase() + apiType.slice(1);
} }
const NEW_FORM_OPTIONS: { value: "registration" | "feedback" | "web-check-in" | "other"; label: string }[] = [
{ value: "registration", label: "Registration" },
{ value: "feedback", label: "Feedback" },
{ value: "web-check-in", label: "Web check-in" },
{ value: "other", label: "Other" },
];
function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" { function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" {
if (apiType === "feedback") return "feedback"; if (apiType === "feedback") return "feedback";
if (apiType === "registration") return "registration"; if (apiType === "registration") return "registration";
@ -173,11 +183,26 @@ function formLinkCopyButtonStyle(copied: boolean): React.CSSProperties {
color: copied ? "#15803d" : "var(--muted)", color: copied ? "#15803d" : "var(--muted)",
cursor: "pointer", cursor: "pointer",
flexShrink: 0, flexShrink: 0,
padding: 0,
};
}
function formHeaderIconButtonStyle(): React.CSSProperties {
return {
...formLinkCopyButtonStyle(false),
color: "var(--muted)",
}; };
} }
export function EventHomeModules() { export function EventHomeModules() {
const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav(); const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav();
const { user } = useAuth();
const { canModule } = usePermission();
const isSuperAdmin = user?.role === "super_admin";
const canReadForms = canModule("forms", "read");
const canWriteForms = canModule("forms", "write");
const canWriteSessions = canModule("sessions", "write");
const canWriteUserMgmt = canModule("user_management", "write");
const bp = useBreakpoint(); const bp = useBreakpoint();
const isMobile = bp === "mobile"; const isMobile = bp === "mobile";
const isTablet = bp === "tablet"; const isTablet = bp === "tablet";
@ -189,7 +214,9 @@ export function EventHomeModules() {
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]); const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null); const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null);
const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null); const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null);
const [formShareTarget, setFormShareTarget] = useState<FormWithSubmissionCount | null>(null);
const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | "web-check-in" | null>(null); const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | "web-check-in" | null>(null);
const [newFormType, setNewFormType] = useState<"registration" | "feedback" | "web-check-in" | "other">("registration");
const [copiedFormLinkId, setCopiedFormLinkId] = useState<string | null>(null); const [copiedFormLinkId, setCopiedFormLinkId] = useState<string | null>(null);
const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 }); const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 });
const [pollQuizLoading, setPollQuizLoading] = useState(false); const [pollQuizLoading, setPollQuizLoading] = useState(false);
@ -587,11 +614,11 @@ export function EventHomeModules() {
</button> </button>
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled || !canWriteSessions}
style={{ style={{
...moduleButtonStyle(true), ...moduleButtonStyle(true),
opacity: actionsDisabled ? 0.55 : 1, opacity: actionsDisabled || !canWriteSessions ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer", cursor: actionsDisabled || !canWriteSessions ? "not-allowed" : "pointer",
}} }}
onClick={() => { onClick={() => {
setOpenAgendaSessionPanelOnce(true); setOpenAgendaSessionPanelOnce(true);
@ -603,7 +630,7 @@ export function EventHomeModules() {
</> </>
} }
/> />
{eventForms.map((form) => { {canReadForms ? eventForms.map((form) => {
const linkCopied = copiedFormLinkId === form._id; const linkCopied = copiedFormLinkId === form._id;
return ( return (
<ModuleCard <ModuleCard
@ -612,32 +639,59 @@ export function EventHomeModules() {
title={form.name?.trim() || "Untitled form"} title={form.name?.trim() || "Untitled form"}
meta={formMetaLine(form)} meta={formMetaLine(form)}
headerAction={ headerAction={
<button <div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", flexShrink: 0 }}>
type="button" <button
title={linkCopied ? "Copied to clipboard" : "Copy public form link"} type="button"
disabled={actionsDisabled} title={linkCopied ? "Copied to clipboard" : "Copy public form link"}
style={{ disabled={actionsDisabled}
...formLinkCopyButtonStyle(linkCopied), style={{
opacity: actionsDisabled ? 0.55 : 1, ...formLinkCopyButtonStyle(linkCopied),
cursor: actionsDisabled ? "not-allowed" : "pointer", opacity: actionsDisabled ? 0.55 : 1,
}} cursor: actionsDisabled ? "not-allowed" : "pointer",
onClick={() => copyFormPublicLink(form)} }}
onMouseEnter={(e) => { onClick={() => copyFormPublicLink(form)}
if (actionsDisabled || linkCopied) return; onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; if (actionsDisabled || linkCopied) return;
(e.currentTarget as HTMLElement).style.color = "var(--primary)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
}} (e.currentTarget as HTMLElement).style.color = "var(--primary)";
onMouseLeave={(e) => { }}
if (linkCopied) return; onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; if (linkCopied) return;
(e.currentTarget as HTMLElement).style.color = "var(--muted)"; (e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
}} (e.currentTarget as HTMLElement).style.color = "var(--muted)";
> }}
{linkCopied ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />} >
</button> {linkCopied ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
</button>
{isSuperAdmin ? (
<button
type="button"
title="Share form"
disabled={actionsDisabled}
style={{
...formHeaderIconButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setFormShareTarget(form)}
onMouseEnter={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
<Share2 size={12} />
</button>
) : null}
</div>
} }
actions={ actions={
<> <>
{canWriteForms ? (
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled}
@ -660,6 +714,8 @@ export function EventHomeModules() {
> >
Edit Edit
</button> </button>
) : null}
{canReadForms ? (
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled}
@ -672,11 +728,54 @@ export function EventHomeModules() {
> >
Responses Responses
</button> </button>
) : null}
</> </>
} }
/> />
); );
})} }) : null}
{canWriteForms ? (
<ModuleCard
icon={<Plus size={18} />}
title="Create New Form"
description="Add a form assigned to this event"
actions={
<>
<select
disabled={actionsDisabled}
value={newFormType}
onChange={(e) => setNewFormType(e.target.value as typeof newFormType)}
style={{
...moduleButtonStyle(),
flex: "1 1 0",
appearance: "auto",
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
>
{NEW_FORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(true),
flex: "0 0 auto",
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setNewFormMode(newFormType)}
>
Create
</button>
</>
}
/>
) : null}
<ModuleCard <ModuleCard
icon={<ShieldCheck size={18} />} icon={<ShieldCheck size={18} />}
title="Users & Permissions" title="Users & Permissions"
@ -705,6 +804,7 @@ export function EventHomeModules() {
> >
View View
</button> </button>
{canWriteUserMgmt ? (
<button <button
type="button" type="button"
disabled={actionsDisabled} disabled={actionsDisabled}
@ -720,6 +820,7 @@ export function EventHomeModules() {
> >
Add New Add New
</button> </button>
) : null}
</> </>
} }
/> />
@ -907,83 +1008,6 @@ export function EventHomeModules() {
) )
} }
/> />
<ModuleCard
icon={<Plus size={18} />}
title="Create New Form"
description="Add a form assigned to this event"
actions={
<>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setNewFormMode("registration")}
onMouseEnter={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}}
>
Registration
</button>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setNewFormMode("feedback")}
onMouseEnter={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
}}
onMouseLeave={(e) => {
if (actionsDisabled) return;
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
}}
>
Feedback
</button>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setNewFormMode("web-check-in")}
>
Web check-in
</button>
<button
type="button"
disabled={actionsDisabled}
style={{
...moduleButtonStyle(true),
opacity: actionsDisabled ? 0.55 : 1,
cursor: actionsDisabled ? "not-allowed" : "pointer",
}}
onClick={() => setNewFormMode("other")}
>
Other
</button>
</>
}
/>
<ModuleCard <ModuleCard
icon={<Upload size={18} />} icon={<Upload size={18} />}
title="Upload Document" title="Upload Document"
@ -1005,6 +1029,10 @@ export function EventHomeModules() {
/> />
</section> </section>
{formShareTarget ? (
<ShareFormPanel formId={formShareTarget._id} onClose={() => setFormShareTarget(null)} />
) : null}
{formEditTarget && selectedEventId ? ( {formEditTarget && selectedEventId ? (
<CreateFormPanel <CreateFormPanel
mode={formPanelMode(formEditTarget.type)} mode={formPanelMode(formEditTarget.type)}

View File

@ -13,6 +13,7 @@ import type { DateRange } from "react-day-picker";
import { QuickCreateEventPanel } from "../panels/QuickCreateEventPanel"; import { QuickCreateEventPanel } from "../panels/QuickCreateEventPanel";
import { useTheme } from "../../contexts/ThemeContext"; import { useTheme } from "../../contexts/ThemeContext";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../hooks/usePermission";
import { useAppNav } from "../../contexts/AppNavContext"; import { useAppNav } from "../../contexts/AppNavContext";
import { import {
portfolioListOrganizationId, portfolioListOrganizationId,
@ -1196,6 +1197,9 @@ function Toast({ msg, onDone }: { msg: string; onDone: () => void }) {
export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId: string) => void }) { export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId: string) => void }) {
const { user } = useAuth(); const { user } = useAuth();
const { canModule, isOrgSuperAdmin } = usePermission();
const canWriteEvents = canModule("events", "write");
const canListUsers = canModule("user_management", "read");
const eventsOrgId = portfolioListOrganizationId(user); const eventsOrgId = portfolioListOrganizationId(user);
const { const {
setActiveNav, setActiveNav,
@ -1230,7 +1234,6 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
const [onboardingSessionCount, setOnboardingSessionCount] = useState(0); const [onboardingSessionCount, setOnboardingSessionCount] = useState(0);
const categories = useMemo(() => buildCategoriesFromApi(apiEvents), [apiEvents]); const categories = useMemo(() => buildCategoriesFromApi(apiEvents), [apiEvents]);
const canListUsers = user?.role === "super_admin" || user?.role === "admin";
const stepDone = useMemo( const stepDone = useMemo(
() => ({ () => ({
@ -1299,7 +1302,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
if (!eventsOrgId) { if (!eventsOrgId) {
setApiEvents([]); setApiEvents([]);
setEventsError( setEventsError(
user?.role === "super_admin" isOrgSuperAdmin
? "Assign an organisation to your account to load events for that tenant." ? "Assign an organisation to your account to load events for that tenant."
: "Your profile has no organisation.", : "Your profile has no organisation.",
); );
@ -1314,7 +1317,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
}) })
.catch(() => setEventsError("Failed to load events")) .catch(() => setEventsError("Failed to load events"))
.finally(() => setEventsLoading(false)); .finally(() => setEventsLoading(false));
}, [eventsOrgId, user?.role]); }, [eventsOrgId, isOrgSuperAdmin]);
useEffect(() => { useEffect(() => {
fetchEvents(); fetchEvents();
@ -1606,11 +1609,13 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
</Popover> </Popover>
{/* Create Event CTA */} {/* Create Event CTA */}
{canWriteEvents ? (
<button <button
onClick={() => setShowCreatePanel(true)} onClick={() => setShowCreatePanel(true)}
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}> style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={14} /> Create Event <Plus size={14} /> Create Event
</button> </button>
) : null}
</div> </div>
</div> </div>
@ -1660,6 +1665,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: "16px", maxWidth: "360px", marginLeft: "auto", marginRight: "auto" }}> <p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: "16px", maxWidth: "360px", marginLeft: "auto", marginRight: "auto" }}>
Create an event to see it listed here with stats, filters, and calendar view. Create an event to see it listed here with stats, filters, and calendar view.
</p> </p>
{canWriteEvents ? (
<button <button
type="button" type="button"
onClick={() => setShowCreatePanel(true)} onClick={() => setShowCreatePanel(true)}
@ -1671,6 +1677,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
> >
<Plus size={16} /> Create your first event <Plus size={16} /> Create your first event
</button> </button>
) : null}
</div> </div>
)} )}

View File

@ -0,0 +1,353 @@
import React, { useCallback, useEffect, useState } from "react";
import { Trash2, X } from "lucide-react";
import { rolesApi, type FormGrant, type Role } from "../../../services/api/roles";
import { usersApi } from "../../../services/api/users";
import type { User } from "../../../services/api/types";
import { usePermission } from "../../hooks/usePermission";
const GRANT_ACTIONS = ["read", "write", "delete"] as const;
export function ShareFormPanel({ formId, onClose }: { formId: string; onClose: () => void }) {
const { canModule } = usePermission();
const [visibility, setVisibility] = useState<"private" | "org">("org");
const [grants, setGrants] = useState<FormGrant[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [subjectType, setSubjectType] = useState<"user" | "role">("user");
const [subjectId, setSubjectId] = useState("");
const [newActions, setNewActions] = useState<string[]>(["read"]);
const load = useCallback(async () => {
setLoading(true);
setError("");
const [gRes, uRes, rRes] = await Promise.all([
rolesApi.listFormGrants(formId),
usersApi.list(),
rolesApi.list(),
]);
if (gRes.error) {
setError(gRes.error.message || "Failed to load sharing settings");
setLoading(false);
return;
}
if (gRes.data?.defaultVisibility) {
setVisibility(gRes.data.defaultVisibility as "private" | "org");
}
setGrants(gRes.data?.grants ?? []);
if (uRes.data?.users) setUsers(uRes.data.users);
if (rRes.data?.roles) setRoles(rRes.data.roles);
setLoading(false);
}, [formId]);
useEffect(() => {
void load();
}, [load]);
const handleVisibilityChange = async (v: "private" | "org") => {
setVisibility(v);
setBusy(true);
setError("");
const res = await rolesApi.updateFormVisibility(formId, v);
if (res.error) {
setError(res.error.message || "Failed to update visibility");
void load();
}
setBusy(false);
};
const toggleNewAction = (action: string) => {
setNewActions((prev) =>
prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action],
);
};
const handleAddGrant = async () => {
if (!subjectId) {
setError("Select a user or role");
return;
}
if (!newActions.length) {
setError("Select at least one permission");
return;
}
setBusy(true);
setError("");
const res = await rolesApi.createFormGrant(formId, {
subjectType,
subjectId,
actions: newActions,
});
if (res.error) {
setError(res.error.message || "Failed to add grant");
setBusy(false);
return;
}
setSubjectId("");
setNewActions(["read"]);
await load();
setBusy(false);
};
const handleRemoveGrant = async (grantId: string) => {
setBusy(true);
setError("");
const res = await rolesApi.removeFormGrant(formId, grantId);
if (res.error) {
setError(res.error.message || "Failed to remove grant");
} else {
await load();
}
setBusy(false);
};
if (!canModule("forms", "write")) return null;
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.4)",
zIndex: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
};
const panelStyle: React.CSSProperties = {
background: "var(--card)",
padding: 20,
borderRadius: 12,
minWidth: 360,
maxWidth: 480,
width: "100%",
maxHeight: "90vh",
overflow: "auto",
border: "1px solid var(--border)",
};
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: "var(--text-xs)",
color: "var(--muted)",
marginBottom: 4,
};
const inputStyle: React.CSSProperties = {
width: "100%",
padding: 8,
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
fontSize: "var(--text-sm)",
};
return (
<div style={overlayStyle} onClick={onClose}>
<div style={panelStyle} onClick={(e) => e.stopPropagation()}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<h3 style={{ margin: 0, fontSize: "var(--text-base)" }}>Share form</h3>
<button
type="button"
onClick={onClose}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", padding: 4 }}
aria-label="Close"
>
<X size={18} />
</button>
</div>
{error && (
<p style={{ color: "var(--destructive)", fontSize: 13, margin: "0 0 12px" }}>{error}</p>
)}
{loading ? (
<p style={{ color: "var(--muted)", fontSize: 13 }}>Loading…</p>
) : (
<>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>Default visibility</label>
<select
value={visibility}
disabled={busy}
onChange={(e) => void handleVisibilityChange(e.target.value as "private" | "org")}
style={inputStyle}
>
<option value="org">Organisation visible — anyone with forms access can see this form</option>
<option value="private">Private — only you and people granted below</option>
</select>
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>People with access</label>
{grants.length === 0 ? (
<p style={{ fontSize: 13, color: "var(--muted)", margin: "8px 0 0" }}>
No explicit grants. {visibility === "org" ? "Form is visible to your organisation." : "Only the creator can access this form."}
</p>
) : (
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0 }}>
{grants.map((g) => (
<li
key={g._id}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 0",
borderBottom: "1px solid var(--border)",
fontSize: 13,
}}
>
<span>
<strong>{g.subjectLabel || g.subjectId}</strong>
<span style={{ color: "var(--muted)", marginLeft: 6 }}>
({g.subjectType}) — {(g.actions || []).join(", ")}
</span>
</span>
<button
type="button"
disabled={busy}
onClick={() => void handleRemoveGrant(g._id)}
style={{
background: "none",
border: "none",
cursor: busy ? "not-allowed" : "pointer",
color: "var(--destructive)",
padding: 4,
}}
title="Remove access"
>
<Trash2 size={14} />
</button>
</li>
))}
</ul>
)}
</div>
<div
style={{
padding: 12,
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--background)",
marginBottom: 16,
}}
>
<p style={{ margin: "0 0 10px", fontSize: 13, fontWeight: 600 }}>Add access</p>
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
<button
type="button"
disabled={busy}
onClick={() => {
setSubjectType("user");
setSubjectId("");
}}
style={{
flex: 1,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid var(--border)",
background: subjectType === "user" ? "var(--primary)" : "transparent",
color: subjectType === "user" ? "#fff" : "var(--foreground)",
cursor: "pointer",
fontSize: 12,
}}
>
User
</button>
<button
type="button"
disabled={busy}
onClick={() => {
setSubjectType("role");
setSubjectId("");
}}
style={{
flex: 1,
padding: "6px 10px",
borderRadius: 6,
border: "1px solid var(--border)",
background: subjectType === "role" ? "var(--primary)" : "transparent",
color: subjectType === "role" ? "#fff" : "var(--foreground)",
cursor: "pointer",
fontSize: 12,
}}
>
Role
</button>
</div>
<select
value={subjectId}
disabled={busy}
onChange={(e) => setSubjectId(e.target.value)}
style={{ ...inputStyle, marginBottom: 10 }}
>
<option value="">Select {subjectType === "user" ? "user" : "role"}…</option>
{subjectType === "user"
? users.map((u) => (
<option key={u._id} value={u._id}>
{[u.firstName, u.lastName].filter(Boolean).join(" ") || u.email} ({u.email})
</option>
))
: roles.map((r) => (
<option key={r._id} value={r._id}>
{r.name}{r.isSystem ? " (system)" : ""}
</option>
))}
</select>
<div style={{ display: "flex", gap: 12, marginBottom: 10, flexWrap: "wrap" }}>
{GRANT_ACTIONS.map((a) => (
<label key={a} style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, cursor: "pointer" }}>
<input
type="checkbox"
checked={newActions.includes(a)}
disabled={busy}
onChange={() => toggleNewAction(a)}
/>
{a}
</label>
))}
</div>
<button
type="button"
disabled={busy || !subjectId}
onClick={() => void handleAddGrant()}
style={{
padding: "8px 14px",
borderRadius: 8,
border: "none",
background: "var(--primary)",
color: "#fff",
cursor: busy || !subjectId ? "not-allowed" : "pointer",
fontSize: 13,
opacity: busy || !subjectId ? 0.6 : 1,
}}
>
Add grant
</button>
</div>
</>
)}
<button
type="button"
onClick={onClose}
style={{
padding: "8px 16px",
borderRadius: 8,
border: "1px solid var(--border)",
background: "transparent",
cursor: "pointer",
fontSize: 13,
}}
>
Close
</button>
</div>
</div>
);
}

View File

@ -4,7 +4,7 @@ import {
Shield, Image, Layout, Tag, Mail, Shield, Image, Layout, Tag, Mail,
Plus, Search, X, MoreHorizontal, Upload, Plus, Search, X, MoreHorizontal, Upload,
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
Link2, List, Check, Link2, List, Check, Share2,
} from "lucide-react"; } from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
@ -53,6 +53,8 @@ import { ImportParticipantsPanel } from "../panels/ImportParticipantsPanel";
import { EditParticipantPanel } from "../panels/EditParticipantPanel"; import { EditParticipantPanel } from "../panels/EditParticipantPanel";
import { CreateFormPanel } from "../panels/CreateFormPanel"; import { CreateFormPanel } from "../panels/CreateFormPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel"; import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
import { ShareFormPanel } from "../forms/ShareFormPanel";
import { usePermission } from "../../hooks/usePermission";
import { UploadFilePanel } from "../panels/UploadFilePanel"; import { UploadFilePanel } from "../panels/UploadFilePanel";
import { CreateCollectionPanel } from "../panels/CreateCollectionPanel"; import { CreateCollectionPanel } from "../panels/CreateCollectionPanel";
import { MessagingEmailPage } from "../messaging/MessagingEmailPage"; import { MessagingEmailPage } from "../messaging/MessagingEmailPage";
@ -1866,14 +1868,18 @@ function cloneSettingsForDuplicate(settings: unknown): unknown {
export function FormsPage({ subPage }: { subPage?: string }) { export function FormsPage({ subPage }: { subPage?: string }) {
const { user } = useAuth(); const { user } = useAuth();
const { canModule } = usePermission();
const orgId = portfolioListOrganizationId(user); const orgId = portfolioListOrganizationId(user);
const canManageForms = Boolean(orgId) || user?.role === "super_admin"; const canReadForms = canModule("forms", "read");
const canWriteForms = canModule("forms", "write");
const canDeleteForms = canModule("forms", "delete");
const filterType = subPage === "forms-feedback" ? "Feedback" const filterType = subPage === "forms-feedback" ? "Feedback"
: subPage === "forms-registration" ? "Registration" : subPage === "forms-registration" ? "Registration"
: subPage === "forms-other" ? "Other" : subPage === "forms-other" ? "Other"
: null; : null;
const [forms, setForms] = useState<FormWithSubmissionCount[]>([]); const [forms, setForms] = useState<FormWithSubmissionCount[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [hovered, setHovered] = useState<string | null>(null); const [hovered, setHovered] = useState<string | null>(null);
const [showFeedback, setShowFeedback] = useState(false); const [showFeedback, setShowFeedback] = useState(false);
const [showReg, setShowReg] = useState(false); const [showReg, setShowReg] = useState(false);
@ -1883,6 +1889,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
const [formActionBusy, setFormActionBusy] = useState<string | null>(null); const [formActionBusy, setFormActionBusy] = useState<string | null>(null);
const [formPageMsg, setFormPageMsg] = useState<string | null>(null); const [formPageMsg, setFormPageMsg] = useState<string | null>(null);
const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null); const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null);
const [shareFormTarget, setShareFormTarget] = useState<FormWithSubmissionCount | null>(null);
const [copiedFormId, setCopiedFormId] = useState<string | null>(null); const [copiedFormId, setCopiedFormId] = useState<string | null>(null);
const publicFormPageUrl = (f: FormWithSubmissionCount) => { const publicFormPageUrl = (f: FormWithSubmissionCount) => {
@ -1899,7 +1906,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
const copyFormPublicLink = (f: FormWithSubmissionCount) => { const copyFormPublicLink = (f: FormWithSubmissionCount) => {
const url = publicFormPageUrl(f); const url = publicFormPageUrl(f);
void navigator.clipboard.writeText(url).then(() => { void navigator.clipboard.writeText(url).then(() => {
setCopiedFormId(id); setCopiedFormId(f._id);
setFormPageMsg(FORM_LINK_COPIED_MSG); setFormPageMsg(FORM_LINK_COPIED_MSG);
window.setTimeout(() => { window.setTimeout(() => {
setCopiedFormId(null); setCopiedFormId(null);
@ -1911,7 +1918,8 @@ export function FormsPage({ subPage }: { subPage?: string }) {
}; };
const fetchForms = useCallback(async () => { const fetchForms = useCallback(async () => {
if (!canManageForms) { setLoadError(null);
if (!canReadForms) {
setForms([]); setForms([]);
return; return;
} }
@ -1923,12 +1931,14 @@ export function FormsPage({ subPage }: { subPage?: string }) {
try { try {
const list = await formsApi.list({ organization: orgId }); const list = await formsApi.list({ organization: orgId });
setForms(list); setForms(list);
} catch { } catch (err) {
setForms([]); setForms([]);
const msg = err instanceof Error && err.message ? err.message : "Could not load forms.";
setLoadError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [canManageForms, orgId]); }, [canReadForms, orgId]);
useEffect(() => { useEffect(() => {
fetchForms(); fetchForms();
@ -2026,7 +2036,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
{pageTitle} {pageTitle}
</h1> </h1>
</div> </div>
{canManageForms ? ( {canWriteForms ? (
<button onClick={openCreate} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}> <button onClick={openCreate} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Create Form <Plus size={13} /> Create Form
</button> </button>
@ -2039,7 +2049,11 @@ export function FormsPage({ subPage }: { subPage?: string }) {
</p> </p>
) : null} ) : null}
{!canManageForms ? ( {!canReadForms ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Access denied" description="You do not have permission to view forms." />
</div>
) : !orgId ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}> <div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." /> <EmptyState icon={<FileText size={40} />} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
</div> </div>
@ -2047,6 +2061,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}> <div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} /> <EmptyState icon={<FileText size={40} />} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} />
</div> </div>
) : loadError ? (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="Could not load forms" description={loadError} cta="Try again" onCta={() => void fetchForms()} />
</div>
) : ( ) : (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}> <div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", gap: "6px" }}> <div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", gap: "6px" }}>
@ -2056,6 +2074,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
</div> </div>
{loading ? ( {loading ? (
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading forms…</div> <div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading forms…</div>
) : filteredForms.length === 0 ? (
<div style={{ padding: "32px" }}>
<EmptyState icon={<FileText size={40} />} title="No forms found" description={`There are no ${pageTitle.toLowerCase()} to show for this organization.`} />
</div>
) : ( ) : (
filteredForms.map(f => { filteredForms.map(f => {
const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft"; const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft";
@ -2084,26 +2106,41 @@ export function FormsPage({ subPage }: { subPage?: string }) {
> >
{copiedFormId === fid ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />} {copiedFormId === fid ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
</button> </button>
{canReadForms ? (
<button type="button" title="Submissions" onClick={() => setSubmissionsPanelForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }} <button type="button" title="Submissions" onClick={() => setSubmissionsPanelForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<List size={12} /> <List size={12} />
</button> </button>
) : null}
{canWriteForms ? (
<button type="button" title="Edit" onClick={() => openEdit(fid)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }} <button type="button" title="Edit" onClick={() => openEdit(fid)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Pencil size={12} /> <Pencil size={12} />
</button> </button>
) : null}
{canWriteForms ? (
<button type="button" title="Share" onClick={() => setShareFormTarget(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Share2 size={12} />
</button>
) : null}
{canWriteForms ? (
<button type="button" title="Duplicate" onClick={() => void duplicateForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }} <button type="button" title="Duplicate" onClick={() => void duplicateForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Copy size={12} /> <Copy size={12} />
</button> </button>
) : null}
{canDeleteForms ? (
<button type="button" title="Delete" onClick={() => { setFormPageMsg(null); setFormDeleteTarget(f); }} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }} <button type="button" title="Delete" onClick={() => { setFormPageMsg(null); setFormDeleteTarget(f); }} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}>
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
) : null}
</div> </div>
</div> </div>
); );
@ -2123,15 +2160,18 @@ export function FormsPage({ subPage }: { subPage?: string }) {
) : null} ) : null}
<Footer /> <Footer />
{showFeedback && canManageForms && ( {showFeedback && canWriteForms && (
<CreateFormPanel formId={editFormId ?? undefined} mode="feedback" onClose={() => { setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} /> <CreateFormPanel formId={editFormId ?? undefined} mode="feedback" onClose={() => { setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)} )}
{showReg && canManageForms && ( {showReg && canWriteForms && (
<CreateFormPanel formId={editFormId ?? undefined} mode="registration" onClose={() => { setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} /> <CreateFormPanel formId={editFormId ?? undefined} mode="registration" onClose={() => { setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)} )}
{showOther && canManageForms && ( {showOther && canWriteForms && (
<CreateFormPanel formId={editFormId ?? undefined} mode="other" onClose={() => { setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} /> <CreateFormPanel formId={editFormId ?? undefined} mode="other" onClose={() => { setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)} )}
{shareFormTarget ? (
<ShareFormPanel formId={shareFormTarget._id} onClose={() => setShareFormTarget(null)} />
) : null}
{submissionsPanelForm ? ( {submissionsPanelForm ? (
<FormSubmissionsPanel <FormSubmissionsPanel
formId={submissionsPanelForm._id} formId={submissionsPanelForm._id}
@ -2396,6 +2436,11 @@ function userInitials(u: User): string {
} }
export function UserManagementPage() { export function UserManagementPage() {
const { setActiveNav } = useAppNav();
const { canModule } = usePermission();
const canReadUsers = canModule("user_management", "read");
const canWriteUsers = canModule("user_management", "write");
const canDeleteUsers = canModule("user_management", "delete");
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hovered, setHovered] = useState<string | null>(null); const [hovered, setHovered] = useState<string | null>(null);
@ -2420,15 +2465,28 @@ export function UserManagementPage() {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}> <div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{!canReadUsers ? (
<div style={{ padding: 32, textAlign: "center", color: "var(--muted)" }}>You do not have permission to view user management.</div>
) : (
<>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}> <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
<div> <div>
<p style={labelStyle}>Portfolio</p> <p style={labelStyle}>Portfolio</p>
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>User Management</h1> <h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>User Management</h1>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{users.length} users across the platform</p> <p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{users.length} users across the platform</p>
</div> </div>
<button onClick={() => { setEditUser(null); setShowInvite(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}> <div style={{ display: "flex", gap: 8 }}>
<Plus size={13} /> Invite User {canModule("user_management", "read") ? (
</button> <button type="button" onClick={() => setActiveNav("roles-management")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--secondary)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: "pointer", fontSize: "var(--text-sm)" }}>
<Shield size={13} /> Manage Roles
</button>
) : null}
{canWriteUsers ? (
<button onClick={() => { setEditUser(null); setShowInvite(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
<Plus size={13} /> Invite User
</button>
) : null}
</div>
</div> </div>
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}> <div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
@ -2439,9 +2497,11 @@ export function UserManagementPage() {
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading users…</div> <div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading users…</div>
) : ( ) : (
users.map((u, idx) => { users.map((u, idx) => {
const rs = ROLE_STYLES[u.role] ?? { bg: "var(--secondary)", color: "var(--muted)" }; const rs = ROLE_STYLES[u.membershipRole ?? u.role] ?? ROLE_STYLES[u.role] ?? { bg: "var(--secondary)", color: "var(--muted)" };
const roleLabel = u.customRole?.name ?? ROLE_LABELS[u.membershipRole ?? u.role] ?? u.role;
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length]; const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
const eventsLabel = u.role === "super_admin" || u.role === "admin" ? "All" : "—"; const assigned = u.assignedEvents?.length ?? 0;
const eventsLabel = assigned > 0 ? String(assigned) : (u.role === "super_admin" || u.role === "admin" ? "All" : "—");
return ( return (
<div key={u._id} <div key={u._id}
onMouseEnter={() => setHovered(u._id)} onMouseEnter={() => setHovered(u._id)}
@ -2455,14 +2515,21 @@ export function UserManagementPage() {
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{userDisplayName(u)}</span> <span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{userDisplayName(u)}</span>
</div> </div>
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{u.email}</span> <span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{u.email}</span>
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: rs.bg, color: rs.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{ROLE_LABELS[u.role] ?? u.role}</span> <span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: rs.bg, color: rs.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{roleLabel}</span>
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{eventsLabel}</span> <span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{eventsLabel}</span>
<div style={{ display: "flex", gap: "4px", opacity: hovered === u._id ? 1 : 0, transition: "opacity 0.15s" }}> <div style={{ display: "flex", gap: "4px", opacity: hovered === u._id ? 1 : 0, transition: "opacity 0.15s" }}>
{canWriteUsers ? (
<button type="button" onClick={() => { setEditUser(u); setShowInvite(true); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }} <button type="button" onClick={() => { setEditUser(u); setShowInvite(true); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}> onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
<Pencil size={11} /> <Pencil size={11} />
</button> </button>
) : null}
{canDeleteUsers ? (
<button type="button" onClick={() => { if (window.confirm(`Remove ${userDisplayName(u)}?`)) void usersApi.remove(u._id).then(() => fetchUsers()); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
<Trash2 size={11} />
</button>
) : null}
</div> </div>
</div> </div>
); );
@ -2470,7 +2537,9 @@ export function UserManagementPage() {
)} )}
</div> </div>
<Footer /> <Footer />
{showInvite && <InviteUserPanel editUser={editUser} onClose={() => { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />} {showInvite && canWriteUsers && <InviteUserPanel editUser={editUser} onClose={() => { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
</>
)}
</div> </div>
); );
} }

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ExternalLink, Eye, Rocket, Save } from 'lucide-react'; import { ExternalLink, Eye, Rocket, Save } from 'lucide-react';
import { import {
PanelShell, PanelShell,
@ -9,6 +9,7 @@ import {
HelperText, HelperText,
} from '../panels/PanelShell'; } from '../panels/PanelShell';
import { import {
sessionsApi,
staticMicrositeApi, staticMicrositeApi,
staticMicrositePublicUrl, staticMicrositePublicUrl,
type EventStaticMicrosite, type EventStaticMicrosite,
@ -18,6 +19,9 @@ import {
import { TemplatePicker } from './TemplatePicker'; import { TemplatePicker } from './TemplatePicker';
import { MicrositeConfigForm } from './MicrositeConfigForm'; import { MicrositeConfigForm } from './MicrositeConfigForm';
import { mergeConfig } from './configUtils'; import { mergeConfig } from './configUtils';
import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
const SCHEDULE_SECTION_ID = 'event_schedule';
export function ManageStaticMicrositePanel({ export function ManageStaticMicrositePanel({
eventId, eventId,
@ -45,13 +49,17 @@ export function ManageStaticMicrositePanel({
const [config, setConfig] = useState<StaticMicrositeConfig>({ sections: {} }); const [config, setConfig] = useState<StaticMicrositeConfig>({ sections: {} });
const [existing, setExisting] = useState<EventStaticMicrosite | null>(null); const [existing, setExisting] = useState<EventStaticMicrosite | null>(null);
const [publishedUrl, setPublishedUrl] = useState<string | null>(null); const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
const [schemaLoading, setSchemaLoading] = useState(false);
const [scheduleSyncing, setScheduleSyncing] = useState(false);
const scheduleAutoFetchedRef = useRef(false);
const configureOpenedRef = useRef(false);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const [tplRes, siteRes] = await Promise.all([ const [tplRes, siteRes] = await Promise.all([
staticMicrositeApi.listTemplates(), staticMicrositeApi.listTemplates(eventId),
staticMicrositeApi.getForEvent(eventId), staticMicrositeApi.getForEvent(eventId),
]); ]);
@ -71,11 +79,16 @@ export function ManageStaticMicrositePanel({
if (tpl) { if (tpl) {
setSelectedTemplate(tpl); setSelectedTemplate(tpl);
const detail = await staticMicrositeApi.getTemplate(tpl._id); setSchemaLoading(true);
const sch = detail.data?.schema; try {
if (sch) { const detail = await staticMicrositeApi.getTemplate(tpl._id, eventId);
if (detail.error) throw new Error(detail.error);
const sch = detail.data?.schema;
if (!sch) throw new Error('Template configuration schema could not be loaded');
setSchema(sch); setSchema(sch);
setConfig(mergeConfig(sch, site.config)); setConfig(mergeConfig(sch, site.config));
} finally {
setSchemaLoading(false);
} }
} }
} }
@ -90,6 +103,15 @@ export function ManageStaticMicrositePanel({
void load(); void load();
}, [load]); }, [load]);
useEffect(() => {
if (loading || !existing || !schema || configureOpenedRef.current) return;
const hasConfig = Object.keys(existing.config?.sections || {}).length > 0;
if (hasConfig) {
configureOpenedRef.current = true;
acc.skipTo(3);
}
}, [loading, existing, schema]);
useEffect(() => { useEffect(() => {
if (!subdomain || subdomain.length < 3) { if (!subdomain || subdomain.length < 3) {
setSubdomainCheck(null); setSubdomainCheck(null);
@ -107,11 +129,74 @@ export function ManageStaticMicrositePanel({
const selectTemplate = async (t: MicrositeV2Template) => { const selectTemplate = async (t: MicrositeV2Template) => {
setSelectedTemplate(t); setSelectedTemplate(t);
const res = await staticMicrositeApi.getTemplate(t._id); setError('');
const sch = res.data?.schema; setSchema(null);
if (sch) { setSchemaLoading(true);
try {
const res = await staticMicrositeApi.getTemplate(t._id, eventId);
if (res.error) throw new Error(res.error);
const sch = res.data?.schema;
if (!sch) throw new Error('Template configuration schema could not be loaded');
setSchema(sch); setSchema(sch);
setConfig(mergeConfig(sch, config)); setConfig(mergeConfig(sch, config));
acc.advance(2);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load template configuration');
setSchema(null);
} finally {
setSchemaLoading(false);
}
};
const applyAgendaToSchedule = useCallback(
(mergeWithExisting: boolean) => {
return sessionsApi.list(eventId).then((res) => {
if (res.error) throw new Error(res.error);
const sessions = res.data?.sessions || [];
setConfig((prev) => {
const sections = prev.sections || {};
const sched = (sections[SCHEDULE_SECTION_ID] as Record<string, unknown>) || {};
const existingDays = (Array.isArray(sched.days) ? sched.days : []) as ScheduleDay[];
// On auto pre-fetch, never overwrite a schedule that already has days.
if (!mergeWithExisting && existingDays.length > 0) return prev;
return {
...prev,
sections: {
...sections,
[SCHEDULE_SECTION_ID]: {
...sched,
show: sched.show !== false,
days: mergeScheduleDays(mergeWithExisting ? existingDays : [], sessions),
},
},
};
});
});
},
[eventId],
);
// Pre-fetch the schedule from the event agenda the first time a schema with a
// schedule section is loaded, but only when no schedule has been saved yet.
useEffect(() => {
if (!schema) return;
const hasScheduleSection = (schema.sections || []).some((s) => s.id === SCHEDULE_SECTION_ID);
if (!hasScheduleSection || scheduleAutoFetchedRef.current) return;
scheduleAutoFetchedRef.current = true;
void applyAgendaToSchedule(false).catch(() => {
/* silent: pre-fetch is best-effort */
});
}, [schema, applyAgendaToSchedule]);
const handleSyncSchedule = async () => {
setScheduleSyncing(true);
setError('');
try {
await applyAgendaToSchedule(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to sync agenda');
} finally {
setScheduleSyncing(false);
} }
}; };
@ -301,8 +386,18 @@ export function ManageStaticMicrositePanel({
config={config} config={config}
onChange={setConfig} onChange={setConfig}
eventId={eventId} eventId={eventId}
onSyncSchedule={() => void handleSyncSchedule()}
scheduleSyncing={scheduleSyncing}
/> />
</AccordionSection> </AccordionSection>
) : schemaLoading ? (
<p style={{ color: 'var(--muted)', fontSize: 'var(--text-sm)', marginTop: '8px' }}>
Loading template configuration…
</p>
) : selectedTemplate && error ? (
<p style={{ color: 'var(--destructive)', fontSize: 'var(--text-sm)', marginTop: '8px' }}>
Could not load configuration for this template. Check the error above and try again.
</p>
) : null} ) : null}
<div style={{ display: 'flex', gap: '8px', marginTop: '20px', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '20px', flexWrap: 'wrap' }}>

View File

@ -1,5 +1,7 @@
import React, { useRef } from 'react'; import React, { useRef, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react'; import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
const SCHEDULE_SECTION_ID = 'event_schedule';
import { uploadMediaFile } from '../../../services/api/media'; import { uploadMediaFile } from '../../../services/api/media';
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types'; import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
import { Field, Label, HelperText } from '../panels/PanelShell'; import { Field, Label, HelperText } from '../panels/PanelShell';
@ -30,7 +32,7 @@ function FieldInput({
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = React.useState(false); const [uploading, setUploading] = React.useState(false);
const handleImage = async (file: File) => { const handleUpload = async (file: File) => {
setUploading(true); setUploading(true);
try { try {
const res = await uploadMediaFile(file, { eventId, module: 'microsite-v2' }); const res = await uploadMediaFile(file, { eventId, module: 'microsite-v2' });
@ -56,6 +58,7 @@ function FieldInput({
<textarea <textarea
rows={field.type === 'richtext' ? 6 : 3} rows={field.type === 'richtext' ? 6 : 3}
value={String(value ?? '')} value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
style={{ ...inputStyle, resize: 'vertical' }} style={{ ...inputStyle, resize: 'vertical' }}
/> />
@ -95,7 +98,7 @@ function FieldInput({
) : null} ) : null}
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => { <input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];
if (f) void handleImage(f); if (f) void handleUpload(f);
e.target.value = ''; e.target.value = '';
}} /> }} />
<button <button
@ -113,6 +116,36 @@ function FieldInput({
</button> </button>
</div> </div>
); );
case 'video':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{value ? (
<video
src={String(value)}
controls
style={{ maxWidth: '100%', maxHeight: '160px', borderRadius: 'var(--radius-button)', background: '#000' }}
/>
) : null}
<input ref={fileRef} type="file" accept="video/*" hidden onChange={(e) => {
const f = e.target.files?.[0];
if (f) void handleUpload(f);
e.target.value = '';
}} />
<button
type="button"
disabled={uploading}
onClick={() => fileRef.current?.click()}
style={{
...inputStyle,
cursor: uploading ? 'wait' : 'pointer',
width: 'auto',
alignSelf: 'flex-start',
}}
>
{uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
</button>
</div>
);
case 'array': case 'array':
return ( return (
<ArrayFieldEditor <ArrayFieldEditor
@ -127,6 +160,7 @@ function FieldInput({
<input <input
type={field.type === 'url' ? 'url' : 'text'} type={field.type === 'url' ? 'url' : 'text'}
value={String(value ?? '')} value={String(value ?? '')}
placeholder={field.placeholder}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
style={inputStyle} style={inputStyle}
/> />
@ -226,11 +260,15 @@ function SectionEditor({
sectionData, sectionData,
onChange, onChange,
eventId, eventId,
onSync,
syncing,
}: { }: {
section: MicrositeV2SectionSchema; section: MicrositeV2SectionSchema;
sectionData: Record<string, unknown>; sectionData: Record<string, unknown>;
onChange: (data: Record<string, unknown>) => void; onChange: (data: Record<string, unknown>) => void;
eventId: string; eventId: string;
onSync?: () => void;
syncing?: boolean;
}) { }) {
const show = sectionData.show !== false; const show = sectionData.show !== false;
@ -247,6 +285,15 @@ function SectionEditor({
return sectionData[key]; return sectionData[key];
}; };
const isFieldVisible = (field: MicrositeV2FieldSchema) => {
if (!field.showIf) return true;
const current = getField(field.showIf.field);
const expected = field.showIf.equals;
return Array.isArray(expected)
? expected.includes(String(current))
: String(current) === String(expected);
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)', fontWeight: 600 }}> <label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)', fontWeight: 600 }}>
@ -257,8 +304,46 @@ function SectionEditor({
/> />
Show {section.label} section Show {section.label} section
</label> </label>
{show && onSync ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
padding: '10px 12px',
borderRadius: 'var(--radius-button)',
border: '1px dashed var(--border)',
background: 'color-mix(in srgb, var(--primary) 6%, transparent)',
}}
>
<button
type="button"
disabled={syncing}
onClick={onSync}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: 'var(--radius-button)',
border: '1px solid var(--primary)',
background: 'transparent',
color: 'var(--primary)',
cursor: syncing ? 'wait' : 'pointer',
fontSize: 'var(--text-sm)',
fontWeight: 600,
alignSelf: 'flex-start',
}}
>
<RefreshCw size={14} style={syncing ? { animation: 'msv2-spin 1s linear infinite' } : undefined} />
{syncing ? 'Syncing…' : 'Sync from event agenda'}
</button>
<style>{`@keyframes msv2-spin { from { transform:rotate(0deg) } to { transform:rotate(360deg) } }`}</style>
<HelperText text="Pulls sessions from the event agenda. New sessions are added and existing synced rows are refreshed; rows you added manually are kept." />
</div>
) : null}
{show {show
? section.fields.map((field) => ( ? section.fields.filter(isFieldVisible).map((field) => (
<div key={field.key}> <div key={field.key}>
<Label text={field.label || field.key} /> <Label text={field.label || field.key} />
<FieldInput <FieldInput
@ -274,11 +359,93 @@ function SectionEditor({
); );
} }
function ConfigAccordion({
title,
badge,
defaultOpen = false,
children,
}: {
title: string;
badge?: React.ReactNode;
defaultOpen?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div
style={{
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
background: 'var(--card, transparent)',
overflow: 'hidden',
}}
>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
padding: '12px 14px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 'var(--text-sm)',
fontWeight: 600,
color: 'var(--foreground)',
textAlign: 'left',
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{title}
{badge}
</span>
<ChevronDown
size={16}
style={{
flexShrink: 0,
transition: 'transform 0.2s ease',
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
color: 'var(--muted)',
}}
/>
</button>
{open ? (
<div style={{ padding: '4px 14px 16px', borderTop: '1px solid var(--border)' }}>{children}</div>
) : null}
</div>
);
}
function HiddenBadge() {
return (
<span
style={{
fontSize: 'var(--text-xs)',
fontWeight: 500,
color: 'var(--muted)',
border: '1px solid var(--border)',
borderRadius: '999px',
padding: '1px 8px',
}}
>
Hidden
</span>
);
}
export function MicrositeConfigForm({ export function MicrositeConfigForm({
schema, schema,
config, config,
onChange, onChange,
eventId, eventId,
onSyncSchedule,
scheduleSyncing,
}: { }: {
schema: { schema: {
meta?: Record<string, unknown>; meta?: Record<string, unknown>;
@ -288,14 +455,16 @@ export function MicrositeConfigForm({
config: StaticMicrositeConfig; config: StaticMicrositeConfig;
onChange: (c: StaticMicrositeConfig) => void; onChange: (c: StaticMicrositeConfig) => void;
eventId: string; eventId: string;
onSyncSchedule?: () => void;
scheduleSyncing?: boolean;
}) { }) {
const theme = config.theme || {}; const theme = config.theme || {};
const sections = config.sections || {}; const sections = config.sections || {};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{schema.meta && Object.keys(schema.meta).length > 0 ? ( {schema.meta && Object.keys(schema.meta).length > 0 ? (
<div> <ConfigAccordion title="Page title" defaultOpen>
<Label text="Page title" /> <Label text="Page title" />
<input <input
type="text" type="text"
@ -304,12 +473,11 @@ export function MicrositeConfigForm({
style={inputStyle} style={inputStyle}
/> />
<HelperText text="Browser tab title for the microsite." /> <HelperText text="Browser tab title for the microsite." />
</div> </ConfigAccordion>
) : null} ) : null}
{(schema.theme?.fields || []).length > 0 ? ( {(schema.theme?.fields || []).length > 0 ? (
<div> <ConfigAccordion title="Theme">
<p style={{ fontSize: 'var(--text-sm)', fontWeight: 600, margin: '0 0 8px' }}>Theme</p>
{schema.theme!.fields!.map((field) => ( {schema.theme!.fields!.map((field) => (
<div key={field.key} style={{ marginBottom: '12px' }}> <div key={field.key} style={{ marginBottom: '12px' }}>
<Label text={field.label || field.key} /> <Label text={field.label || field.key} />
@ -321,31 +489,34 @@ export function MicrositeConfigForm({
/> />
</div> </div>
))} ))}
</div> </ConfigAccordion>
) : null} ) : null}
{schema.sections.map((section) => ( {schema.sections.map((section) => {
<div const sectionData = (sections[section.id] as Record<string, unknown>) || { show: true };
key={section.id} const isShown = sectionData.show !== false;
style={{ return (
borderTop: '1px solid var(--border)', <ConfigAccordion
paddingTop: '16px', key={section.id}
}} title={section.label}
> badge={isShown ? undefined : <HiddenBadge />}
<p style={{ fontSize: 'var(--text-sm)', fontWeight: 600, margin: '0 0 12px' }}>{section.label}</p> >
<SectionEditor <SectionEditor
section={section} section={section}
sectionData={(sections[section.id] as Record<string, unknown>) || { show: true }} sectionData={sectionData}
onChange={(data) => onChange={(data) =>
onChange({ onChange({
...config, ...config,
sections: { ...sections, [section.id]: data }, sections: { ...sections, [section.id]: data },
}) })
} }
eventId={eventId} eventId={eventId}
/> onSync={section.id === SCHEDULE_SECTION_ID ? onSyncSchedule : undefined}
</div> syncing={scheduleSyncing}
))} />
</ConfigAccordion>
);
})}
</div> </div>
); );
} }

View File

@ -1,7 +1,8 @@
import React, { useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Save } from 'lucide-react'; import { Save } from 'lucide-react';
import { PanelShell, Label, Field, HelperText } from '../panels/PanelShell'; import { PanelShell, Label, Field, HelperText } from '../panels/PanelShell';
import { staticMicrositeApi, type MicrositeV2Template } from '../../../services/api'; import { platformAdminApi, staticMicrositeApi, type MicrositeV2Template } from '../../../services/api';
import type { OrganizationRecord } from '../../../services/api/types';
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
width: '100%', width: '100%',
@ -14,6 +15,11 @@ const inputStyle: React.CSSProperties = {
boxSizing: 'border-box', boxSizing: 'border-box',
}; };
function orgIdsFromTemplate(template: MicrositeV2Template | null): string[] {
if (!template?.allowedOrganizations?.length) return [];
return template.allowedOrganizations.map((o) => (typeof o === 'string' ? o : o._id));
}
export function UploadMicrositeV2TemplatePanel({ export function UploadMicrositeV2TemplatePanel({
template, template,
onClose, onClose,
@ -28,6 +34,11 @@ export function UploadMicrositeV2TemplatePanel({
const [slug, setSlug] = useState(template?.slug || ''); const [slug, setSlug] = useState(template?.slug || '');
const [name, setName] = useState(template?.name || ''); const [name, setName] = useState(template?.name || '');
const [description, setDescription] = useState(template?.description || ''); const [description, setDescription] = useState(template?.description || '');
const [visibility, setVisibility] = useState<'public' | 'private'>(template?.visibility || 'public');
const [allowedOrgIds, setAllowedOrgIds] = useState<string[]>(() => orgIdsFromTemplate(template));
const [orgSearch, setOrgSearch] = useState('');
const [orgOptions, setOrgOptions] = useState<OrganizationRecord[]>([]);
const [orgLoading, setOrgLoading] = useState(false);
const [templateHtml, setTemplateHtml] = useState<File | null>(null); const [templateHtml, setTemplateHtml] = useState<File | null>(null);
const [templateJson, setTemplateJson] = useState<File | null>(null); const [templateJson, setTemplateJson] = useState<File | null>(null);
const [thumbnail, setThumbnail] = useState<File | null>(null); const [thumbnail, setThumbnail] = useState<File | null>(null);
@ -35,11 +46,41 @@ export function UploadMicrositeV2TemplatePanel({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const loadOrgs = useCallback(async (search: string) => {
setOrgLoading(true);
try {
const data = await platformAdminApi.listOrganizations({ page: 1, limit: 50, search: search || undefined });
setOrgOptions(data.organizations);
} catch {
setOrgOptions([]);
} finally {
setOrgLoading(false);
}
}, []);
useEffect(() => {
if (visibility !== 'private') return;
const t = window.setTimeout(() => void loadOrgs(orgSearch), 250);
return () => window.clearTimeout(t);
}, [visibility, orgSearch, loadOrgs]);
const toggleOrg = (orgId: string) => {
setAllowedOrgIds((prev) => (prev.includes(orgId) ? prev.filter((id) => id !== orgId) : [...prev, orgId]));
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
if (visibility === 'private' && allowedOrgIds.length === 0) {
throw new Error('Select at least one account for a private template');
}
let templateId = template?._id; let templateId = template?._id;
const visibilityPayload = {
visibility,
allowedOrganizations: visibility === 'private' ? allowedOrgIds : [],
};
if (!isEdit) { if (!isEdit) {
if (!slug.trim() || !name.trim()) { if (!slug.trim() || !name.trim()) {
@ -49,13 +90,15 @@ export function UploadMicrositeV2TemplatePanel({
slug: slug.trim(), slug: slug.trim(),
name: name.trim(), name: name.trim(),
description: description.trim() || undefined, description: description.trim() || undefined,
...visibilityPayload,
}); });
if (createRes.error || !createRes.data) throw new Error(createRes.error || 'Create failed'); if (createRes.error || !createRes.data) throw new Error(createRes.error || 'Create failed');
templateId = createRes.data._id; templateId = createRes.data._id;
} else if (name !== template?.name || description !== template?.description) { } else {
const updateRes = await staticMicrositeApi.updateTemplate(template!._id, { const updateRes = await staticMicrositeApi.updateTemplate(template!._id, {
name: name.trim(), name: name.trim(),
description: description.trim(), description: description.trim(),
...visibilityPayload,
}); });
if (updateRes.error) throw new Error(updateRes.error); if (updateRes.error) throw new Error(updateRes.error);
} }
@ -85,6 +128,14 @@ export function UploadMicrositeV2TemplatePanel({
} }
}; };
const selectedOrgLabels = allowedOrgIds.map((id) => {
const fromOptions = orgOptions.find((o) => o._id === id);
if (fromOptions) return fromOptions.name;
const fromTemplate = template?.allowedOrganizations?.find((o) => (typeof o === 'string' ? o : o._id) === id);
if (fromTemplate && typeof fromTemplate !== 'string') return fromTemplate.name || fromTemplate.email || id;
return id;
});
return ( return (
<PanelShell <PanelShell
title={isEdit ? 'Edit template' : 'New microsite template'} title={isEdit ? 'Edit template' : 'New microsite template'}
@ -127,6 +178,98 @@ export function UploadMicrositeV2TemplatePanel({
</Field> </Field>
</div> </div>
<div>
<Label text="Visibility" />
<Field>
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value as 'public' | 'private')}
style={inputStyle}
>
<option value="public">Public — available to all accounts</option>
<option value="private">Private — selected accounts only</option>
</select>
</Field>
</div>
{visibility === 'private' ? (
<div>
<Label text="Allowed accounts" />
{allowedOrgIds.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
{selectedOrgLabels.map((label, i) => (
<span
key={allowedOrgIds[i]}
style={{
fontSize: 'var(--text-xs)',
padding: '4px 8px',
borderRadius: 'var(--radius-button)',
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
border: '1px solid var(--border)',
}}
>
{label}
<button
type="button"
onClick={() => toggleOrg(allowedOrgIds[i]!)}
style={{ marginLeft: '6px', border: 'none', background: 'none', cursor: 'pointer', color: 'var(--muted)' }}
aria-label="Remove"
>
×
</button>
</span>
))}
</div>
) : null}
<Field>
<input
type="search"
value={orgSearch}
onChange={(e) => setOrgSearch(e.target.value)}
placeholder="Search organizations…"
style={inputStyle}
/>
</Field>
<div
style={{
marginTop: '8px',
maxHeight: '160px',
overflowY: 'auto',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-button)',
}}
>
{orgLoading ? (
<p style={{ padding: '10px', margin: 0, fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>Loading…</p>
) : orgOptions.length === 0 ? (
<p style={{ padding: '10px', margin: 0, fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>No organizations found.</p>
) : (
orgOptions.map((org) => (
<label
key={org._id}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 10px',
cursor: 'pointer',
borderBottom: '1px solid var(--border)',
fontSize: 'var(--text-sm)',
}}
>
<input type="checkbox" checked={allowedOrgIds.includes(org._id)} onChange={() => toggleOrg(org._id)} />
<span>
{org.name}
{org.email ? <span style={{ color: 'var(--muted)', marginLeft: '6px' }}>{org.email}</span> : null}
</span>
</label>
))
)}
</div>
<HelperText text="Only selected organizations will see this template when building event microsites." />
</div>
) : null}
<div> <div>
<Label text={`template.html ${isEdit ? '(optional)' : '(required)'}`} /> <Label text={`template.html ${isEdit ? '(optional)' : '(required)'}`} />
<input type="file" accept=".html,text/html" onChange={(e) => setTemplateHtml(e.target.files?.[0] || null)} /> <input type="file" accept=".html,text/html" onChange={(e) => setTemplateHtml(e.target.files?.[0] || null)} />

View File

@ -0,0 +1,159 @@
import type { ApiSession } from '../../../services/api';
/**
* Maps the event agenda (Session[]) into the microsite "event_schedule" section
* shape, and merges fresh agenda data into an already-configured schedule.
*
* Each synced session carries a hidden `sourceId` (the agenda session _id) and
* each synced day carries a hidden `sourceDateKey` (YYYY-MM-DD). These extra
* fields are ignored by the Handlebars template (which only reads time/type/
* title and label/date) but let us re-sync without losing manual entries.
*/
export interface ScheduleSession {
time: string;
type: string;
title: string;
/** Agenda session _id for synced rows; absent for manually added rows. */
sourceId?: string;
[key: string]: unknown;
}
export interface ScheduleDay {
label: string;
date: string;
/** Calendar day (YYYY-MM-DD) this day was synced from. */
sourceDateKey?: string;
sessions: ScheduleSession[];
[key: string]: unknown;
}
/** YYYY-MM-DD in the local timezone, or '' when the date is missing/invalid. */
export function toDateKey(iso?: string): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** Human-readable date line, e.g. "Wednesday, Jul 8, 2026". */
export function toDateLabel(dateKey: string): string {
if (!dateKey || dateKey === '_nodate') return '';
const d = new Date(`${dateKey}T00:00:00`);
if (Number.isNaN(d.getTime())) return dateKey;
return d.toLocaleDateString(undefined, {
weekday: 'long',
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
/** Formats a start–end range, e.g. "9:00 AM – 10:30 AM". */
export function formatTimeRange(startIso?: string, endIso?: string): string {
const fmt = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
};
const start = fmt(startIso);
const end = fmt(endIso);
if (start && end) return `${start} – ${end}`;
return start || end || '';
}
/** Derives the schedule "type" column: track name, else capitalized session type. */
export function sessionTypeLabel(session: ApiSession): string {
const track = session.track;
if (track && typeof track === 'object' && 'name' in track && track.name) {
return String(track.name);
}
if (session.type) return session.type.charAt(0).toUpperCase() + session.type.slice(1);
return 'Main programme';
}
function mapSession(session: ApiSession): ScheduleSession {
return {
time: formatTimeRange(session.startTime, session.endTime),
type: sessionTypeLabel(session),
title: session.title,
sourceId: session._id,
};
}
function groupByDateKey(sessions: ApiSession[]): Map<string, ApiSession[]> {
const sorted = [...sessions].sort((a, b) => (a.startTime || '').localeCompare(b.startTime || ''));
const groups = new Map<string, ApiSession[]>();
for (const session of sorted) {
const key = toDateKey(session.startTime) || '_nodate';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(session);
}
return groups;
}
/**
* Merges the current agenda into an existing set of schedule days.
*
* - Sessions already present (matched by `sourceId`) are refreshed from the
* agenda (time/type/title) — a sync overrides earlier custom edits on synced
* rows, as requested.
* - New agenda sessions are appended to their matching day (created if needed).
* - Days/sessions that are NOT in the agenda (manually added, or removed from
* the agenda) are kept untouched.
*
* Passing an empty `existingDays` produces a clean build from the agenda.
*/
export function mergeScheduleDays(existingDays: ScheduleDay[], sessions: ApiSession[]): ScheduleDay[] {
const days: ScheduleDay[] = (Array.isArray(existingDays) ? existingDays : []).map((d) => ({
...d,
sessions: Array.isArray(d.sessions) ? d.sessions.map((s) => ({ ...s })) : [],
}));
const bySourceId = new Map<string, ScheduleSession>();
for (const day of days) {
for (const session of day.sessions) {
if (session.sourceId) bySourceId.set(String(session.sourceId), session);
}
}
const groups = groupByDateKey(sessions);
const sortedKeys = [...groups.keys()].sort((a, b) => {
if (a === '_nodate') return 1;
if (b === '_nodate') return -1;
return a.localeCompare(b);
});
let dayCount = days.length;
for (const dateKey of sortedKeys) {
let day = days.find((d) => d.sourceDateKey === dateKey);
if (!day) {
dayCount += 1;
day = {
label: `Day ${String(dayCount).padStart(2, '0')}`,
date: toDateLabel(dateKey),
sourceDateKey: dateKey,
sessions: [],
};
days.push(day);
}
for (const session of groups.get(dateKey)!) {
const existing = bySourceId.get(session._id);
if (existing) {
existing.time = formatTimeRange(session.startTime, session.endTime);
existing.type = sessionTypeLabel(session);
existing.title = session.title;
} else {
const mapped = mapSession(session);
day.sessions.push(mapped);
bySourceId.set(session._id, mapped);
}
}
}
return days;
}

View File

@ -6,9 +6,16 @@ import React, { useState, useEffect, useCallback } from "react";
import { Plus, Trash2, GripVertical } from "lucide-react"; import { Plus, Trash2, GripVertical } from "lucide-react";
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell"; import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { usePermission } from "../../hooks/usePermission";
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api"; import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord } from "../../../services/api"; import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
import { assignUniqueFieldNames } from "../../utils/formFieldNames"; import { assignUniqueFieldNames } from "../../utils/formFieldNames";
import {
EVENT_HEADER_TOGGLE_LABELS,
eventHeaderFromApi,
eventHeaderToApi,
type EventHeaderToggleKey,
} from "../../utils/formEventHeaderDisplay";
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor"; import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
type FormMode = "feedback" | "registration" | "other" | "web-check-in"; type FormMode = "feedback" | "registration" | "other" | "web-check-in";
@ -453,6 +460,12 @@ interface FForm {
confirmationEmail: "Yes" | "No"; confirmationEmail: "Yes" | "No";
onSubmitTags: string; onSubmitTags: string;
onSubmitListId: string; onSubmitListId: string;
headerShowLogo: "Yes" | "No";
headerShowTitle: "Yes" | "No";
headerShowDateRange: "Yes" | "No";
headerShowEventType: "Yes" | "No";
headerShowLocationType: "Yes" | "No";
headerShowVenue: "Yes" | "No";
} }
const newQ = (types: string[]): Question => { const newQ = (types: string[]): Question => {
@ -483,6 +496,12 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
confirmationEmail: "Yes", confirmationEmail: "Yes",
onSubmitTags: "", onSubmitTags: "",
onSubmitListId: "", onSubmitListId: "",
headerShowLogo: "Yes",
headerShowTitle: "Yes",
headerShowDateRange: "Yes",
headerShowEventType: "Yes",
headerShowLocationType: "Yes",
headerShowVenue: "Yes",
}); });
function typeFromApi( function typeFromApi(
@ -702,6 +721,9 @@ export function CreateFormPanel({
onSuccess: () => void; onSuccess: () => void;
}) { }) {
const { user } = useAuth(); const { user } = useAuth();
const { canModule } = usePermission();
const canWriteForms = canModule("forms", "write");
const readOnly = Boolean(formId) && !canWriteForms;
const listOrgId = portfolioListOrganizationId(user); const listOrgId = portfolioListOrganizationId(user);
const isFeedback = mode === "feedback"; const isFeedback = mode === "feedback";
const isReg = mode === "registration"; const isReg = mode === "registration";
@ -780,6 +802,7 @@ export function CreateFormPanel({
: fromApiFields(data.fields, qTypes, loadMode); : fromApiFields(data.fields, qTypes, loadMode);
const s = data.settings ?? {}; const s = data.settings ?? {};
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : ""; const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
const header = eventHeaderFromApi(s.eventHeader);
setForm({ setForm({
name: data.name ?? "", name: data.name ?? "",
description: data.description ?? "", description: data.description ?? "",
@ -794,6 +817,12 @@ export function CreateFormPanel({
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes", confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
onSubmitTags: tags, onSubmitTags: tags,
onSubmitListId: s.onSubmitParticipantListId ?? "", onSubmitListId: s.onSubmitParticipantListId ?? "",
headerShowLogo: header.showLogo,
headerShowTitle: header.showTitle,
headerShowDateRange: header.showDateRange,
headerShowEventType: header.showEventType,
headerShowLocationType: header.showLocationType,
headerShowVenue: header.showVenue,
}); });
setSelectedEventId(eventIdFromFormRef(data.event)); setSelectedEventId(eventIdFromFormRef(data.event));
} catch { } catch {
@ -881,6 +910,14 @@ export function CreateFormPanel({
paymentEnabled: form.paymentEnabled === "Yes", paymentEnabled: form.paymentEnabled === "Yes",
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "", paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
sendConfirmationEmail: form.confirmationEmail === "Yes", sendConfirmationEmail: form.confirmationEmail === "Yes",
eventHeader: eventHeaderToApi({
showLogo: form.headerShowLogo,
showTitle: form.headerShowTitle,
showDateRange: form.headerShowDateRange,
showEventType: form.headerShowEventType,
showLocationType: form.headerShowLocationType,
showVenue: form.headerShowVenue,
}),
...(isReg ...(isReg
? { ? {
onSubmitAddTags: tagArr, onSubmitAddTags: tagArr,
@ -954,9 +991,15 @@ export function CreateFormPanel({
completedCount={acc.done.size} completedCount={acc.done.size}
totalSections={TOTAL} totalSections={TOTAL}
ctaLabel="Save" ctaLabel="Save"
ctaDisabled={!form.name.trim() || !selectedEventId.trim() || submitting || (!formId && orgEvents.length === 0)} ctaDisabled={readOnly || !form.name.trim() || !selectedEventId.trim() || submitting || (!formId && orgEvents.length === 0)}
hidePrimaryCta={readOnly}
onCtaClick={handleSave} onCtaClick={handleSave}
> >
{readOnly ? (
<p style={{ padding: "12px 16px", margin: "0 0 12px", background: "var(--secondary)", borderRadius: 8, fontSize: 13, color: "var(--muted)" }}>
You have read-only access to this form. Fields cannot be edited.
</p>
) : null}
<AccordionSection <AccordionSection
n={1} n={1}
title="Form Details" title="Form Details"
@ -970,7 +1013,7 @@ export function CreateFormPanel({
> >
<Field> <Field>
<Label text="Form Name" required /> <Label text="Form Name" required />
<input style={IS} placeholder={isFeedbackOrOther ? "e.g. Post-Event Feedback 2026" : "e.g. Conference Registration 2026"} value={form.name} onChange={e => set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} /> <input style={IS} placeholder={isFeedbackOrOther ? "e.g. Post-Event Feedback 2026" : "e.g. Conference Registration 2026"} value={form.name} onChange={e => set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} readOnly={readOnly} disabled={readOnly} />
</Field> </Field>
<Field> <Field>
<Label text="Event" required /> <Label text="Event" required />
@ -1151,6 +1194,36 @@ export function CreateFormPanel({
<HelperText text="Shown to respondents after they submit the form." /> <HelperText text="Shown to respondents after they submit the form." />
</Field> </Field>
</Grid> </Grid>
<Field>
<Label text="Event header on public form" />
<HelperText text="Choose which linked event details appear above the form on the public page." />
<Grid cols={2}>
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => {
const formKey = {
showLogo: "headerShowLogo",
showTitle: "headerShowTitle",
showDateRange: "headerShowDateRange",
showEventType: "headerShowEventType",
showLocationType: "headerShowLocationType",
showVenue: "headerShowVenue",
}[key] as keyof FForm;
return (
<Field key={key}>
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
<RadioGroup
name={`header-${key}`}
value={form[formKey] as "Yes" | "No"}
onChange={(v) => set(formKey, v as FForm[typeof formKey])}
options={[
{ label: "Show", value: "Yes" },
{ label: "Hide", value: "No" },
]}
/>
</Field>
);
})}
</Grid>
</Field>
</AccordionSection> </AccordionSection>
</PanelShell> </PanelShell>
); );

View File

@ -8,12 +8,13 @@ import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGr
import { useAuth } from "../../contexts/AuthContext"; import { useAuth } from "../../contexts/AuthContext";
import { usersApi } from "../../../services/api"; import { usersApi } from "../../../services/api";
import { eventsApi, portfolioListOrganizationId } from "../../../services/api"; import { eventsApi, portfolioListOrganizationId } from "../../../services/api";
import { rolesApi, type Role } from "../../../services/api/roles";
import type { Event, User } from "../../../services/api"; import type { Event, User } from "../../../services/api";
const ROLES = [ const SYSTEM_ROLES = [
{ value: "admin", label: "Admin", desc: "Full access to all events and settings." }, { value: "admin", label: "Admin", desc: "Full access to all events and settings." },
{ value: "moderator", label: "Moderator", desc: "Can manage sessions, participants, and messaging." }, { value: "moderator", label: "Moderator", desc: "Can manage sessions, participants, and messaging." },
{ value: "viewer", label: "Viewer", desc: "Can view events and dashboards only." }, { value: "viewer", label: "Viewer", desc: "Can view events and dashboards only." },
]; ];
interface FForm { interface FForm {
@ -22,6 +23,8 @@ interface FForm {
email: string; email: string;
password: string; password: string;
role: string; role: string;
roleMode: "system" | "custom";
customRoleId: string;
assignedEvents: string[]; assignedEvents: string[];
sendInviteNow: "Yes" | "No"; sendInviteNow: "Yes" | "No";
personalMessage: string; personalMessage: string;
@ -33,6 +36,8 @@ const initialForm: FForm = {
email: "", email: "",
password: "", password: "",
role: "moderator", role: "moderator",
roleMode: "system",
customRoleId: "",
assignedEvents: [], assignedEvents: [],
sendInviteNow: "Yes", sendInviteNow: "Yes",
personalMessage: "", personalMessage: "",
@ -42,46 +47,62 @@ function isValidEmail(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim()); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
} }
function formFromUser(editUser: User): FForm {
const customRole = editUser.customRole;
const assigned = editUser.assignedEvents ?? [];
if (customRole && !customRole.isSystem) {
return {
firstName: editUser.firstName ?? "",
lastName: editUser.lastName ?? "",
email: editUser.email ?? "",
password: "",
role: editUser.membershipRole ?? editUser.role ?? "moderator",
roleMode: "custom",
customRoleId: customRole._id,
assignedEvents: assigned,
sendInviteNow: "Yes",
personalMessage: "",
};
}
return {
firstName: editUser.firstName ?? "",
lastName: editUser.lastName ?? "",
email: editUser.email ?? "",
password: "",
role: editUser.membershipRole ?? editUser.role ?? "moderator",
roleMode: "system",
customRoleId: "",
assignedEvents: assigned,
sendInviteNow: "Yes",
personalMessage: "",
};
}
export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: User | null; onClose: () => void; onSuccess?: () => void }) { export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: User | null; onClose: () => void; onSuccess?: () => void }) {
const { user } = useAuth(); const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user); const listOrgId = portfolioListOrganizationId(user);
const isEdit = !!editUser; const isEdit = !!editUser;
const TOTAL = 3; const TOTAL = 3;
const acc = useAccordion(TOTAL); const acc = useAccordion(TOTAL);
const [form, setForm] = useState<FForm>(() => const [form, setForm] = useState<FForm>(() => (editUser ? formFromUser(editUser) : initialForm));
editUser
? {
firstName: editUser.firstName ?? "",
lastName: editUser.lastName ?? "",
email: editUser.email ?? "",
password: "",
role: editUser.role ?? "moderator",
assignedEvents: [],
sendInviteNow: "Yes",
personalMessage: "",
}
: initialForm
);
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({}); const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [eventsLoading, setEventsLoading] = useState(true); const [eventsLoading, setEventsLoading] = useState(true);
const [customRoles, setCustomRoles] = useState<Role[]>([]);
useEffect(() => { useEffect(() => {
if (editUser) { if (editUser) setForm(formFromUser(editUser));
setForm({
firstName: editUser.firstName ?? "",
lastName: editUser.lastName ?? "",
email: editUser.email ?? "",
password: "",
role: editUser.role ?? "moderator",
assignedEvents: [],
sendInviteNow: "Yes",
personalMessage: "",
});
}
}, [editUser?._id]); }, [editUser?._id]);
useEffect(() => {
rolesApi.list().then((r) => {
if (r.data?.roles) {
setCustomRoles(r.data.roles.filter((role) => !role.isSystem));
}
});
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
@ -118,7 +139,10 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
if (isEdit && f.password.length > 0 && f.password.length < 6) return false; if (isEdit && f.password.length > 0 && f.password.length < 6) return false;
return true; return true;
} }
if (n === 2) return true; if (n === 2) {
if (f.roleMode === "custom" && !f.customRoleId) return false;
return true;
}
if (n === 3) return true; if (n === 3) return true;
return true; return true;
}, [isEdit]); }, [isEdit]);
@ -129,21 +153,31 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
if (valid) acc.advance(n); if (valid) acc.advance(n);
}, [form, validateSection, acc]); }, [form, validateSection, acc]);
const buildPayload = useCallback(() => {
const base = {
email: form.email.trim(),
firstName: form.firstName.trim() || undefined,
lastName: form.lastName.trim() || undefined,
assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
};
if (form.roleMode === "custom" && form.customRoleId) {
return { ...base, role: form.role, customRoleId: form.customRoleId };
}
return { ...base, role: form.role };
}, [form]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!validateSection(form, 1)) { if (!validateSection(form, 1) || !validateSection(form, 2)) {
setSectionErrors(prev => ({ ...prev, 1: "Please complete all required fields." })); setSectionErrors(prev => ({ ...prev, 1: "Please complete all required fields.", 2: form.roleMode === "custom" && !form.customRoleId ? "Select a custom role." : "" }));
return; return;
} }
setSubmitting(true); setSubmitting(true);
setSectionErrors({}); setSectionErrors({});
const payload = buildPayload();
if (isEdit && editUser) { if (isEdit && editUser) {
const res = await usersApi.update(editUser._id, { const res = await usersApi.update(editUser._id, {
email: form.email.trim(), ...payload,
firstName: form.firstName.trim() || undefined,
lastName: form.lastName.trim() || undefined,
role: form.role,
...(form.password ? { password: form.password } : {}), ...(form.password ? { password: form.password } : {}),
assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
}); });
setSubmitting(false); setSubmitting(false);
if (res.error) { if (res.error) {
@ -152,12 +186,8 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
} }
} else { } else {
const res = await usersApi.create({ const res = await usersApi.create({
email: form.email.trim(), ...payload,
password: form.password, password: form.password,
firstName: form.firstName.trim() || undefined,
lastName: form.lastName.trim() || undefined,
role: form.role,
assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
}); });
setSubmitting(false); setSubmitting(false);
if (res.error) { if (res.error) {
@ -167,7 +197,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
} }
onSuccess?.(); onSuccess?.();
onClose(); onClose();
}, [form, validateSection, isEdit, editUser, onSuccess, onClose]); }, [form, validateSection, isEdit, editUser, onSuccess, onClose, buildPayload]);
const onSaveLast = useCallback(() => { const onSaveLast = useCallback(() => {
if (!validateSection(form, 3)) { if (!validateSection(form, 3)) {
@ -184,8 +214,11 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
form.email.trim() && form.email.trim() &&
isValidEmail(form.email) && isValidEmail(form.email) &&
!submitting && !submitting &&
(form.roleMode !== "custom" || Boolean(form.customRoleId)) &&
(!isEdit ? form.password.length >= 6 : (form.password.length === 0 || form.password.length >= 6)); (!isEdit ? form.password.length >= 6 : (form.password.length === 0 || form.password.length >= 6));
const selectedRole = ROLES.find(r => r.value === form.role);
const selectedSystemRole = SYSTEM_ROLES.find(r => r.value === form.role);
const selectedCustomRole = customRoles.find(r => r._id === form.customRoleId);
return ( return (
<PanelShell <PanelShell
@ -199,7 +232,6 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
ctaDisabled={!canSave} ctaDisabled={!canSave}
onCtaClick={handleSave} onCtaClick={handleSave}
> >
{/* 1. User Details */}
<AccordionSection <AccordionSection
n={1} n={1}
title="User Details" title="User Details"
@ -226,14 +258,13 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
<HelperText text="An invitation link will be sent to this address." /> <HelperText text="An invitation link will be sent to this address." />
</Field> </Field>
<Field span2> <Field span2>
<Label text="Initial password" required /> <Label text={isEdit ? "New password (optional)" : "Initial password"} required={!isEdit} />
<input type="password" style={IS} placeholder="Min 6 characters" value={form.password} onChange={e => set("password", e.target.value)} onFocus={focusIn} onBlur={focusOut} minLength={6} /> <input type="password" style={IS} placeholder="Min 6 characters" value={form.password} onChange={e => set("password", e.target.value)} onFocus={focusIn} onBlur={focusOut} minLength={6} />
<HelperText text="User will need this to sign in. They can change it later." /> <HelperText text={isEdit ? "Leave blank to keep the current password." : "User will need this to sign in. They can change it later."} />
</Field> </Field>
</Grid> </Grid>
</AccordionSection> </AccordionSection>
{/* 2. Role & Permissions */}
<AccordionSection <AccordionSection
n={2} n={2}
title="Role & Permissions" title="Role & Permissions"
@ -245,30 +276,85 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
nextLabel="Event Access" nextLabel="Event Access"
sectionError={sectionErrors[2]} sectionError={sectionErrors[2]}
> >
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}> <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
{ROLES.map(r => ( <button
<button key={r.value} type="button" onClick={() => set("role", r.value)} type="button"
style={{ display: "flex", alignItems: "flex-start", gap: "12px", padding: "12px 14px", borderRadius: "var(--radius-button)", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--accent)" : "var(--card)", cursor: "pointer", textAlign: "left", transition: "all .12s" }}> onClick={() => set("roleMode", "system")}
<div style={{ width: "16px", height: "16px", borderRadius: "50%", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--primary)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", marginTop: "1px", flexShrink: 0 }}> style={{
{form.role === r.value && <div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#fff" }} />} flex: 1,
</div> padding: "8px 12px",
<div> borderRadius: "var(--radius-button)",
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: form.role === r.value ? "var(--primary)" : "var(--foreground)", margin: "0 0 2px" }}>{r.label}</p> border: `2px solid ${form.roleMode === "system" ? "var(--primary)" : "var(--border)"}`,
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>{r.desc}</p> background: form.roleMode === "system" ? "var(--accent)" : "var(--card)",
</div> cursor: "pointer",
</button> fontSize: "var(--text-sm)",
))} }}
>
System role
</button>
<button
type="button"
onClick={() => set("roleMode", "custom")}
style={{
flex: 1,
padding: "8px 12px",
borderRadius: "var(--radius-button)",
border: `2px solid ${form.roleMode === "custom" ? "var(--primary)" : "var(--border)"}`,
background: form.roleMode === "custom" ? "var(--accent)" : "var(--card)",
cursor: "pointer",
fontSize: "var(--text-sm)",
}}
>
Custom role
</button>
</div> </div>
{selectedRole && ( {form.roleMode === "system" ? (
<div style={{ padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)" }}> <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{SYSTEM_ROLES.map(r => (
<button key={r.value} type="button" onClick={() => set("role", r.value)}
style={{ display: "flex", alignItems: "flex-start", gap: "12px", padding: "12px 14px", borderRadius: "var(--radius-button)", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--accent)" : "var(--card)", cursor: "pointer", textAlign: "left", transition: "all .12s" }}>
<div style={{ width: "16px", height: "16px", borderRadius: "50%", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--primary)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", marginTop: "1px", flexShrink: 0 }}>
{form.role === r.value && <div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#fff" }} />}
</div>
<div>
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: form.role === r.value ? "var(--primary)" : "var(--foreground)", margin: "0 0 2px" }}>{r.label}</p>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>{r.desc}</p>
</div>
</button>
))}
</div>
) : (
<Field>
<Label text="Custom role" required />
<select
value={form.customRoleId}
onChange={(e) => set("customRoleId", e.target.value)}
style={{ ...IS, width: "100%" }}
>
<option value="">Select a role…</option>
{customRoles.map((r) => (
<option key={r._id} value={r._id}>{r.name}</option>
))}
</select>
{customRoles.length === 0 && (
<HelperText text="No custom roles yet. Create roles under User Management → Manage Roles." />
)}
</Field>
)}
{(selectedSystemRole && form.roleMode === "system") || (selectedCustomRole && form.roleMode === "custom") ? (
<div style={{ padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", marginTop: 12 }}>
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}> <p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
<strong style={{ color: "var(--foreground)" }}>{selectedRole.label}:</strong> {selectedRole.desc} <strong style={{ color: "var(--foreground)" }}>
{form.roleMode === "custom" ? selectedCustomRole?.name : selectedSystemRole?.label}:
</strong>{" "}
{form.roleMode === "custom"
? selectedCustomRole?.description || "Custom permissions apply."
: selectedSystemRole?.desc}
</p> </p>
</div> </div>
)} ) : null}
</AccordionSection> </AccordionSection>
{/* 3. Event Access & Invite */}
<AccordionSection <AccordionSection
n={3} n={3}
title="Event Access & Invite" title="Event Access & Invite"
@ -309,6 +395,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
)} )}
<HelperText text="Leave empty to grant access to all events based on their role." /> <HelperText text="Leave empty to grant access to all events based on their role." />
</Field> </Field>
{!isEdit ? (
<Grid cols={1}> <Grid cols={1}>
<Field> <Field>
<Label text="Send Invite Now" required /> <Label text="Send Invite Now" required />
@ -320,6 +407,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
<HelperText text={`${form.personalMessage.length}/300 characters.`} /> <HelperText text={`${form.personalMessage.length}/300 characters.`} />
</Field> </Field>
</Grid> </Grid>
) : null}
</AccordionSection> </AccordionSection>
</PanelShell> </PanelShell>
); );

View File

@ -286,5 +286,10 @@ export function useAccordion(total: number) {
if (n < total) setOpen(n + 1); if (n < total) setOpen(n + 1);
}; };
const toggle = (n: number) => setOpen(prev => prev === n ? 0 : n); const toggle = (n: number) => setOpen(prev => prev === n ? 0 : n);
return { open, done, advance, toggle }; const skipTo = (n: number) => {
if (n < 1) return;
setDone(new Set(Array.from({ length: n - 1 }, (_, i) => i + 1)));
setOpen(n);
};
return { open, done, advance, toggle, skipTo };
} }

View File

@ -11,8 +11,10 @@ import {
type PendingInvite, type PendingInvite,
type AgendaTrack, type AgendaTrack,
} from "../../../services/api/eventStaff"; } from "../../../services/api/eventStaff";
import { buildEventAccessPayload } from "../../utils/eventAccessPayload";
const ROLES = ["Moderator", "Presenter", "Co-organiser", "Viewer"]; import { buildEventRoleOptions } from "../../utils/eventRoleOptions";
import { usePermission } from "../../hooks/usePermission";
import { rolesApi, type Role } from "../../../services/api/roles";
function getInitials(firstName?: string, lastName?: string, email?: string): string { function getInitials(firstName?: string, lastName?: string, email?: string): string {
if (firstName || lastName) { if (firstName || lastName) {
@ -133,9 +135,10 @@ function SessionTreeSection({
/* Add existing org users to event */ /* Add existing org users to event */
function AddUsersModal({ function AddUsersModal({
eventId, eventId,
roleOptions,
onClose, onClose,
onSuccess, onSuccess,
}: { eventId: string; onClose: () => void; onSuccess: () => void }) { }: { eventId: string; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
const [step, setStep] = useState<"users" | "sessions">("users"); const [step, setStep] = useState<"users" | "sessions">("users");
const [available, setAvailable] = useState<{ _id: string; email: string; firstName?: string; lastName?: string; role: string }[]>([]); const [available, setAvailable] = useState<{ _id: string; email: string; firstName?: string; lastName?: string; role: string }[]>([]);
const [tracks, setTracks] = useState<AgendaTrack[]>([]); const [tracks, setTracks] = useState<AgendaTrack[]>([]);
@ -187,9 +190,11 @@ function AddUsersModal({
const handleAdd = async () => { const handleAdd = async () => {
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
const res = await eventStaffApi.addStaff(eventId, { const res = await eventStaffApi.addStaff(eventId, {
userIds: Array.from(selectedUserIds), userIds: Array.from(selectedUserIds),
sessionIds: Array.from(sessionIds), sessionIds: sids,
trackIds: tids,
eventRole, eventRole,
}); });
setSubmitting(false); setSubmitting(false);
@ -228,7 +233,7 @@ function AddUsersModal({
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 8 }}>Event role (applies to all selected)</p> <p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 8 }}>Event role (applies to all selected)</p>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}> <select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)} {roleOptions.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
</div> </div>
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 10 }}>Select users</p> <p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 10 }}>Select users</p>
@ -278,9 +283,10 @@ function AddUsersModal({
interface InviteRow { id: string; email: string; role: string; } interface InviteRow { id: string; email: string; role: string; }
function InviteModal({ function InviteModal({
eventId, eventId,
roleOptions,
onClose, onClose,
onSuccess, onSuccess,
}: { eventId: string; onClose: () => void; onSuccess: () => void }) { }: { eventId: string; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
const [rows, setRows] = useState<InviteRow[]>([{ id: "r1", email: "", role: "Viewer" }]); const [rows, setRows] = useState<InviteRow[]>([{ id: "r1", email: "", role: "Viewer" }]);
const [step, setStep] = useState<"users" | "sessions">("users"); const [step, setStep] = useState<"users" | "sessions">("users");
const [tracks, setTracks] = useState<AgendaTrack[]>([]); const [tracks, setTracks] = useState<AgendaTrack[]>([]);
@ -288,6 +294,7 @@ function InviteModal({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ sent: number; errors: { email: string; message: string }[] } | null>(null); const [result, setResult] = useState<{ sent: number; errors: { email: string; message: string }[] } | null>(null);
const [requestError, setRequestError] = useState<string | null>(null);
const allSessionIds = useMemo(() => allSessionIdsFromTracks(tracks), [tracks]); const allSessionIds = useMemo(() => allSessionIdsFromTracks(tracks), [tracks]);
@ -314,17 +321,25 @@ function InviteModal({
const handleSend = async () => { const handleSend = async () => {
setResult(null); setResult(null);
setRequestError(null);
setSubmitting(true); setSubmitting(true);
const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
const res = await eventStaffApi.sendInvites(eventId, { const res = await eventStaffApi.sendInvites(eventId, {
invites: validRows.map((r) => ({ email: r.email.trim(), eventRole: r.role || "Viewer" })), invites: validRows.map((r) => ({ email: r.email.trim(), eventRole: r.role || "Viewer" })),
sessionIds: Array.from(sessionIds), sessionIds: sids,
trackIds: tids,
}); });
setSubmitting(false); setSubmitting(false);
if (res.data) setResult(res.data); if (res.error && !res.data) {
if (res.error && !res.data) return; setRequestError(res.error);
if (res.data && (res.data.sent > 0 || res.data.errors?.length === 0)) { return;
onSuccess(); }
onClose(); if (res.data) {
setResult(res.data);
if (res.data.sent > 0 && (!res.data.errors || res.data.errors.length === 0)) {
onSuccess();
onClose();
}
} }
}; };
@ -362,7 +377,7 @@ function InviteModal({
<p style={{ fontSize: "var(--text-xs)", marginBottom: 5 }}>Role <span style={{ color: "var(--destructive)" }}>*</span></p> <p style={{ fontSize: "var(--text-xs)", marginBottom: 5 }}>Role <span style={{ color: "var(--destructive)" }}>*</span></p>
<select value={row.role} onChange={(e) => updateRow(row.id, "role", e.target.value)} <select value={row.role} onChange={(e) => updateRow(row.id, "role", e.target.value)}
style={{ width: "100%", height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}> style={{ width: "100%", height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)} {roleOptions.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
</div> </div>
{rows.length > 1 && ( {rows.length > 1 && (
@ -380,6 +395,11 @@ function InviteModal({
loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} /> loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />
)} )}
</div> </div>
{requestError && (
<div style={{ padding: "8px 24px", background: "#fef2f2", color: "#b91c1c", fontSize: "var(--text-sm)" }}>
{requestError}
</div>
)}
{result?.errors && result.errors.length > 0 && ( {result?.errors && result.errors.length > 0 && (
<div style={{ padding: "8px 24px", background: "#fef2f2", color: "#b91c1c", fontSize: "var(--text-sm)" }}> <div style={{ padding: "8px 24px", background: "#fef2f2", color: "#b91c1c", fontSize: "var(--text-sm)" }}>
{result.errors.map((e, i) => <div key={i}>{e.email}: {e.message}</div>)} {result.errors.map((e, i) => <div key={i}>{e.email}: {e.message}</div>)}
@ -411,9 +431,10 @@ function InviteModal({
function EditAccessModal({ function EditAccessModal({
eventId, eventId,
member, member,
roleOptions,
onClose, onClose,
onSuccess, onSuccess,
}: { eventId: string; member: EventStaffMember; onClose: () => void; onSuccess: () => void }) { }: { eventId: string; member: EventStaffMember; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
const [tracks, setTracks] = useState<AgendaTrack[]>([]); const [tracks, setTracks] = useState<AgendaTrack[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -435,7 +456,12 @@ function EditAccessModal({
const handleSave = async () => { const handleSave = async () => {
setSubmitting(true); setSubmitting(true);
const res = await eventStaffApi.updateStaff(eventId, member.userId, { sessionIds: Array.from(sessionIds), eventRole }); const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
const res = await eventStaffApi.updateStaff(eventId, member.userId, {
sessionIds: sids,
trackIds: tids,
eventRole,
});
setSubmitting(false); setSubmitting(false);
if (!res.error) { if (!res.error) {
onSuccess(); onSuccess();
@ -457,7 +483,7 @@ function EditAccessModal({
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<label style={{ fontSize: "var(--text-xs)", color: "var(--muted)", display: "block", marginBottom: 6 }}>Event role</label> <label style={{ fontSize: "var(--text-xs)", color: "var(--muted)", display: "block", marginBottom: 6 }}>Event role</label>
<select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}> <select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)} {roleOptions.map((r) => <option key={r} value={r}>{r}</option>)}
</select> </select>
</div> </div>
{loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />} {loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />}
@ -473,6 +499,8 @@ function EditAccessModal({
export function PermissionsPage() { export function PermissionsPage() {
const { selectedEventId, permissionsOpenAddOnce, setPermissionsOpenAddOnce } = useAppNav(); const { selectedEventId, permissionsOpenAddOnce, setPermissionsOpenAddOnce } = useAppNav();
const { canModule } = usePermission();
const canWriteStaff = canModule("user_management", "write");
const [eventName, setEventName] = useState<string | null>(null); const [eventName, setEventName] = useState<string | null>(null);
const [members, setMembers] = useState<EventStaffMember[]>([]); const [members, setMembers] = useState<EventStaffMember[]>([]);
const [invites, setInvites] = useState<PendingInvite[]>([]); const [invites, setInvites] = useState<PendingInvite[]>([]);
@ -484,6 +512,13 @@ export function PermissionsPage() {
const [showAdd, setShowAdd] = useState(false); const [showAdd, setShowAdd] = useState(false);
const [showInvite, setShowInvite] = useState(false); const [showInvite, setShowInvite] = useState(false);
const [editMember, setEditMember] = useState<EventStaffMember | null>(null); const [editMember, setEditMember] = useState<EventStaffMember | null>(null);
const [orgRoles, setOrgRoles] = useState<Role[]>([]);
const eventRoleOptions = useMemo(() => buildEventRoleOptions(orgRoles), [orgRoles]);
const editRoleOptions = useMemo(
() => buildEventRoleOptions(orgRoles, editMember?.eventRole),
[orgRoles, editMember?.eventRole],
);
const loadData = async () => { const loadData = async () => {
if (!selectedEventId) { if (!selectedEventId) {
@ -515,6 +550,15 @@ export function PermissionsPage() {
loadData(); loadData();
}, [selectedEventId]); }, [selectedEventId]);
useEffect(() => {
if (!showAdd && !showInvite && !editMember) return;
let cancelled = false;
rolesApi.list().then((res) => {
if (!cancelled && res.data?.roles) setOrgRoles(res.data.roles);
});
return () => { cancelled = true; };
}, [showAdd, showInvite, editMember]);
useEffect(() => { useEffect(() => {
if (!permissionsOpenAddOnce) return; if (!permissionsOpenAddOnce) return;
setShowAdd(true); setShowAdd(true);
@ -580,12 +624,16 @@ export function PermissionsPage() {
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
{canWriteStaff ? (
<button type="button" onClick={() => setShowAdd(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--primary)", background: "transparent", color: "var(--primary)", fontSize: "var(--text-sm)", fontWeight: 600, cursor: "pointer" }}> <button type="button" onClick={() => setShowAdd(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--primary)", background: "transparent", color: "var(--primary)", fontSize: "var(--text-sm)", fontWeight: 600, cursor: "pointer" }}>
<Users size={14} /> Add users <Users size={14} /> Add users
</button> </button>
) : null}
{canWriteStaff ? (
<button type="button" onClick={() => setShowInvite(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontWeight: 600 }}> <button type="button" onClick={() => setShowInvite(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontWeight: 600 }}>
<UserPlus size={14} /> Invite by email <UserPlus size={14} /> Invite by email
</button> </button>
) : null}
</div> </div>
</div> </div>
@ -625,13 +673,17 @@ export function PermissionsPage() {
</div> </div>
<span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>{m.email}</span> <span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>{m.email}</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#e0e7ff", color: "#3730a3", fontSize: 11, fontWeight: 600 }}>{m.eventRole || "Viewer"}</span> <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#e0e7ff", color: "#3730a3", fontSize: 11, fontWeight: 600 }}>{m.eventRole || "Viewer"}</span>
<div style={{ display: "flex", gap: 6, opacity: hoveredId === m.userId ? 1 : 0 }}> <div style={{ display: "flex", gap: 6, opacity: hoveredId === m.userId && canWriteStaff ? 1 : 0 }}>
{canWriteStaff ? (
<>
<button type="button" onClick={() => setEditMember(m)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}> <button type="button" onClick={() => setEditMember(m)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
<Pencil size={12} /> Edit <Pencil size={12} /> Edit
</button> </button>
<button type="button" onClick={() => handleRemoveMember(m.userId)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}> <button type="button" onClick={() => handleRemoveMember(m.userId)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}>
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
</>
) : null}
</div> </div>
</div> </div>
))} ))}
@ -648,13 +700,17 @@ export function PermissionsPage() {
</div> </div>
<span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>Pending invite</span> <span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>Pending invite</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#fef9c3", color: "#92400e", fontSize: 11, fontWeight: 600 }}>{inv.eventRole}</span> <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#fef9c3", color: "#92400e", fontSize: 11, fontWeight: 600 }}>{inv.eventRole}</span>
<div style={{ display: "flex", gap: 6, opacity: hoveredId === `inv-${inv.id}` ? 1 : 0 }}> <div style={{ display: "flex", gap: 6, opacity: hoveredId === `inv-${inv.id}` && canWriteStaff ? 1 : 0 }}>
{canWriteStaff ? (
<>
<button type="button" onClick={() => handleResendInvite(inv.id)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}> <button type="button" onClick={() => handleResendInvite(inv.id)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
<MailCheck size={12} /> Resend <MailCheck size={12} /> Resend
</button> </button>
<button type="button" onClick={() => handleCancelInvite(inv.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}> <button type="button" onClick={() => handleCancelInvite(inv.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}>
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>
</>
) : null}
</div> </div>
</div> </div>
))} ))}
@ -662,9 +718,9 @@ export function PermissionsPage() {
)} )}
</div> </div>
{showAdd && selectedEventId && <AddUsersModal eventId={selectedEventId} onClose={() => setShowAdd(false)} onSuccess={loadData} />} {showAdd && selectedEventId && <AddUsersModal eventId={selectedEventId} roleOptions={eventRoleOptions} onClose={() => setShowAdd(false)} onSuccess={loadData} />}
{showInvite && selectedEventId && <InviteModal eventId={selectedEventId} onClose={() => setShowInvite(false)} onSuccess={loadData} />} {showInvite && selectedEventId && <InviteModal eventId={selectedEventId} roleOptions={eventRoleOptions} onClose={() => setShowInvite(false)} onSuccess={loadData} />}
{editMember && selectedEventId && <EditAccessModal eventId={selectedEventId} member={editMember} onClose={() => setEditMember(null)} onSuccess={loadData} />} {editMember && selectedEventId && <EditAccessModal eventId={selectedEventId} member={editMember} roleOptions={editRoleOptions} onClose={() => setEditMember(null)} onSuccess={loadData} />}
</div> </div>
); );
} }

View File

@ -0,0 +1,150 @@
import React, { useCallback, useEffect, useState } from "react";
import { Shield, Trash2, Pencil } from "lucide-react";
import { rolesApi, type Role, type AclModuleCatalogEntry, type RolePermission } from "../../../services/api/roles";
import { usePermission } from "../../hooks/usePermission";
const ACTIONS = ["read", "write", "delete"] as const;
export function RolesPage() {
const { canModule } = usePermission();
const [roles, setRoles] = useState<Role[]>([]);
const [catalog, setCatalog] = useState<AclModuleCatalogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Role | null>(null);
const [form, setForm] = useState({ name: "", description: "", permissions: [] as RolePermission[] });
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
const [r, c] = await Promise.all([rolesApi.list(), rolesApi.catalog()]);
if (r.data?.roles) setRoles(r.data.roles);
if (c.data?.modules) setCatalog(c.data.modules);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
if (!canModule("user_management", "read")) {
return <p style={{ padding: 24, color: "var(--muted)" }}>You do not have access to role management.</p>;
}
const togglePerm = (module: string, action: string) => {
setForm((f) => {
const perms = [...f.permissions];
let entry = perms.find((p) => p.module === module);
if (!entry) {
entry = {
module,
actions: [],
scope: catalog.find((m) => m.key === module)?.rowLevel ? "assigned" : "all",
};
perms.push(entry);
}
const has = entry.actions.includes(action);
entry.actions = has ? entry.actions.filter((a) => a !== action) : [...entry.actions, action];
if (!entry.actions.length) return { ...f, permissions: perms.filter((p) => p.module !== module) };
return { ...f, permissions: perms };
});
};
const save = async () => {
setError("");
if (!form.name.trim()) {
setError("Name is required");
return;
}
const res = editing ? await rolesApi.update(editing._id, form) : await rolesApi.create(form);
if (res.error) {
setError(res.error.message || "Save failed");
return;
}
setEditing(null);
setForm({ name: "", description: "", permissions: [] });
load();
};
const startEdit = (role: Role) => {
if (role.isSystem) return;
setEditing(role);
setForm({ name: role.name, description: role.description || "", permissions: role.permissions || [] });
};
const remove = async (id: string) => {
if (!window.confirm("Delete this role?")) return;
const res = await rolesApi.remove(id);
if (res.error) alert(res.error.message);
else load();
};
return (
<div style={{ padding: "24px", maxWidth: 960 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 20 }}>
<Shield size={22} color="var(--primary)" />
<h1 style={{ margin: 0, fontSize: "var(--text-xl)" }}>Custom Roles</h1>
</div>
{canModule("user_management", "write") && (
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 12, padding: 16, marginBottom: 24 }}>
<h2 style={{ margin: "0 0 12px", fontSize: "var(--text-base)" }}>{editing ? "Edit role" : "New role"}</h2>
{error && <p style={{ color: "var(--destructive)", fontSize: 13 }}>{error}</p>}
<input
placeholder="Role name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
style={{ width: "100%", marginBottom: 8, padding: 8, borderRadius: 8, border: "1px solid var(--border)" }}
/>
<div style={{ overflowX: "auto", marginBottom: 12 }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: "left", padding: 6 }}>Module</th>
{ACTIONS.map((a) => (
<th key={a} style={{ padding: 6 }}>{a}</th>
))}
</tr>
</thead>
<tbody>
{catalog.slice(0, 24).map((m) => {
const entry = form.permissions.find((p) => p.module === m.key);
return (
<tr key={m.key}>
<td style={{ padding: 6 }}>{m.label}</td>
{ACTIONS.map((a) => (
<td key={a} style={{ textAlign: "center" }}>
<input type="checkbox" checked={entry?.actions.includes(a) ?? false} onChange={() => togglePerm(m.key, a)} />
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<button type="button" onClick={save} style={{ padding: "8px 16px", background: "var(--primary)", color: "#fff", border: "none", borderRadius: 8 }}>
{editing ? "Update" : "Create"}
</button>
</div>
)}
{loading ? (
<p>Loading roles…</p>
) : (
roles.map((r) => (
<div key={r._id} style={{ display: "flex", justifyContent: "space-between", padding: 12, border: "1px solid var(--border)", borderRadius: 8, marginBottom: 8 }}>
<span><strong>{r.name}</strong>{r.isSystem ? " (system)" : ""}</span>
{!r.isSystem && canModule("user_management", "write") && (
<span>
<button type="button" onClick={() => startEdit(r)}><Pencil size={16} /></button>
{canModule("user_management", "delete") && (
<button type="button" onClick={() => remove(r._id)}><Trash2 size={16} /></button>
)}
</span>
)}
</div>
))
)}
</div>
);
}

View File

@ -14,7 +14,7 @@ function builderIframeSrc(): string {
} }
function apiBaseUrlForChild(): string { function apiBaseUrlForChild(): string {
const base = import.meta.env.VITE_API_URL || "http://localhost:3002/api"; const base = import.meta.env.VITE_API_URL || "http://localhost:3001/api";
return normalizeBaseUrl(base); return normalizeBaseUrl(base);
} }

View File

@ -118,12 +118,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
} }
if (d && "user" in d && d.user) { if (d && "user" in d && d.user) {
setStoredUser(d.user); await refreshUser();
setState((s) => ({ ...s, user: d.user }));
return { success: true }; return { success: true };
} }
return { error: "Login failed" }; return { error: "Login failed" };
}, []); }, [refreshUser]);
const loginWithGoogleCredential = useCallback(async (idToken: string): Promise<LoginResult> => { const loginWithGoogleCredential = useCallback(async (idToken: string): Promise<LoginResult> => {
let res = await authApi.googleLogin(idToken); let res = await authApi.googleLogin(idToken);
@ -140,23 +139,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
} }
if (d && "user" in d && d.user) { if (d && "user" in d && d.user) {
setStoredUser(d.user); await refreshUser();
setState((s) => ({ ...s, user: d.user }));
return { success: true }; return { success: true };
} }
return { error: "Google sign-in failed" }; return { error: "Google sign-in failed" };
}, []); }, [refreshUser]);
const completeTwoFactorLogin = useCallback(async (twoFactorToken: string, code: string) => { const completeTwoFactorLogin = useCallback(async (twoFactorToken: string, code: string) => {
const res = await authApi.completeLogin2fa(twoFactorToken, code.replace(/\s/g, "")); const res = await authApi.completeLogin2fa(twoFactorToken, code.replace(/\s/g, ""));
if (res.error) return { error: res.error }; if (res.error) return { error: res.error };
if (res.data?.user) { if (res.data?.user) {
setStoredUser(res.data.user); await refreshUser();
setState((s) => ({ ...s, user: res.data!.user }));
return {}; return {};
} }
return { error: "Verification failed" }; return { error: "Verification failed" };
}, []); }, [refreshUser]);
const register = useCallback( const register = useCallback(
async (payload: { async (payload: {

View File

@ -0,0 +1,101 @@
import React, { createContext, useCallback, useContext, useMemo } from "react";
import { useAuth } from "./AuthContext";
import type { ResolvedPermissions } from "../../services/api/types";
type AclAction = "read" | "write" | "delete";
interface PermissionContextValue {
permissions: ResolvedPermissions | null;
canModule: (moduleKey: string, action: AclAction) => boolean;
moduleScope: (moduleKey: string) => "all" | "assigned" | null;
isOrgSuperAdmin: boolean;
loading: boolean;
}
const ACTION_IMPLIES: Record<AclAction, AclAction[]> = {
read: ["read"],
write: ["read", "write"],
delete: ["read", "write", "delete"],
};
const PermissionContext = createContext<PermissionContextValue | null>(null);
export function PermissionProvider({ children }: { children: React.ReactNode }) {
const { user, loading, checked } = useAuth();
const permissions = user?.permissions ?? null;
const canModule = useCallback(
(moduleKey: string, action: AclAction) => {
if (!permissions) return user?.role === "super_admin";
if (permissions.isOrgSuperAdmin) return true;
const actions = permissions.modules?.[moduleKey] || [];
return (ACTION_IMPLIES[action] || [action]).some((a) => actions.includes(a));
},
[permissions, user?.role],
);
const moduleScope = useCallback(
(moduleKey: string) => {
if (!permissions) return null;
const s = permissions.scopes?.[moduleKey];
return s === "all" || s === "assigned" ? s : null;
},
[permissions],
);
const value = useMemo(
() => ({
permissions,
canModule,
moduleScope,
isOrgSuperAdmin: Boolean(permissions?.isOrgSuperAdmin || user?.role === "super_admin"),
loading: loading || !checked,
}),
[permissions, canModule, moduleScope, user?.role, loading, checked],
);
return <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>;
}
export function usePermission() {
const ctx = useContext(PermissionContext);
if (!ctx) {
throw new Error("usePermission must be used within PermissionProvider");
}
return ctx;
}
/** Nav module key mapping for portfolio sections */
export const NAV_MODULE_MAP: Record<string, string> = {
home: "events",
events: "events",
activities: "live_polls",
faculty: "speaker_management",
"all-participants": "participants",
forms: "forms",
"forms-feedback": "forms",
"forms-registration": "forms",
"forms-other": "forms",
content: "content_management",
"content-library": "content_management",
"content-collections": "content_management",
messaging: "email_outreach_campaigns",
"messaging-email": "email_outreach_campaigns",
"messaging-whatsapp": "custom_template_for_wa_messaging",
payment: "billing",
"user-management": "user_management",
"roles-management": "user_management",
"participant-lists": "participants",
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
settings: "settings",
design: "custom_badge_and_print_file_creation",
"design-badges": "custom_badge_and_print_file_creation",
"design-emailers": "email_outreach_campaigns",
"design-whatsapp-templates": "custom_template_for_wa_messaging",
};
export function navItemAllowed(navId: string, canModule: (m: string, a: AclAction) => boolean): boolean {
const mod = NAV_MODULE_MAP[navId];
if (!mod) return true;
return canModule(mod, "read");
}

View File

@ -0,0 +1 @@
export { usePermission, PermissionProvider, navItemAllowed, NAV_MODULE_MAP } from "../contexts/PermissionContext";

View File

@ -0,0 +1,19 @@
import type { AgendaTrack } from "../../../services/api/eventStaff";
/** Derive trackIds when every session in a track is selected (row-level track scope). */
export function buildEventAccessPayload(
sessionIds: Set<string> | string[],
tracks: AgendaTrack[],
): { sessionIds: string[]; trackIds: string[] } {
const sidSet = sessionIds instanceof Set ? sessionIds : new Set(sessionIds);
const sids = Array.from(sidSet);
const tids = tracks
.filter(
(t) =>
t.trackId &&
t.sessions.length > 0 &&
t.sessions.every((s) => sidSet.has(s.sessionId)),
)
.map((t) => t.trackId as string);
return { sessionIds: sids, trackIds: tids };
}

View File

@ -0,0 +1,29 @@
import type { Role } from "../../services/api/roles";
/** Default event access labels used before custom roles existed. */
export const DEFAULT_EVENT_ROLES = ["Moderator", "Presenter", "Co-organiser", "Viewer"];
/** Merge built-in event labels with organisation custom roles (excludes system roles). */
export function buildEventRoleOptions(roles: Role[], extra?: string | null): string[] {
const seen = new Set<string>();
const options: string[] = [];
const customRoles = roles.filter((role) => !role.isSystem);
const systemRoleNames = new Set(
roles.filter((role) => role.isSystem).map((role) => role.name.trim().toLowerCase()),
);
const add = (name: string | undefined | null) => {
const trimmed = String(name ?? "").trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
options.push(trimmed);
};
for (const label of DEFAULT_EVENT_ROLES) add(label);
for (const role of customRoles) add(role.name);
if (extra && !systemRoleNames.has(extra.trim().toLowerCase())) add(extra);
return options;
}

View File

@ -0,0 +1,59 @@
export interface FormEventHeaderDisplay {
showLogo?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
showLocationType?: boolean;
showVenue?: boolean;
}
export type ResolvedFormEventHeaderDisplay = Required<FormEventHeaderDisplay>;
export function resolveFormEventHeaderDisplay(
display?: FormEventHeaderDisplay | null,
): ResolvedFormEventHeaderDisplay {
return {
showLogo: display?.showLogo !== false,
showTitle: display?.showTitle !== false,
showDateRange: display?.showDateRange !== false,
showEventType: display?.showEventType !== false,
showLocationType: display?.showLocationType !== false,
showVenue: display?.showVenue !== false,
};
}
export type EventHeaderToggleKey = keyof ResolvedFormEventHeaderDisplay;
export const EVENT_HEADER_TOGGLE_LABELS: Record<EventHeaderToggleKey, string> = {
showLogo: "Event logo / banner",
showTitle: "Event title",
showDateRange: "Date range",
showEventType: "Event type",
showLocationType: "In person / virtual",
showVenue: "Venue",
};
export function eventHeaderFromApi(raw?: FormEventHeaderDisplay | null): Record<EventHeaderToggleKey, "Yes" | "No"> {
const resolved = resolveFormEventHeaderDisplay(raw);
return {
showLogo: resolved.showLogo ? "Yes" : "No",
showTitle: resolved.showTitle ? "Yes" : "No",
showDateRange: resolved.showDateRange ? "Yes" : "No",
showEventType: resolved.showEventType ? "Yes" : "No",
showLocationType: resolved.showLocationType ? "Yes" : "No",
showVenue: resolved.showVenue ? "Yes" : "No",
};
}
export function eventHeaderToApi(
toggles: Record<EventHeaderToggleKey, "Yes" | "No">,
): FormEventHeaderDisplay {
return {
showLogo: toggles.showLogo === "Yes",
showTitle: toggles.showTitle === "Yes",
showDateRange: toggles.showDateRange === "Yes",
showEventType: toggles.showEventType === "Yes",
showLocationType: toggles.showLocationType === "Yes",
showVenue: toggles.showVenue === "Yes",
};
}

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Eye, Pencil, Plus, Trash2 } from 'lucide-react'; import { Copy, Eye, Pencil, Plus, Trash2 } from 'lucide-react';
import { staticMicrositeApi, type MicrositeV2Template } from '../../services/api'; import { staticMicrositeApi, type MicrositeV2Template } from '../../services/api';
import { UploadMicrositeV2TemplatePanel } from '../components/microsite-v2/UploadMicrositeV2TemplatePanel'; import { UploadMicrositeV2TemplatePanel } from '../components/microsite-v2/UploadMicrositeV2TemplatePanel';
@ -70,6 +70,29 @@ export function SuperAdminMicrositeV2TemplatesPage() {
} }
}; };
const handleDuplicate = async (t: MicrositeV2Template) => {
const suggestedSlug = `${t.slug}-copy`;
const slug = window.prompt('New slug for the duplicate:', suggestedSlug)?.trim();
if (!slug) return;
const name = window.prompt('Name for the duplicate:', `${t.name} (Copy)`)?.trim();
if (!name) return;
const res = await staticMicrositeApi.duplicateTemplate(t._id, { slug, name });
if (res.error) {
alert(res.error);
return;
}
void load();
};
const visibilityLabel = (t: MicrositeV2Template) => {
if (t.visibility === 'private') {
const count = t.allowedOrganizations?.length || 0;
return `Private (${count} account${count === 1 ? '' : 's'})`;
}
return 'Public';
};
return ( return (
<> <>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
@ -122,6 +145,7 @@ export function SuperAdminMicrositeV2TemplatesPage() {
<th style={{ padding: '10px 8px' }}>Name</th> <th style={{ padding: '10px 8px' }}>Name</th>
<th style={{ padding: '10px 8px' }}>Slug</th> <th style={{ padding: '10px 8px' }}>Slug</th>
<th style={{ padding: '10px 8px' }}>Version</th> <th style={{ padding: '10px 8px' }}>Version</th>
<th style={{ padding: '10px 8px' }}>Visibility</th>
<th style={{ padding: '10px 8px' }}>Active</th> <th style={{ padding: '10px 8px' }}>Active</th>
<th style={{ padding: '10px 8px' }}>Actions</th> <th style={{ padding: '10px 8px' }}>Actions</th>
</tr> </tr>
@ -139,6 +163,7 @@ export function SuperAdminMicrositeV2TemplatesPage() {
<td style={{ padding: '10px 8px' }}>{t.name}</td> <td style={{ padding: '10px 8px' }}>{t.name}</td>
<td style={{ padding: '10px 8px', fontFamily: 'monospace' }}>{t.slug}</td> <td style={{ padding: '10px 8px', fontFamily: 'monospace' }}>{t.slug}</td>
<td style={{ padding: '10px 8px' }}>v{t.version}</td> <td style={{ padding: '10px 8px' }}>v{t.version}</td>
<td style={{ padding: '10px 8px', fontSize: 'var(--text-xs)' }}>{visibilityLabel(t)}</td>
<td style={{ padding: '10px 8px' }}> <td style={{ padding: '10px 8px' }}>
<button <button
type="button" type="button"
@ -160,6 +185,9 @@ export function SuperAdminMicrositeV2TemplatesPage() {
<button type="button" title="Edit" onClick={() => { setEditTarget(t); setPanelOpen(true); }} style={{ border: 'none', background: 'none', cursor: 'pointer' }}> <button type="button" title="Edit" onClick={() => { setEditTarget(t); setPanelOpen(true); }} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
<button type="button" title="Duplicate" onClick={() => void handleDuplicate(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
<Copy size={16} />
</button>
<button type="button" title="Preview" onClick={() => void handlePreview(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}> <button type="button" title="Preview" onClick={() => void handlePreview(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
<Eye size={16} /> <Eye size={16} />
</button> </button>

View File

@ -16,7 +16,7 @@ import {
export type { ApiRequestInit }; export type { ApiRequestInit };
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3002/api'; export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
const BASE_URL = API_BASE_URL; const BASE_URL = API_BASE_URL;
const STORAGE_KEYS = { const STORAGE_KEYS = {

View File

@ -7,6 +7,7 @@ export interface EventStaffMember {
lastName?: string; lastName?: string;
role: string; role: string;
sessionIds: string[]; sessionIds: string[];
trackIds?: string[];
eventRole: string; eventRole: string;
} }
@ -37,16 +38,16 @@ export const eventStaffApi = {
agendaTree(eventId: string) { agendaTree(eventId: string) {
return api.get<{ tracks: AgendaTrack[] }>(`/events/${eventId}/agenda-tree`); return api.get<{ tracks: AgendaTrack[] }>(`/events/${eventId}/agenda-tree`);
}, },
addStaff(eventId: string, body: { userIds: string[]; sessionIds?: string[]; eventRole?: string }) { addStaff(eventId: string, body: { userIds: string[]; sessionIds?: string[]; trackIds?: string[]; eventRole?: string }) {
return api.post<null>(`/events/${eventId}/staff`, body); return api.post<null>(`/events/${eventId}/staff`, body);
}, },
updateStaff(eventId: string, userId: string, body: { sessionIds?: string[]; eventRole?: string }) { updateStaff(eventId: string, userId: string, body: { sessionIds?: string[]; trackIds?: string[]; eventRole?: string }) {
return api.put<null>(`/events/${eventId}/staff/${userId}`, body); return api.put<null>(`/events/${eventId}/staff/${userId}`, body);
}, },
removeStaff(eventId: string, userId: string) { removeStaff(eventId: string, userId: string) {
return api.delete<null>(`/events/${eventId}/staff/${userId}`); return api.delete<null>(`/events/${eventId}/staff/${userId}`);
}, },
sendInvites(eventId: string, body: { invites: { email: string; eventRole?: string }[]; sessionIds?: string[] }) { sendInvites(eventId: string, body: { invites: { email: string; eventRole?: string }[]; sessionIds?: string[]; trackIds?: string[] }) {
return api.post<{ sent: number; errors: { email: string; message: string }[] }>(`/events/${eventId}/staff/invites`, body); return api.post<{ sent: number; errors: { email: string; message: string }[] }>(`/events/${eventId}/staff/invites`, body);
}, },
cancelInvite(eventId: string, inviteId: string) { cancelInvite(eventId: string, inviteId: string) {

View File

@ -36,6 +36,7 @@ export const formsApi = {
fields?: unknown[]; fields?: unknown[];
settings?: unknown; settings?: unknown;
event: string; event: string;
defaultVisibility?: "private" | "org";
}): Promise<Form> { }): Promise<Form> {
return unwrapForm(await api.post<Form>('/forms', payload)); return unwrapForm(await api.post<Form>('/forms', payload));
}, },
@ -56,6 +57,7 @@ export const formsApi = {
fields?: unknown[]; fields?: unknown[];
settings?: unknown; settings?: unknown;
event: string; event: string;
defaultVisibility?: "private" | "org";
} }
): Promise<Form> { ): Promise<Form> {
return unwrapForm(await api.put<Form>(`/forms/${id}`, payload)); return unwrapForm(await api.put<Form>(`/forms/${id}`, payload));

View File

@ -42,6 +42,7 @@ export {
} from './eventTicketingManual'; } from './eventTicketingManual';
export { speakersApi } from './speakers'; export { speakersApi } from './speakers';
export { formsApi } from './forms'; export { formsApi } from './forms';
export { rolesApi } from './roles';
export { usersApi } from './users'; export { usersApi } from './users';
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks'; export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
export { sessionsApi, type ApiSession, type SessionPayload } from './sessions'; export { sessionsApi, type ApiSession, type SessionPayload } from './sessions';

61
src/services/api/roles.ts Normal file
View File

@ -0,0 +1,61 @@
import { api } from "./client";
export interface RolePermission {
module: string;
actions: ("read" | "write" | "delete")[];
scope?: "all" | "assigned";
}
export interface Role {
_id: string;
name: string;
description?: string;
isSystem?: boolean;
systemKey?: string;
permissions: RolePermission[];
}
export interface AclModuleCatalogEntry {
key: string;
label: string;
category: string;
rowLevel: boolean;
actions: string[];
scopes: string[];
}
export interface FormGrant {
_id: string;
subjectType: "user" | "role";
subjectId: string;
subjectLabel?: string;
actions: string[];
}
export const rolesApi = {
catalog: () => api.get<{ modules: AclModuleCatalogEntry[]; actions: string[] }>("/roles/catalog"),
list: () => api.get<{ roles: Role[] }>("/roles"),
create: (payload: { name: string; description?: string; permissions: RolePermission[] }) =>
api.post<Role>("/roles", payload),
update: (id: string, payload: Partial<{ name: string; description: string; permissions: RolePermission[] }>) =>
api.put<Role>(`/roles/${id}`, payload),
remove: (id: string) => api.delete<null>(`/roles/${id}`),
listFormGrants: (formId: string) =>
api.get<{ grants: FormGrant[]; defaultVisibility: string }>(`/forms/${formId}/grants`),
createFormGrant: (
formId: string,
payload: { subjectType: "user" | "role"; subjectId: string; actions: string[] },
) => api.post(`/forms/${formId}/grants`, payload),
removeFormGrant: (formId: string, grantId: string) =>
api.delete<null>(`/forms/${formId}/grants/${grantId}`),
updateFormVisibility: (formId: string, defaultVisibility: "private" | "org") =>
api.put(`/forms/${formId}/visibility`, { defaultVisibility }),
};

View File

@ -7,29 +7,49 @@ import type {
} from './types'; } from './types';
export const staticMicrositeApi = { export const staticMicrositeApi = {
listTemplates() { listTemplates(eventId?: string) {
return api.get<MicrositeV2Template[]>('/microsite-v2-templates'); const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
return api.get<MicrositeV2Template[]>(`/microsite-v2-templates${q}`);
}, },
listAdminTemplates() { listAdminTemplates() {
return api.get<MicrositeV2Template[]>('/microsite-v2-templates/admin'); return api.get<MicrositeV2Template[]>('/microsite-v2-templates/admin');
}, },
getTemplate(id: string) { getTemplate(id: string, eventId?: string) {
return api.get<MicrositeV2Template>(`/microsite-v2-templates/${id}`); const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
return api.get<MicrositeV2Template>(`/microsite-v2-templates/${id}${q}`);
}, },
createTemplate(payload: { slug: string; name: string; description?: string; version?: number }) { createTemplate(payload: {
slug: string;
name: string;
description?: string;
version?: number;
visibility?: 'public' | 'private';
allowedOrganizations?: string[];
}) {
return api.post<MicrositeV2Template>('/microsite-v2-templates', payload); return api.post<MicrositeV2Template>('/microsite-v2-templates', payload);
}, },
updateTemplate( updateTemplate(
id: string, id: string,
payload: Partial<{ name: string; description: string; isActive: boolean; version: number }>, payload: Partial<{
name: string;
description: string;
isActive: boolean;
version: number;
visibility: 'public' | 'private';
allowedOrganizations: string[];
}>,
) { ) {
return api.put<MicrositeV2Template>(`/microsite-v2-templates/${id}`, payload); return api.put<MicrositeV2Template>(`/microsite-v2-templates/${id}`, payload);
}, },
duplicateTemplate(id: string, payload?: { slug?: string; name?: string }) {
return api.post<MicrositeV2Template>(`/microsite-v2-templates/${id}/duplicate`, payload || {});
},
deleteTemplate(id: string) { deleteTemplate(id: string) {
return api.delete<null>(`/microsite-v2-templates/${id}`); return api.delete<null>(`/microsite-v2-templates/${id}`);
}, },

View File

@ -20,13 +20,24 @@ export type ApiResponse<T> = ApiSuccess<T> | ApiError;
export interface EventAccess { export interface EventAccess {
event: string; event: string;
trackIds?: string[];
sessionIds?: string[]; sessionIds?: string[];
eventRole?: string;
}
export interface ResolvedPermissions {
modules: Record<string, string[]>;
scopes: Record<string, string>;
isOrgSuperAdmin?: boolean;
eventAccess?: EventAccess[];
customRoleId?: string | null;
} }
export interface OrganizationMembership { export interface OrganizationMembership {
organization: { _id: string; name: string }; organization: { _id: string; name: string };
role: string; role: string;
eventAccess: EventAccess[]; eventAccess: EventAccess[];
customRole?: { _id: string; name: string; isSystem?: boolean; systemKey?: string };
} }
export interface NotificationSettings { export interface NotificationSettings {
@ -58,6 +69,10 @@ export interface User {
notificationPreferencesSavedAt?: string; notificationPreferencesSavedAt?: string;
twoFactorEnabled?: boolean; twoFactorEnabled?: boolean;
isActive?: boolean; isActive?: boolean;
permissions?: ResolvedPermissions;
customRole?: { _id: string; name: string; isSystem?: boolean; systemKey?: string };
membershipRole?: string;
assignedEvents?: string[];
} }
export interface OrganizationRecord { export interface OrganizationRecord {
@ -528,6 +543,14 @@ export interface FormSettings {
sendConfirmationEmail?: boolean; sendConfirmationEmail?: boolean;
onSubmitAddTags?: string[]; onSubmitAddTags?: string[];
onSubmitParticipantListId?: string; onSubmitParticipantListId?: string;
eventHeader?: {
showLogo?: boolean;
showTitle?: boolean;
showDateRange?: boolean;
showEventType?: boolean;
showLocationType?: boolean;
showVenue?: boolean;
};
} }
/** Populated from GET /forms list when `event` is populated. */ /** Populated from GET /forms list when `event` is populated. */
@ -537,6 +560,8 @@ export interface Form {
_id: string; _id: string;
tenantOrganization?: string; tenantOrganization?: string;
event?: FormEventRef; event?: FormEventRef;
createdBy?: string;
defaultVisibility?: "private" | "org";
name: string; name: string;
description?: string; description?: string;
type: string; type: string;
@ -576,6 +601,7 @@ export interface PublicFormDefinition {
showThankYou?: boolean; showThankYou?: boolean;
collectEmail?: boolean; collectEmail?: boolean;
allowAnonymous?: boolean; allowAnonymous?: boolean;
eventHeader?: FormSettings['eventHeader'];
}; };
} }
@ -767,16 +793,24 @@ export type MicrositeV2FieldType =
| 'color' | 'color'
| 'boolean' | 'boolean'
| 'image' | 'image'
| 'video'
| 'select' | 'select'
| 'array'; | 'array';
export interface MicrositeV2FieldShowIf {
field: string;
equals: string | string[];
}
export interface MicrositeV2FieldSchema { export interface MicrositeV2FieldSchema {
key: string; key: string;
type: MicrositeV2FieldType; type: MicrositeV2FieldType;
label?: string; label?: string;
default?: unknown; default?: unknown;
placeholder?: string;
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
itemFields?: MicrositeV2FieldSchema[]; itemFields?: MicrositeV2FieldSchema[];
showIf?: MicrositeV2FieldShowIf;
} }
export interface MicrositeV2SectionSchema { export interface MicrositeV2SectionSchema {
@ -804,6 +838,8 @@ export interface MicrositeV2Template {
schema?: MicrositeV2TemplateSchema; schema?: MicrositeV2TemplateSchema;
assetsBaseUrl?: string; assetsBaseUrl?: string;
isActive: boolean; isActive: boolean;
visibility?: 'public' | 'private';
allowedOrganizations?: Array<string | { _id: string; name?: string; email?: string }>;
version: number; version: number;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@ -824,6 +860,9 @@ export interface EventStaticMicrosite {
config: StaticMicrositeConfig; config: StaticMicrositeConfig;
status: 'draft' | 'published'; status: 'draft' | 'published';
publishedHtmlUrl?: string; publishedHtmlUrl?: string;
publishedHtmlPath?: string;
publishedConfigPath?: string;
publishTarget?: 's3' | 'ec2';
publishedAt?: string; publishedAt?: string;
publicUrl?: string; publicUrl?: string;
createdAt?: string; createdAt?: string;

View File

@ -9,6 +9,7 @@ export interface CreateUserPayload {
role?: string; role?: string;
phone?: string; phone?: string;
assignedEvents?: string[]; assignedEvents?: string[];
customRoleId?: string;
} }
export const usersApi = { export const usersApi = {

View File

@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { formsApi } from "../services/api"; import { formsApi } from "../services/api";
import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "../services/api"; import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "../services/api";
import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames"; import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput"; import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields"; import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
@ -49,15 +50,81 @@ function shouldShowLocation(ev: PublicFormEventSummary): boolean {
function PublicFormEventHeader({ function PublicFormEventHeader({
event, event,
variant = "default", variant = "default",
display,
}: { }: {
event: PublicFormEventSummary; event: PublicFormEventSummary;
variant?: "default" | "embedded"; variant?: "default" | "embedded";
display?: FormEventHeaderDisplay | null;
}) { }) {
const d = {
showLogo: display?.showLogo !== false,
showTitle: display?.showTitle !== false,
showDateRange: display?.showDateRange !== false,
showEventType: display?.showEventType !== false,
showLocationType: display?.showLocationType !== false,
showVenue: display?.showVenue !== false,
};
const typeLabel = formatEventType(event.eventType); const typeLabel = formatEventType(event.eventType);
const dateLine = formatEventDateRange(event); const dateLine = formatEventDateRange(event);
const kind = formatLocationKind(event.locationType); const kind = formatLocationKind(event.locationType);
const showLoc = shouldShowLocation(event); const showLoc = d.showVenue && shouldShowLocation(event);
const embedded = variant === "embedded"; const embedded = variant === "embedded";
const showBanner = d.showLogo && !!event.imageUrl && !event.logoUrl;
const showLogo = d.showLogo && !!event.logoUrl;
const showTitle = d.showTitle && !!event.name;
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
const metaItems: Array<{ key: string; node: React.ReactNode }> = [];
if (d.showDateRange && dateLine) {
metaItems.push({
key: "date",
node: <span style={{ color: "var(--foreground, #334155)", fontWeight: 500 }}>{dateLine}</span>,
});
}
if (d.showEventType && typeLabel) {
metaItems.push({ key: "type", node: <span>{typeLabel}</span> });
}
if (d.showLocationType && kind) {
metaItems.push({
key: "kind",
node: (
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "2px 8px",
borderRadius: 999,
background:
event.locationType === "virtual"
? "rgba(59, 130, 246, 0.12)"
: event.locationType === "hybrid"
? "rgba(147, 51, 234, 0.12)"
: "rgba(22, 163, 74, 0.12)",
color:
event.locationType === "virtual"
? "#1d4ed8"
: event.locationType === "hybrid"
? "#7e22ce"
: "#15803d",
fontWeight: 600,
fontSize: "0.75rem",
}}
>
{kind}
</span>
),
});
}
if (showLoc) {
metaItems.push({
key: "venue",
node: <span style={{ color: "var(--foreground, #475569)" }}>{event.location!.trim()}</span>,
});
}
if (!showBanner && !showLogo && !showTitle && !showSubtitle && metaItems.length === 0) {
return null;
}
return ( return (
<div <div
@ -67,7 +134,7 @@ function PublicFormEventHeader({
borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)", borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)",
}} }}
> >
{event.imageUrl && !event.logoUrl ? ( {showBanner ? (
<div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}> <div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}>
<img <img
src={event.imageUrl} src={event.imageUrl}
@ -77,7 +144,7 @@ function PublicFormEventHeader({
</div> </div>
) : null} ) : null}
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}> <div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
{event.logoUrl ? ( {showLogo ? (
<img <img
src={event.logoUrl} src={event.logoUrl}
alt={event.name} alt={event.name}
@ -92,19 +159,21 @@ function PublicFormEventHeader({
/> />
) : null} ) : null}
<div style={{ minWidth: 0, flex: "1 1 200px" }}> <div style={{ minWidth: 0, flex: "1 1 200px" }}>
<h1 {showTitle ? (
style={{ <h1
fontFamily: "var(--font-inter, system-ui, sans-serif)", style={{
fontSize: "1.375rem", fontFamily: "var(--font-inter, system-ui, sans-serif)",
fontWeight: 600, fontSize: "1.375rem",
margin: "0 0 6px", fontWeight: 600,
lineHeight: 1.25, margin: "0 0 6px",
color: "var(--foreground, #0f172a)", lineHeight: 1.25,
}} color: "var(--foreground, #0f172a)",
> }}
{event.name} >
</h1> {event.name}
{event.subtitle ? ( </h1>
) : null}
{showSubtitle ? (
<p <p
style={{ style={{
margin: "0 0 10px", margin: "0 0 10px",
@ -116,62 +185,30 @@ function PublicFormEventHeader({
{event.subtitle} {event.subtitle}
</p> </p>
) : null} ) : null}
<div {metaItems.length > 0 ? (
style={{ <div
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: "6px 10px",
fontSize: "0.8125rem",
color: "var(--muted, #64748b)",
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
}}
>
<span style={{ color: "var(--foreground, #334155)", fontWeight: 500 }}>{dateLine}</span>
{typeLabel ? (
<>
<span aria-hidden="true" style={{ opacity: 0.5 }}>
·
</span>
<span>{typeLabel}</span>
</>
) : null}
<span aria-hidden="true" style={{ opacity: 0.5 }}>
·
</span>
<span
style={{ style={{
display: "inline-flex", display: "flex",
flexWrap: "wrap",
alignItems: "center", alignItems: "center",
padding: "2px 8px", gap: "6px 10px",
borderRadius: 999, fontSize: "0.8125rem",
background: color: "var(--muted, #64748b)",
event.locationType === "virtual" fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
? "rgba(59, 130, 246, 0.12)"
: event.locationType === "hybrid"
? "rgba(147, 51, 234, 0.12)"
: "rgba(22, 163, 74, 0.12)",
color:
event.locationType === "virtual"
? "#1d4ed8"
: event.locationType === "hybrid"
? "#7e22ce"
: "#15803d",
fontWeight: 600,
fontSize: "0.75rem",
}} }}
> >
{kind} {metaItems.map((item, index) => (
</span> <React.Fragment key={item.key}>
{showLoc ? ( {index > 0 ? (
<> <span aria-hidden="true" style={{ opacity: 0.5 }}>
<span aria-hidden="true" style={{ opacity: 0.5 }}> ·
· </span>
</span> ) : null}
<span style={{ color: "var(--foreground, #475569)" }}>{event.location.trim()}</span> {item.node}
</> </React.Fragment>
) : null} ))}
</div> </div>
) : null}
</div> </div>
</div> </div>
</div> </div>
@ -388,7 +425,7 @@ export function PublicFormView() {
boxShadow: "0 1px 3px rgba(0,0,0,0.08)", boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
}} }}
> >
{ev ? <PublicFormEventHeader event={ev} variant="embedded" /> : null} {ev ? <PublicFormEventHeader event={ev} variant="embedded" display={form.settings?.eventHeader} /> : null}
<h1 <h1
style={{ style={{
fontFamily: "var(--font-inter, system-ui, sans-serif)", fontFamily: "var(--font-inter, system-ui, sans-serif)",
@ -399,7 +436,21 @@ export function PublicFormView() {
> >
{form.name} {form.name}
</h1> </h1>
<p style={{ margin: 0, fontSize: "0.95rem", lineHeight: 1.5, color: "var(--foreground)" }}>{msg}</p> <p
style={{
margin: 0,
padding: "12px 16px",
borderRadius: 6,
backgroundColor: "#d1e7dd",
color: "#198754",
fontWeight: 700,
fontSize: "0.95rem",
lineHeight: 1.5,
border: "1px solid #badbcc",
}}
>
{msg}
</p>
</div> </div>
</div> </div>
); );
@ -438,7 +489,7 @@ export function PublicFormView() {
}} }}
> >
<div style={{ maxWidth: 520, margin: "0 auto" }}> <div style={{ maxWidth: 520, margin: "0 auto" }}>
{ev ? <PublicFormEventHeader event={ev} /> : null} {ev ? <PublicFormEventHeader event={ev} display={form.settings?.eventHeader} /> : null}
<h1 <h1
style={{ style={{
fontFamily: "var(--font-inter, system-ui, sans-serif)", fontFamily: "var(--font-inter, system-ui, sans-serif)",