diff --git a/dist/index.html b/dist/index.html
index 1494ae00..d5b558b3 100644
--- a/dist/index.html
+++ b/dist/index.html
@@ -12,7 +12,7 @@
rel="stylesheet"
/>
-
+
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 4fde4021..0b4aea3d 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -2,6 +2,7 @@ import React from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ThemeProvider } from "./contexts/ThemeContext";
import { AuthProvider } from "./contexts/AuthContext";
+import { PermissionProvider } from "./contexts/PermissionContext";
import { ErrorBoundary, GlobalMutationLoadingOverlay } from "./components/shared";
import { LoginView, RegisterView } from "./views/AuthViews";
import { AcceptEventInviteView } from "./views/AcceptEventInviteView";
@@ -19,6 +20,7 @@ export default function App() {
+
@@ -41,6 +43,7 @@ export default function App() {
} />
+
diff --git a/src/app/components/MainContent.tsx b/src/app/components/MainContent.tsx
index 66e7f59a..d3cef884 100644
--- a/src/app/components/MainContent.tsx
+++ b/src/app/components/MainContent.tsx
@@ -30,6 +30,7 @@ import {
} from "./global/GlobalStubPage";
import { PaymentSettingsPage } from "./global/PaymentSettingsPage";
import { SettingsPage } from "./global/SettingsPage";
+import { RolesPage } from "./roles/RolesPage";
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
import { SearchResultsPage } from "./search/SearchResultsPage";
@@ -127,6 +128,7 @@ export function MainContent() {
return ;
if (activeNav === "payment") return ;
if (activeNav === "user-management") return ;
+ if (activeNav === "roles-management") return ;
if (activeNav === "settings")
return (
= {
+ dashboard: "events",
+ agenda: "sessions",
+ participants: "participants",
+ attendance: "participants",
+ "event-activities": "live_polls",
+ rewards: "gamification",
+ analytics: "events",
+ permissions: "user_management",
+};
/* ─────────────────────────────────────
Event-specific nav items
@@ -37,6 +50,12 @@ interface 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(null);
const bp = useBreakpoint();
const isMobile = bp === "mobile";
@@ -93,7 +112,7 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
msOverflowStyle: "none" as any,
}}
>
- {EVENT_TABS.map(tab => {
+ {visibleTabs.map(tab => {
const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz"));
return (
void;
}) {
- const defaultOpen = PORTFOLIO_NAV.filter(n => n.children).reduce>((acc, item) => {
+ const { canModule } = usePermission();
+ const visibleNav = PORTFOLIO_NAV.filter((item) => navItemAllowed(item.id, canModule));
+ const defaultOpen = visibleNav.filter(n => n.children).reduce>((acc, item) => {
acc[item.id] = isChildActive(item, activeItem);
return acc;
}, {});
@@ -166,7 +169,7 @@ function NavList({
return (
<>
- {PORTFOLIO_NAV.map(item => {
+ {visibleNav.map(item => {
/* ── Special: All Events accordion ── */
if (item.id === "events") {
@@ -256,7 +259,7 @@ function NavList({
{isOpen && (
- {item.children!.map(child => {
+ {item.children!.filter((child) => navItemAllowed(child.id, canModule)).map(child => {
const cActive = activeItem === child.id;
return (
onNavigate(child.id)}
diff --git a/src/app/components/dashboard/EventHomeModules.tsx b/src/app/components/dashboard/EventHomeModules.tsx
index 48a8fe58..50e0f464 100644
--- a/src/app/components/dashboard/EventHomeModules.tsx
+++ b/src/app/components/dashboard/EventHomeModules.tsx
@@ -1,7 +1,9 @@
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 { useAppNav } from "../../contexts/AppNavContext";
+import { useAuth } from "../../contexts/AuthContext";
+import { usePermission } from "../../hooks/usePermission";
import { eventsApi } from "../../../services/api/events";
import { eventStaffApi } from "../../../services/api/eventStaff";
import { quizPollsApi } from "../../../services/api/quizPolls";
@@ -11,6 +13,7 @@ import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
import { CreateFormPanel } from "../panels/CreateFormPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
+import { ShareFormPanel } from "../forms/ShareFormPanel";
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
import { EventBadgesPanel } from "./EventBadgesPanel";
import { EventDocumentsPanel } from "./EventDocumentsPanel";
@@ -129,9 +132,9 @@ function moduleButtonStyle(primary = false): React.CSSProperties {
minWidth: 0,
padding: "6px 8px",
borderRadius: "var(--radius-button)",
- border: primary ? "none" : "1px solid var(--border)",
- background: primary ? "var(--primary)" : "var(--card)",
- color: primary ? "var(--primary-foreground)" : "var(--card-foreground)",
+ border: primary ? "1px solid var(--primary)" : "1px solid var(--border)",
+ background: "var(--card)",
+ color: primary ? "var(--primary)" : "var(--card-foreground)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-noto-sans)",
fontWeight: "var(--font-weight-medium)",
@@ -148,6 +151,13 @@ function formDisplayType(apiType: string): string {
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" {
if (apiType === "feedback") return "feedback";
if (apiType === "registration") return "registration";
@@ -173,11 +183,26 @@ function formLinkCopyButtonStyle(copied: boolean): React.CSSProperties {
color: copied ? "#15803d" : "var(--muted)",
cursor: "pointer",
flexShrink: 0,
+ padding: 0,
+ };
+}
+
+function formHeaderIconButtonStyle(): React.CSSProperties {
+ return {
+ ...formLinkCopyButtonStyle(false),
+ color: "var(--muted)",
};
}
export function EventHomeModules() {
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 isMobile = bp === "mobile";
const isTablet = bp === "tablet";
@@ -189,7 +214,9 @@ export function EventHomeModules() {
const [eventForms, setEventForms] = useState([]);
const [formEditTarget, setFormEditTarget] = useState(null);
const [formResponsesTarget, setFormResponsesTarget] = useState(null);
+ const [formShareTarget, setFormShareTarget] = useState(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(null);
const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 });
const [pollQuizLoading, setPollQuizLoading] = useState(false);
@@ -587,11 +614,11 @@ export function EventHomeModules() {
{
setOpenAgendaSessionPanelOnce(true);
@@ -603,7 +630,7 @@ export function EventHomeModules() {
>
}
/>
- {eventForms.map((form) => {
+ {canReadForms ? eventForms.map((form) => {
const linkCopied = copiedFormLinkId === form._id;
return (
copyFormPublicLink(form)}
- onMouseEnter={(e) => {
- if (actionsDisabled || linkCopied) return;
- (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
- (e.currentTarget as HTMLElement).style.color = "var(--primary)";
- }}
- onMouseLeave={(e) => {
- if (linkCopied) return;
- (e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
- (e.currentTarget as HTMLElement).style.color = "var(--muted)";
- }}
- >
- {linkCopied ? : }
-
+
+ copyFormPublicLink(form)}
+ onMouseEnter={(e) => {
+ if (actionsDisabled || linkCopied) return;
+ (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
+ (e.currentTarget as HTMLElement).style.color = "var(--primary)";
+ }}
+ onMouseLeave={(e) => {
+ if (linkCopied) return;
+ (e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
+ (e.currentTarget as HTMLElement).style.color = "var(--muted)";
+ }}
+ >
+ {linkCopied ? : }
+
+ {isSuperAdmin ? (
+ 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)";
+ }}
+ >
+
+
+ ) : null}
+
}
actions={
<>
+ {canWriteForms ? (
Edit
+ ) : null}
+ {canReadForms ? (
Responses
+ ) : null}
>
}
/>
);
- })}
+ }) : null}
+ {canWriteForms ? (
+
}
+ title="Create New Form"
+ description="Add a form assigned to this event"
+ actions={
+ <>
+
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) => (
+
+ {opt.label}
+
+ ))}
+
+
setNewFormMode(newFormType)}
+ >
+ Create
+
+ >
+ }
+ />
+ ) : null}
}
title="Users & Permissions"
@@ -705,6 +804,7 @@ export function EventHomeModules() {
>
View
+ {canWriteUserMgmt ? (
Add New
+ ) : null}
>
}
/>
@@ -907,83 +1008,6 @@ export function EventHomeModules() {
)
}
/>
-
}
- title="Create New Form"
- description="Add a form assigned to this event"
- actions={
- <>
-
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
-
-
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
-
-
setNewFormMode("web-check-in")}
- >
- Web check-in
-
-
setNewFormMode("other")}
- >
- Other
-
- >
- }
- />
}
title="Upload Document"
@@ -1005,6 +1029,10 @@ export function EventHomeModules() {
/>
+ {formShareTarget ? (
+
setFormShareTarget(null)} />
+ ) : null}
+
{formEditTarget && selectedEventId ? (
void }) {
export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId: string) => void }) {
const { user } = useAuth();
+ const { canModule, isOrgSuperAdmin } = usePermission();
+ const canWriteEvents = canModule("events", "write");
+ const canListUsers = canModule("user_management", "read");
const eventsOrgId = portfolioListOrganizationId(user);
const {
setActiveNav,
@@ -1230,7 +1234,6 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
const [onboardingSessionCount, setOnboardingSessionCount] = useState(0);
const categories = useMemo(() => buildCategoriesFromApi(apiEvents), [apiEvents]);
- const canListUsers = user?.role === "super_admin" || user?.role === "admin";
const stepDone = useMemo(
() => ({
@@ -1299,7 +1302,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
if (!eventsOrgId) {
setApiEvents([]);
setEventsError(
- user?.role === "super_admin"
+ isOrgSuperAdmin
? "Assign an organisation to your account to load events for that tenant."
: "Your profile has no organisation.",
);
@@ -1314,7 +1317,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
})
.catch(() => setEventsError("Failed to load events"))
.finally(() => setEventsLoading(false));
- }, [eventsOrgId, user?.role]);
+ }, [eventsOrgId, isOrgSuperAdmin]);
useEffect(() => {
fetchEvents();
@@ -1606,11 +1609,13 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
{/* Create Event CTA */}
+ {canWriteEvents ? (
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)" }}>
Create Event
+ ) : null}
@@ -1660,6 +1665,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
Create an event to see it listed here with stats, filters, and calendar view.
+ {canWriteEvents ? (
setShowCreatePanel(true)}
@@ -1671,6 +1677,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
>
Create your first event
+ ) : null}
)}
diff --git a/src/app/components/forms/ShareFormPanel.tsx b/src/app/components/forms/ShareFormPanel.tsx
new file mode 100644
index 00000000..6302e087
--- /dev/null
+++ b/src/app/components/forms/ShareFormPanel.tsx
@@ -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([]);
+ const [users, setUsers] = useState([]);
+ const [roles, setRoles] = useState([]);
+ 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(["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 (
+
+
e.stopPropagation()}>
+
+
Share form
+
+
+
+
+
+ {error && (
+
{error}
+ )}
+
+ {loading ? (
+
Loading…
+ ) : (
+ <>
+
+ Default visibility
+ void handleVisibilityChange(e.target.value as "private" | "org")}
+ style={inputStyle}
+ >
+ Organisation visible — anyone with forms access can see this form
+ Private — only you and people granted below
+
+
+
+
+
People with access
+ {grants.length === 0 ? (
+
+ No explicit grants. {visibility === "org" ? "Form is visible to your organisation." : "Only the creator can access this form."}
+
+ ) : (
+
+ {grants.map((g) => (
+
+
+ {g.subjectLabel || g.subjectId}
+
+ ({g.subjectType}) — {(g.actions || []).join(", ")}
+
+
+ void handleRemoveGrant(g._id)}
+ style={{
+ background: "none",
+ border: "none",
+ cursor: busy ? "not-allowed" : "pointer",
+ color: "var(--destructive)",
+ padding: 4,
+ }}
+ title="Remove access"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
Add access
+
+ {
+ 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
+
+ {
+ 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
+
+
+
setSubjectId(e.target.value)}
+ style={{ ...inputStyle, marginBottom: 10 }}
+ >
+ Select {subjectType === "user" ? "user" : "role"}…
+ {subjectType === "user"
+ ? users.map((u) => (
+
+ {[u.firstName, u.lastName].filter(Boolean).join(" ") || u.email} ({u.email})
+
+ ))
+ : roles.map((r) => (
+
+ {r.name}{r.isSystem ? " (system)" : ""}
+
+ ))}
+
+
+ {GRANT_ACTIONS.map((a) => (
+
+ toggleNewAction(a)}
+ />
+ {a}
+
+ ))}
+
+
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
+
+
+ >
+ )}
+
+
+ Close
+
+
+
+ );
+}
diff --git a/src/app/components/global/GlobalStubPage.tsx b/src/app/components/global/GlobalStubPage.tsx
index cb3bd4e8..e5ec9505 100644
--- a/src/app/components/global/GlobalStubPage.tsx
+++ b/src/app/components/global/GlobalStubPage.tsx
@@ -4,7 +4,7 @@ import {
Shield, Image, Layout, Tag, Mail,
Plus, Search, X, MoreHorizontal, Upload,
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
- Link2, List, Check,
+ Link2, List, Check, Share2,
} from "lucide-react";
import { useBreakpoint } from "../../hooks/useBreakpoint";
import { useAuth } from "../../contexts/AuthContext";
@@ -53,6 +53,8 @@ import { ImportParticipantsPanel } from "../panels/ImportParticipantsPanel";
import { EditParticipantPanel } from "../panels/EditParticipantPanel";
import { CreateFormPanel } from "../panels/CreateFormPanel";
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
+import { ShareFormPanel } from "../forms/ShareFormPanel";
+import { usePermission } from "../../hooks/usePermission";
import { UploadFilePanel } from "../panels/UploadFilePanel";
import { CreateCollectionPanel } from "../panels/CreateCollectionPanel";
import { MessagingEmailPage } from "../messaging/MessagingEmailPage";
@@ -1866,14 +1868,18 @@ function cloneSettingsForDuplicate(settings: unknown): unknown {
export function FormsPage({ subPage }: { subPage?: string }) {
const { user } = useAuth();
+ const { canModule } = usePermission();
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"
: subPage === "forms-registration" ? "Registration"
: subPage === "forms-other" ? "Other"
: null;
const [forms, setForms] = useState([]);
const [loading, setLoading] = useState(false);
+ const [loadError, setLoadError] = useState(null);
const [hovered, setHovered] = useState(null);
const [showFeedback, setShowFeedback] = useState(false);
const [showReg, setShowReg] = useState(false);
@@ -1883,6 +1889,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
const [formActionBusy, setFormActionBusy] = useState(null);
const [formPageMsg, setFormPageMsg] = useState(null);
const [submissionsPanelForm, setSubmissionsPanelForm] = useState(null);
+ const [shareFormTarget, setShareFormTarget] = useState(null);
const [copiedFormId, setCopiedFormId] = useState(null);
const publicFormPageUrl = (f: FormWithSubmissionCount) => {
@@ -1899,7 +1906,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
const copyFormPublicLink = (f: FormWithSubmissionCount) => {
const url = publicFormPageUrl(f);
void navigator.clipboard.writeText(url).then(() => {
- setCopiedFormId(id);
+ setCopiedFormId(f._id);
setFormPageMsg(FORM_LINK_COPIED_MSG);
window.setTimeout(() => {
setCopiedFormId(null);
@@ -1911,7 +1918,8 @@ export function FormsPage({ subPage }: { subPage?: string }) {
};
const fetchForms = useCallback(async () => {
- if (!canManageForms) {
+ setLoadError(null);
+ if (!canReadForms) {
setForms([]);
return;
}
@@ -1923,12 +1931,14 @@ export function FormsPage({ subPage }: { subPage?: string }) {
try {
const list = await formsApi.list({ organization: orgId });
setForms(list);
- } catch {
+ } catch (err) {
setForms([]);
+ const msg = err instanceof Error && err.message ? err.message : "Could not load forms.";
+ setLoadError(msg);
} finally {
setLoading(false);
}
- }, [canManageForms, orgId]);
+ }, [canReadForms, orgId]);
useEffect(() => {
fetchForms();
@@ -2026,7 +2036,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
{pageTitle}
- {canManageForms ? (
+ {canWriteForms ? (
Create Form
@@ -2039,7 +2049,11 @@ export function FormsPage({ subPage }: { subPage?: string }) {
) : null}
- {!canManageForms ? (
+ {!canReadForms ? (
+
+ } title="Access denied" description="You do not have permission to view forms." />
+
+ ) : !orgId ? (
} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
@@ -2047,6 +2061,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
} 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={() => {}} />
+ ) : loadError ? (
+
+ } title="Could not load forms" description={loadError} cta="Try again" onCta={() => void fetchForms()} />
+
) : (
@@ -2056,6 +2074,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
{loading ? (
Loading forms…
+ ) : filteredForms.length === 0 ? (
+
+ } title="No forms found" description={`There are no ${pageTitle.toLowerCase()} to show for this organization.`} />
+
) : (
filteredForms.map(f => {
const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft";
@@ -2084,26 +2106,41 @@ export function FormsPage({ subPage }: { subPage?: string }) {
>
{copiedFormId === fid ?
:
}
+ {canReadForms ? (
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)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
+ ) : null}
+ {canWriteForms ? (
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)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
+ ) : null}
+ {canWriteForms ? (
+
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)"; }}>
+
+
+ ) : null}
+ {canWriteForms ? (
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)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
+ ) : null}
+ {canDeleteForms ? (
{ 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)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}>
+ ) : null}
);
@@ -2123,15 +2160,18 @@ export function FormsPage({ subPage }: { subPage?: string }) {
) : null}
- {showFeedback && canManageForms && (
+ {showFeedback && canWriteForms && (
{ setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)}
- {showReg && canManageForms && (
+ {showReg && canWriteForms && (
{ setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)}
- {showOther && canManageForms && (
+ {showOther && canWriteForms && (
{ setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
)}
+ {shareFormTarget ? (
+ setShareFormTarget(null)} />
+ ) : null}
{submissionsPanelForm ? (
([]);
const [loading, setLoading] = useState(true);
const [hovered, setHovered] = useState(null);
@@ -2420,15 +2465,28 @@ export function UserManagementPage() {
return (
+ {!canReadUsers ? (
+
You do not have permission to view user management.
+ ) : (
+ <>
Portfolio
User Management
{users.length} users across the platform
-
{ 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)" }}>
- Invite User
-
+
+ {canModule("user_management", "read") ? (
+
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)" }}>
+ Manage Roles
+
+ ) : null}
+ {canWriteUsers ? (
+
{ 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)" }}>
+ Invite User
+
+ ) : null}
+
@@ -2439,9 +2497,11 @@ export function UserManagementPage() {
Loading users…
) : (
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 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 (
setHovered(u._id)}
@@ -2455,14 +2515,21 @@ export function UserManagementPage() {
{userDisplayName(u)}
{u.email}
-
{ROLE_LABELS[u.role] ?? u.role}
+
{roleLabel}
{eventsLabel}
+ {canWriteUsers ? (
{ 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)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
+ ) : null}
+ {canDeleteUsers ? (
+
{ 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" }}>
+
+
+ ) : null}
);
@@ -2470,7 +2537,9 @@ export function UserManagementPage() {
)}
- {showInvite && { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
+ {showInvite && canWriteUsers && { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
+ >
+ )}
);
}
diff --git a/src/app/components/microsite-v2/ManageStaticMicrositePanel.tsx b/src/app/components/microsite-v2/ManageStaticMicrositePanel.tsx
index 2f120fc8..0c055dc5 100644
--- a/src/app/components/microsite-v2/ManageStaticMicrositePanel.tsx
+++ b/src/app/components/microsite-v2/ManageStaticMicrositePanel.tsx
@@ -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 {
PanelShell,
@@ -9,6 +9,7 @@ import {
HelperText,
} from '../panels/PanelShell';
import {
+ sessionsApi,
staticMicrositeApi,
staticMicrositePublicUrl,
type EventStaticMicrosite,
@@ -18,6 +19,9 @@ import {
import { TemplatePicker } from './TemplatePicker';
import { MicrositeConfigForm } from './MicrositeConfigForm';
import { mergeConfig } from './configUtils';
+import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
+
+const SCHEDULE_SECTION_ID = 'event_schedule';
export function ManageStaticMicrositePanel({
eventId,
@@ -45,13 +49,17 @@ export function ManageStaticMicrositePanel({
const [config, setConfig] = useState({ sections: {} });
const [existing, setExisting] = useState(null);
const [publishedUrl, setPublishedUrl] = useState(null);
+ const [schemaLoading, setSchemaLoading] = useState(false);
+ const [scheduleSyncing, setScheduleSyncing] = useState(false);
+ const scheduleAutoFetchedRef = useRef(false);
+ const configureOpenedRef = useRef(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const [tplRes, siteRes] = await Promise.all([
- staticMicrositeApi.listTemplates(),
+ staticMicrositeApi.listTemplates(eventId),
staticMicrositeApi.getForEvent(eventId),
]);
@@ -71,11 +79,16 @@ export function ManageStaticMicrositePanel({
if (tpl) {
setSelectedTemplate(tpl);
- const detail = await staticMicrositeApi.getTemplate(tpl._id);
- const sch = detail.data?.schema;
- if (sch) {
+ setSchemaLoading(true);
+ try {
+ 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);
setConfig(mergeConfig(sch, site.config));
+ } finally {
+ setSchemaLoading(false);
}
}
}
@@ -90,6 +103,15 @@ export function ManageStaticMicrositePanel({
void 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(() => {
if (!subdomain || subdomain.length < 3) {
setSubdomainCheck(null);
@@ -107,11 +129,74 @@ export function ManageStaticMicrositePanel({
const selectTemplate = async (t: MicrositeV2Template) => {
setSelectedTemplate(t);
- const res = await staticMicrositeApi.getTemplate(t._id);
- const sch = res.data?.schema;
- if (sch) {
+ setError('');
+ setSchema(null);
+ 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);
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) || {};
+ 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}
onChange={setConfig}
eventId={eventId}
+ onSyncSchedule={() => void handleSyncSchedule()}
+ scheduleSyncing={scheduleSyncing}
/>
+ ) : schemaLoading ? (
+
+ Loading template configuration…
+
+ ) : selectedTemplate && error ? (
+
+ Could not load configuration for this template. Check the error above and try again.
+
) : null}
diff --git a/src/app/components/microsite-v2/MicrositeConfigForm.tsx b/src/app/components/microsite-v2/MicrositeConfigForm.tsx
index 25443fdf..510b08eb 100644
--- a/src/app/components/microsite-v2/MicrositeConfigForm.tsx
+++ b/src/app/components/microsite-v2/MicrositeConfigForm.tsx
@@ -1,5 +1,7 @@
-import React, { useRef } from 'react';
-import { Plus, Trash2 } from 'lucide-react';
+import React, { useRef, useState } from 'react';
+import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
+
+const SCHEDULE_SECTION_ID = 'event_schedule';
import { uploadMediaFile } from '../../../services/api/media';
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
import { Field, Label, HelperText } from '../panels/PanelShell';
@@ -30,7 +32,7 @@ function FieldInput({
const fileRef = useRef(null);
const [uploading, setUploading] = React.useState(false);
- const handleImage = async (file: File) => {
+ const handleUpload = async (file: File) => {
setUploading(true);
try {
const res = await uploadMediaFile(file, { eventId, module: 'microsite-v2' });
@@ -56,6 +58,7 @@ function FieldInput({
);
+ case 'video':
+ return (
+
+ {value ? (
+
+ ) : null}
+ {
+ const f = e.target.files?.[0];
+ if (f) void handleUpload(f);
+ e.target.value = '';
+ }} />
+ fileRef.current?.click()}
+ style={{
+ ...inputStyle,
+ cursor: uploading ? 'wait' : 'pointer',
+ width: 'auto',
+ alignSelf: 'flex-start',
+ }}
+ >
+ {uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
+
+
+ );
case 'array':
return (
onChange(e.target.value)}
style={inputStyle}
/>
@@ -226,11 +260,15 @@ function SectionEditor({
sectionData,
onChange,
eventId,
+ onSync,
+ syncing,
}: {
section: MicrositeV2SectionSchema;
sectionData: Record;
onChange: (data: Record) => void;
eventId: string;
+ onSync?: () => void;
+ syncing?: boolean;
}) {
const show = sectionData.show !== false;
@@ -247,6 +285,15 @@ function SectionEditor({
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 (
@@ -257,8 +304,46 @@ function SectionEditor({
/>
Show {section.label} section
+ {show && onSync ? (
+
+
+
+ {syncing ? 'Syncing…' : 'Sync from event agenda'}
+
+
+
+
+ ) : null}
{show
- ? section.fields.map((field) => (
+ ? section.fields.filter(isFieldVisible).map((field) => (
+ 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',
+ }}
+ >
+
+ {title}
+ {badge}
+
+
+
+ {open ? (
+ {children}
+ ) : null}
+
+ );
+}
+
+function HiddenBadge() {
+ return (
+
+ Hidden
+
+ );
+}
+
export function MicrositeConfigForm({
schema,
config,
onChange,
eventId,
+ onSyncSchedule,
+ scheduleSyncing,
}: {
schema: {
meta?: Record
;
@@ -288,14 +455,16 @@ export function MicrositeConfigForm({
config: StaticMicrositeConfig;
onChange: (c: StaticMicrositeConfig) => void;
eventId: string;
+ onSyncSchedule?: () => void;
+ scheduleSyncing?: boolean;
}) {
const theme = config.theme || {};
const sections = config.sections || {};
return (
-
+
{schema.meta && Object.keys(schema.meta).length > 0 ? (
-
+
-
+
) : null}
{(schema.theme?.fields || []).length > 0 ? (
-
-
Theme
+
{schema.theme!.fields!.map((field) => (
@@ -321,31 +489,34 @@ export function MicrositeConfigForm({
/>
))}
-
+
) : null}
- {schema.sections.map((section) => (
-
-
{section.label}
-
) || { show: true }}
- onChange={(data) =>
- onChange({
- ...config,
- sections: { ...sections, [section.id]: data },
- })
- }
- eventId={eventId}
- />
-
- ))}
+ {schema.sections.map((section) => {
+ const sectionData = (sections[section.id] as Record
) || { show: true };
+ const isShown = sectionData.show !== false;
+ return (
+ }
+ >
+
+ onChange({
+ ...config,
+ sections: { ...sections, [section.id]: data },
+ })
+ }
+ eventId={eventId}
+ onSync={section.id === SCHEDULE_SECTION_ID ? onSyncSchedule : undefined}
+ syncing={scheduleSyncing}
+ />
+
+ );
+ })}
);
}
diff --git a/src/app/components/microsite-v2/UploadMicrositeV2TemplatePanel.tsx b/src/app/components/microsite-v2/UploadMicrositeV2TemplatePanel.tsx
index 21e72d8b..886f6af0 100644
--- a/src/app/components/microsite-v2/UploadMicrositeV2TemplatePanel.tsx
+++ b/src/app/components/microsite-v2/UploadMicrositeV2TemplatePanel.tsx
@@ -1,7 +1,8 @@
-import React, { useState } from 'react';
+import React, { useCallback, useEffect, useState } from 'react';
import { Save } from 'lucide-react';
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 = {
width: '100%',
@@ -14,6 +15,11 @@ const inputStyle: React.CSSProperties = {
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({
template,
onClose,
@@ -28,6 +34,11 @@ export function UploadMicrositeV2TemplatePanel({
const [slug, setSlug] = useState(template?.slug || '');
const [name, setName] = useState(template?.name || '');
const [description, setDescription] = useState(template?.description || '');
+ const [visibility, setVisibility] = useState<'public' | 'private'>(template?.visibility || 'public');
+ const [allowedOrgIds, setAllowedOrgIds] = useState
(() => orgIdsFromTemplate(template));
+ const [orgSearch, setOrgSearch] = useState('');
+ const [orgOptions, setOrgOptions] = useState([]);
+ const [orgLoading, setOrgLoading] = useState(false);
const [templateHtml, setTemplateHtml] = useState(null);
const [templateJson, setTemplateJson] = useState(null);
const [thumbnail, setThumbnail] = useState(null);
@@ -35,11 +46,41 @@ export function UploadMicrositeV2TemplatePanel({
const [saving, setSaving] = useState(false);
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 () => {
setSaving(true);
setError('');
try {
+ if (visibility === 'private' && allowedOrgIds.length === 0) {
+ throw new Error('Select at least one account for a private template');
+ }
+
let templateId = template?._id;
+ const visibilityPayload = {
+ visibility,
+ allowedOrganizations: visibility === 'private' ? allowedOrgIds : [],
+ };
if (!isEdit) {
if (!slug.trim() || !name.trim()) {
@@ -49,13 +90,15 @@ export function UploadMicrositeV2TemplatePanel({
slug: slug.trim(),
name: name.trim(),
description: description.trim() || undefined,
+ ...visibilityPayload,
});
if (createRes.error || !createRes.data) throw new Error(createRes.error || 'Create failed');
templateId = createRes.data._id;
- } else if (name !== template?.name || description !== template?.description) {
+ } else {
const updateRes = await staticMicrositeApi.updateTemplate(template!._id, {
name: name.trim(),
description: description.trim(),
+ ...visibilityPayload,
});
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 (
+
+
+
+ setVisibility(e.target.value as 'public' | 'private')}
+ style={inputStyle}
+ >
+ Public — available to all accounts
+ Private — selected accounts only
+
+
+
+
+ {visibility === 'private' ? (
+
+
+ {allowedOrgIds.length > 0 ? (
+
+ {selectedOrgLabels.map((label, i) => (
+
+ {label}
+ toggleOrg(allowedOrgIds[i]!)}
+ style={{ marginLeft: '6px', border: 'none', background: 'none', cursor: 'pointer', color: 'var(--muted)' }}
+ aria-label="Remove"
+ >
+ ×
+
+
+ ))}
+
+ ) : null}
+
+ setOrgSearch(e.target.value)}
+ placeholder="Search organizations…"
+ style={inputStyle}
+ />
+
+
+ {orgLoading ? (
+
Loading…
+ ) : orgOptions.length === 0 ? (
+
No organizations found.
+ ) : (
+ orgOptions.map((org) => (
+
+ toggleOrg(org._id)} />
+
+ {org.name}
+ {org.email ? {org.email} : null}
+
+
+ ))
+ )}
+
+
+
+ ) : null}
+
setTemplateHtml(e.target.files?.[0] || null)} />
diff --git a/src/app/components/microsite-v2/scheduleSync.ts b/src/app/components/microsite-v2/scheduleSync.ts
new file mode 100644
index 00000000..acc4f9dc
--- /dev/null
+++ b/src/app/components/microsite-v2/scheduleSync.ts
@@ -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
{
+ const sorted = [...sessions].sort((a, b) => (a.startTime || '').localeCompare(b.startTime || ''));
+ const groups = new Map();
+ 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();
+ 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;
+}
diff --git a/src/app/components/panels/CreateFormPanel.tsx b/src/app/components/panels/CreateFormPanel.tsx
index 9faa00a3..5973154f 100644
--- a/src/app/components/panels/CreateFormPanel.tsx
+++ b/src/app/components/panels/CreateFormPanel.tsx
@@ -6,9 +6,16 @@ import React, { useState, useEffect, useCallback } from "react";
import { Plus, Trash2, GripVertical } from "lucide-react";
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
import { useAuth } from "../../contexts/AuthContext";
+import { usePermission } from "../../hooks/usePermission";
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
+import {
+ EVENT_HEADER_TOGGLE_LABELS,
+ eventHeaderFromApi,
+ eventHeaderToApi,
+ type EventHeaderToggleKey,
+} from "../../utils/formEventHeaderDisplay";
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
type FormMode = "feedback" | "registration" | "other" | "web-check-in";
@@ -453,6 +460,12 @@ interface FForm {
confirmationEmail: "Yes" | "No";
onSubmitTags: 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 => {
@@ -483,6 +496,12 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
confirmationEmail: "Yes",
onSubmitTags: "",
onSubmitListId: "",
+ headerShowLogo: "Yes",
+ headerShowTitle: "Yes",
+ headerShowDateRange: "Yes",
+ headerShowEventType: "Yes",
+ headerShowLocationType: "Yes",
+ headerShowVenue: "Yes",
});
function typeFromApi(
@@ -702,6 +721,9 @@ export function CreateFormPanel({
onSuccess: () => void;
}) {
const { user } = useAuth();
+ const { canModule } = usePermission();
+ const canWriteForms = canModule("forms", "write");
+ const readOnly = Boolean(formId) && !canWriteForms;
const listOrgId = portfolioListOrganizationId(user);
const isFeedback = mode === "feedback";
const isReg = mode === "registration";
@@ -780,6 +802,7 @@ export function CreateFormPanel({
: fromApiFields(data.fields, qTypes, loadMode);
const s = data.settings ?? {};
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
+ const header = eventHeaderFromApi(s.eventHeader);
setForm({
name: data.name ?? "",
description: data.description ?? "",
@@ -794,6 +817,12 @@ export function CreateFormPanel({
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
onSubmitTags: tags,
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));
} catch {
@@ -881,6 +910,14 @@ export function CreateFormPanel({
paymentEnabled: form.paymentEnabled === "Yes",
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
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
? {
onSubmitAddTags: tagArr,
@@ -954,9 +991,15 @@ export function CreateFormPanel({
completedCount={acc.done.size}
totalSections={TOTAL}
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}
>
+ {readOnly ? (
+
+ You have read-only access to this form. Fields cannot be edited.
+
+ ) : null}
- set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
+ set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} readOnly={readOnly} disabled={readOnly} />
@@ -1151,6 +1194,36 @@ export function CreateFormPanel({
+
+
+
+
+ {(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 (
+
+
+ set(formKey, v as FForm[typeof formKey])}
+ options={[
+ { label: "Show", value: "Yes" },
+ { label: "Hide", value: "No" },
+ ]}
+ />
+
+ );
+ })}
+
+
);
diff --git a/src/app/components/panels/InviteUserPanel.tsx b/src/app/components/panels/InviteUserPanel.tsx
index e9276c1b..864304a8 100644
--- a/src/app/components/panels/InviteUserPanel.tsx
+++ b/src/app/components/panels/InviteUserPanel.tsx
@@ -8,12 +8,13 @@ import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGr
import { useAuth } from "../../contexts/AuthContext";
import { usersApi } from "../../../services/api";
import { eventsApi, portfolioListOrganizationId } from "../../../services/api";
+import { rolesApi, type Role } from "../../../services/api/roles";
import type { Event, User } from "../../../services/api";
-const ROLES = [
- { value: "admin", label: "Admin", desc: "Full access to all events and settings." },
- { value: "moderator", label: "Moderator", desc: "Can manage sessions, participants, and messaging." },
- { value: "viewer", label: "Viewer", desc: "Can view events and dashboards only." },
+const SYSTEM_ROLES = [
+ { value: "admin", label: "Admin", desc: "Full access to all events and settings." },
+ { value: "moderator", label: "Moderator", desc: "Can manage sessions, participants, and messaging." },
+ { value: "viewer", label: "Viewer", desc: "Can view events and dashboards only." },
];
interface FForm {
@@ -22,6 +23,8 @@ interface FForm {
email: string;
password: string;
role: string;
+ roleMode: "system" | "custom";
+ customRoleId: string;
assignedEvents: string[];
sendInviteNow: "Yes" | "No";
personalMessage: string;
@@ -33,6 +36,8 @@ const initialForm: FForm = {
email: "",
password: "",
role: "moderator",
+ roleMode: "system",
+ customRoleId: "",
assignedEvents: [],
sendInviteNow: "Yes",
personalMessage: "",
@@ -42,46 +47,62 @@ function isValidEmail(s: string): boolean {
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 }) {
const { user } = useAuth();
const listOrgId = portfolioListOrganizationId(user);
const isEdit = !!editUser;
const TOTAL = 3;
const acc = useAccordion(TOTAL);
- const [form, setForm] = useState(() =>
- editUser
- ? {
- firstName: editUser.firstName ?? "",
- lastName: editUser.lastName ?? "",
- email: editUser.email ?? "",
- password: "",
- role: editUser.role ?? "moderator",
- assignedEvents: [],
- sendInviteNow: "Yes",
- personalMessage: "",
- }
- : initialForm
- );
+ const [form, setForm] = useState(() => (editUser ? formFromUser(editUser) : initialForm));
const [sectionErrors, setSectionErrors] = useState>({});
const [submitting, setSubmitting] = useState(false);
const [events, setEvents] = useState([]);
const [eventsLoading, setEventsLoading] = useState(true);
+ const [customRoles, setCustomRoles] = useState([]);
useEffect(() => {
- if (editUser) {
- setForm({
- firstName: editUser.firstName ?? "",
- lastName: editUser.lastName ?? "",
- email: editUser.email ?? "",
- password: "",
- role: editUser.role ?? "moderator",
- assignedEvents: [],
- sendInviteNow: "Yes",
- personalMessage: "",
- });
- }
+ if (editUser) setForm(formFromUser(editUser));
}, [editUser?._id]);
+ useEffect(() => {
+ rolesApi.list().then((r) => {
+ if (r.data?.roles) {
+ setCustomRoles(r.data.roles.filter((role) => !role.isSystem));
+ }
+ });
+ }, []);
+
useEffect(() => {
let cancelled = false;
(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;
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;
return true;
}, [isEdit]);
@@ -129,21 +153,31 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
if (valid) acc.advance(n);
}, [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 () => {
- if (!validateSection(form, 1)) {
- setSectionErrors(prev => ({ ...prev, 1: "Please complete all required fields." }));
+ if (!validateSection(form, 1) || !validateSection(form, 2)) {
+ setSectionErrors(prev => ({ ...prev, 1: "Please complete all required fields.", 2: form.roleMode === "custom" && !form.customRoleId ? "Select a custom role." : "" }));
return;
}
setSubmitting(true);
setSectionErrors({});
+ const payload = buildPayload();
if (isEdit && editUser) {
const res = await usersApi.update(editUser._id, {
- email: form.email.trim(),
- firstName: form.firstName.trim() || undefined,
- lastName: form.lastName.trim() || undefined,
- role: form.role,
+ ...payload,
...(form.password ? { password: form.password } : {}),
- assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
});
setSubmitting(false);
if (res.error) {
@@ -152,12 +186,8 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
}
} else {
const res = await usersApi.create({
- email: form.email.trim(),
+ ...payload,
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);
if (res.error) {
@@ -167,7 +197,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
}
onSuccess?.();
onClose();
- }, [form, validateSection, isEdit, editUser, onSuccess, onClose]);
+ }, [form, validateSection, isEdit, editUser, onSuccess, onClose, buildPayload]);
const onSaveLast = useCallback(() => {
if (!validateSection(form, 3)) {
@@ -184,8 +214,11 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
form.email.trim() &&
isValidEmail(form.email) &&
!submitting &&
+ (form.roleMode !== "custom" || Boolean(form.customRoleId)) &&
(!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 (
- {/* 1. User Details */}
-
+
set("password", e.target.value)} onFocus={focusIn} onBlur={focusOut} minLength={6} />
-
+
- {/* 2. Role & Permissions */}
-
- {ROLES.map(r => (
-
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" }}>
-
- {form.role === r.value &&
}
-
-
-
- ))}
+
+ set("roleMode", "system")}
+ style={{
+ flex: 1,
+ padding: "8px 12px",
+ borderRadius: "var(--radius-button)",
+ border: `2px solid ${form.roleMode === "system" ? "var(--primary)" : "var(--border)"}`,
+ background: form.roleMode === "system" ? "var(--accent)" : "var(--card)",
+ cursor: "pointer",
+ fontSize: "var(--text-sm)",
+ }}
+ >
+ System role
+
+ 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
+
- {selectedRole && (
-
+ {form.roleMode === "system" ? (
+
+ {SYSTEM_ROLES.map(r => (
+
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" }}>
+
+ {form.role === r.value &&
}
+
+
+
+ ))}
+
+ ) : (
+
+
+ set("customRoleId", e.target.value)}
+ style={{ ...IS, width: "100%" }}
+ >
+ Select a role…
+ {customRoles.map((r) => (
+ {r.name}
+ ))}
+
+ {customRoles.length === 0 && (
+
+ )}
+
+ )}
+ {(selectedSystemRole && form.roleMode === "system") || (selectedCustomRole && form.roleMode === "custom") ? (
+
- {selectedRole.label}: {selectedRole.desc}
+
+ {form.roleMode === "custom" ? selectedCustomRole?.name : selectedSystemRole?.label}:
+ {" "}
+ {form.roleMode === "custom"
+ ? selectedCustomRole?.description || "Custom permissions apply."
+ : selectedSystemRole?.desc}
- )}
+ ) : null}
- {/* 3. Event Access & Invite */}
+ {!isEdit ? (
@@ -320,6 +407,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
+ ) : null}
);
diff --git a/src/app/components/panels/PanelShell.tsx b/src/app/components/panels/PanelShell.tsx
index c9e45371..cba705c2 100644
--- a/src/app/components/panels/PanelShell.tsx
+++ b/src/app/components/panels/PanelShell.tsx
@@ -286,5 +286,10 @@ export function useAccordion(total: number) {
if (n < total) setOpen(n + 1);
};
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 };
}
diff --git a/src/app/components/permissions/PermissionsPage.tsx b/src/app/components/permissions/PermissionsPage.tsx
index 5fdfa879..996f5f3e 100644
--- a/src/app/components/permissions/PermissionsPage.tsx
+++ b/src/app/components/permissions/PermissionsPage.tsx
@@ -11,8 +11,10 @@ import {
type PendingInvite,
type AgendaTrack,
} from "../../../services/api/eventStaff";
-
-const ROLES = ["Moderator", "Presenter", "Co-organiser", "Viewer"];
+import { buildEventAccessPayload } from "../../utils/eventAccessPayload";
+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 {
if (firstName || lastName) {
@@ -133,9 +135,10 @@ function SessionTreeSection({
/* Add existing org users to event */
function AddUsersModal({
eventId,
+ roleOptions,
onClose,
onSuccess,
-}: { eventId: string; onClose: () => void; onSuccess: () => void }) {
+}: { eventId: string; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
const [step, setStep] = useState<"users" | "sessions">("users");
const [available, setAvailable] = useState<{ _id: string; email: string; firstName?: string; lastName?: string; role: string }[]>([]);
const [tracks, setTracks] = useState
([]);
@@ -187,9 +190,11 @@ function AddUsersModal({
const handleAdd = async () => {
setError(null);
setSubmitting(true);
+ const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
const res = await eventStaffApi.addStaff(eventId, {
userIds: Array.from(selectedUserIds),
- sessionIds: Array.from(sessionIds),
+ sessionIds: sids,
+ trackIds: tids,
eventRole,
});
setSubmitting(false);
@@ -228,7 +233,7 @@ function AddUsersModal({
Event role (applies to all selected)
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) => {r} )}
+ {roleOptions.map((r) => {r} )}
Select users
@@ -278,9 +283,10 @@ function AddUsersModal({
interface InviteRow { id: string; email: string; role: string; }
function InviteModal({
eventId,
+ roleOptions,
onClose,
onSuccess,
-}: { eventId: string; onClose: () => void; onSuccess: () => void }) {
+}: { eventId: string; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
const [rows, setRows] = useState([{ id: "r1", email: "", role: "Viewer" }]);
const [step, setStep] = useState<"users" | "sessions">("users");
const [tracks, setTracks] = useState([]);
@@ -288,6 +294,7 @@ function InviteModal({
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<{ sent: number; errors: { email: string; message: string }[] } | null>(null);
+ const [requestError, setRequestError] = useState(null);
const allSessionIds = useMemo(() => allSessionIdsFromTracks(tracks), [tracks]);
@@ -314,17 +321,25 @@ function InviteModal({
const handleSend = async () => {
setResult(null);
+ setRequestError(null);
setSubmitting(true);
+ const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
const res = await eventStaffApi.sendInvites(eventId, {
invites: validRows.map((r) => ({ email: r.email.trim(), eventRole: r.role || "Viewer" })),
- sessionIds: Array.from(sessionIds),
+ sessionIds: sids,
+ trackIds: tids,
});
setSubmitting(false);
- if (res.data) setResult(res.data);
- if (res.error && !res.data) return;
- if (res.data && (res.data.sent > 0 || res.data.errors?.length === 0)) {
- onSuccess();
- onClose();
+ if (res.error && !res.data) {
+ setRequestError(res.error);
+ return;
+ }
+ 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({
Role *
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)" }}>
- {ROLES.map((r) => {r} )}
+ {roleOptions.map((r) => {r} )}
{rows.length > 1 && (
@@ -380,6 +395,11 @@ function InviteModal({
loading ?
Loading sessions…
:
)}
+ {requestError && (
+
+ {requestError}
+
+ )}
{result?.errors && result.errors.length > 0 && (
{result.errors.map((e, i) =>
{e.email}: {e.message}
)}
@@ -411,9 +431,10 @@ function InviteModal({
function EditAccessModal({
eventId,
member,
+ roleOptions,
onClose,
onSuccess,
-}: { eventId: string; member: EventStaffMember; onClose: () => void; onSuccess: () => void }) {
+}: { eventId: string; member: EventStaffMember; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
const [tracks, setTracks] = useState
([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
@@ -435,7 +456,12 @@ function EditAccessModal({
const handleSave = async () => {
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);
if (!res.error) {
onSuccess();
@@ -457,7 +483,7 @@ function EditAccessModal({
Event role
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) => {r} )}
+ {roleOptions.map((r) => {r} )}
{loading ? Loading sessions…
: }
@@ -473,6 +499,8 @@ function EditAccessModal({
export function PermissionsPage() {
const { selectedEventId, permissionsOpenAddOnce, setPermissionsOpenAddOnce } = useAppNav();
+ const { canModule } = usePermission();
+ const canWriteStaff = canModule("user_management", "write");
const [eventName, setEventName] = useState(null);
const [members, setMembers] = useState([]);
const [invites, setInvites] = useState([]);
@@ -484,6 +512,13 @@ export function PermissionsPage() {
const [showAdd, setShowAdd] = useState(false);
const [showInvite, setShowInvite] = useState(false);
const [editMember, setEditMember] = useState(null);
+ const [orgRoles, setOrgRoles] = useState([]);
+
+ const eventRoleOptions = useMemo(() => buildEventRoleOptions(orgRoles), [orgRoles]);
+ const editRoleOptions = useMemo(
+ () => buildEventRoleOptions(orgRoles, editMember?.eventRole),
+ [orgRoles, editMember?.eventRole],
+ );
const loadData = async () => {
if (!selectedEventId) {
@@ -515,6 +550,15 @@ export function PermissionsPage() {
loadData();
}, [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(() => {
if (!permissionsOpenAddOnce) return;
setShowAdd(true);
@@ -580,12 +624,16 @@ export function PermissionsPage() {
+ {canWriteStaff ? (
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" }}>
Add users
+ ) : null}
+ {canWriteStaff ? (
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 }}>
Invite by email
+ ) : null}
@@ -625,13 +673,17 @@ export function PermissionsPage() {
{m.email}
{m.eventRole || "Viewer"}
-
+
+ {canWriteStaff ? (
+ <>
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" }}>
Edit
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" }}>
+ >
+ ) : null}
))}
@@ -648,13 +700,17 @@ export function PermissionsPage() {
Pending invite
{inv.eventRole}
-
+
+ {canWriteStaff ? (
+ <>
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" }}>
Resend
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" }}>
+ >
+ ) : null}
))}
@@ -662,9 +718,9 @@ export function PermissionsPage() {
)}
- {showAdd && selectedEventId && setShowAdd(false)} onSuccess={loadData} />}
- {showInvite && selectedEventId && setShowInvite(false)} onSuccess={loadData} />}
- {editMember && selectedEventId && setEditMember(null)} onSuccess={loadData} />}
+ {showAdd && selectedEventId && setShowAdd(false)} onSuccess={loadData} />}
+ {showInvite && selectedEventId && setShowInvite(false)} onSuccess={loadData} />}
+ {editMember && selectedEventId && setEditMember(null)} onSuccess={loadData} />}
);
}
diff --git a/src/app/components/roles/RolesPage.tsx b/src/app/components/roles/RolesPage.tsx
new file mode 100644
index 00000000..49482e1f
--- /dev/null
+++ b/src/app/components/roles/RolesPage.tsx
@@ -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([]);
+ const [catalog, setCatalog] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [editing, setEditing] = useState(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 You do not have access to role management.
;
+ }
+
+ 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 (
+
+
+
+
Custom Roles
+
+
+ {canModule("user_management", "write") && (
+
+
{editing ? "Edit role" : "New role"}
+ {error &&
{error}
}
+
setForm((f) => ({ ...f, name: e.target.value }))}
+ style={{ width: "100%", marginBottom: 8, padding: 8, borderRadius: 8, border: "1px solid var(--border)" }}
+ />
+
+
+ {editing ? "Update" : "Create"}
+
+
+ )}
+
+ {loading ? (
+
Loading roles…
+ ) : (
+ roles.map((r) => (
+
+
{r.name} {r.isSystem ? " (system)" : ""}
+ {!r.isSystem && canModule("user_management", "write") && (
+
+ startEdit(r)}>
+ {canModule("user_management", "delete") && (
+ remove(r._id)}>
+ )}
+
+ )}
+
+ ))
+ )}
+
+ );
+}
diff --git a/src/app/components/website/MicrositeEditor.tsx b/src/app/components/website/MicrositeEditor.tsx
index d744983f..296d220d 100644
--- a/src/app/components/website/MicrositeEditor.tsx
+++ b/src/app/components/website/MicrositeEditor.tsx
@@ -14,7 +14,7 @@ function builderIframeSrc(): 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);
}
diff --git a/src/app/contexts/AuthContext.tsx b/src/app/contexts/AuthContext.tsx
index 4a6e3aeb..c9a1f445 100644
--- a/src/app/contexts/AuthContext.tsx
+++ b/src/app/contexts/AuthContext.tsx
@@ -118,12 +118,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
}
if (d && "user" in d && d.user) {
- setStoredUser(d.user);
- setState((s) => ({ ...s, user: d.user }));
+ await refreshUser();
return { success: true };
}
return { error: "Login failed" };
- }, []);
+ }, [refreshUser]);
const loginWithGoogleCredential = useCallback(async (idToken: string): Promise => {
let res = await authApi.googleLogin(idToken);
@@ -140,23 +139,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
}
if (d && "user" in d && d.user) {
- setStoredUser(d.user);
- setState((s) => ({ ...s, user: d.user }));
+ await refreshUser();
return { success: true };
}
return { error: "Google sign-in failed" };
- }, []);
+ }, [refreshUser]);
const completeTwoFactorLogin = useCallback(async (twoFactorToken: string, code: string) => {
const res = await authApi.completeLogin2fa(twoFactorToken, code.replace(/\s/g, ""));
if (res.error) return { error: res.error };
if (res.data?.user) {
- setStoredUser(res.data.user);
- setState((s) => ({ ...s, user: res.data!.user }));
+ await refreshUser();
return {};
}
return { error: "Verification failed" };
- }, []);
+ }, [refreshUser]);
const register = useCallback(
async (payload: {
diff --git a/src/app/contexts/PermissionContext.tsx b/src/app/contexts/PermissionContext.tsx
new file mode 100644
index 00000000..a0e0b91f
--- /dev/null
+++ b/src/app/contexts/PermissionContext.tsx
@@ -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 = {
+ read: ["read"],
+ write: ["read", "write"],
+ delete: ["read", "write", "delete"],
+};
+
+const PermissionContext = createContext(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 {children} ;
+}
+
+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 = {
+ 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");
+}
diff --git a/src/app/hooks/usePermission.ts b/src/app/hooks/usePermission.ts
new file mode 100644
index 00000000..1ed9da31
--- /dev/null
+++ b/src/app/hooks/usePermission.ts
@@ -0,0 +1 @@
+export { usePermission, PermissionProvider, navItemAllowed, NAV_MODULE_MAP } from "../contexts/PermissionContext";
diff --git a/src/app/utils/eventAccessPayload.ts b/src/app/utils/eventAccessPayload.ts
new file mode 100644
index 00000000..5e71110e
--- /dev/null
+++ b/src/app/utils/eventAccessPayload.ts
@@ -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[],
+ 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 };
+}
diff --git a/src/app/utils/eventRoleOptions.ts b/src/app/utils/eventRoleOptions.ts
new file mode 100644
index 00000000..16c445f6
--- /dev/null
+++ b/src/app/utils/eventRoleOptions.ts
@@ -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();
+ 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;
+}
diff --git a/src/app/utils/formEventHeaderDisplay.ts b/src/app/utils/formEventHeaderDisplay.ts
new file mode 100644
index 00000000..083881e7
--- /dev/null
+++ b/src/app/utils/formEventHeaderDisplay.ts
@@ -0,0 +1,59 @@
+export interface FormEventHeaderDisplay {
+ showLogo?: boolean;
+ showTitle?: boolean;
+ showDateRange?: boolean;
+ showEventType?: boolean;
+ showLocationType?: boolean;
+ showVenue?: boolean;
+}
+
+export type ResolvedFormEventHeaderDisplay = Required;
+
+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 = {
+ 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 {
+ 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,
+): 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",
+ };
+}
diff --git a/src/app/views/SuperAdminMicrositeV2TemplatesPage.tsx b/src/app/views/SuperAdminMicrositeV2TemplatesPage.tsx
index 6e3124ec..1d6243f0 100644
--- a/src/app/views/SuperAdminMicrositeV2TemplatesPage.tsx
+++ b/src/app/views/SuperAdminMicrositeV2TemplatesPage.tsx
@@ -1,5 +1,5 @@
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 { 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 (
<>
@@ -122,6 +145,7 @@ export function SuperAdminMicrositeV2TemplatesPage() {
Name
Slug
Version
+
Visibility
Active
Actions
@@ -139,6 +163,7 @@ export function SuperAdminMicrositeV2TemplatesPage() {
{t.name}
{t.slug}
v{t.version}
+
{visibilityLabel(t)}
{ setEditTarget(t); setPanelOpen(true); }} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
+ void handleDuplicate(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
+
+
void handlePreview(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
diff --git a/src/services/api/client.ts b/src/services/api/client.ts
index ee625a3a..f7ac6a7e 100644
--- a/src/services/api/client.ts
+++ b/src/services/api/client.ts
@@ -16,7 +16,7 @@ import {
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 STORAGE_KEYS = {
diff --git a/src/services/api/eventStaff.ts b/src/services/api/eventStaff.ts
index 111f9d4f..86640fb7 100644
--- a/src/services/api/eventStaff.ts
+++ b/src/services/api/eventStaff.ts
@@ -7,6 +7,7 @@ export interface EventStaffMember {
lastName?: string;
role: string;
sessionIds: string[];
+ trackIds?: string[];
eventRole: string;
}
@@ -37,16 +38,16 @@ export const eventStaffApi = {
agendaTree(eventId: string) {
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(`/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(`/events/${eventId}/staff/${userId}`, body);
},
removeStaff(eventId: string, userId: string) {
return api.delete(`/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);
},
cancelInvite(eventId: string, inviteId: string) {
diff --git a/src/services/api/forms.ts b/src/services/api/forms.ts
index 4a15a68e..7cf70088 100644
--- a/src/services/api/forms.ts
+++ b/src/services/api/forms.ts
@@ -36,6 +36,7 @@ export const formsApi = {
fields?: unknown[];
settings?: unknown;
event: string;
+ defaultVisibility?: "private" | "org";
}): Promise