Add ACL roles, form sharing, and microsite v2 improvements.
Merge remote document upload changes while keeping permission-gated forms UI and share workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
248caa7029
commit
bad7d38777
|
|
@ -12,7 +12,7 @@
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
<script type="module" crossorigin src="/assets/index-D03jbJi-.js"></script>
|
<script type="module" crossorigin src="/assets/index-DJFhkFks.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-YPkmj8ow.css">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
import { AuthProvider } from "./contexts/AuthContext";
|
||||||
|
import { PermissionProvider } from "./contexts/PermissionContext";
|
||||||
import { ErrorBoundary, GlobalMutationLoadingOverlay } from "./components/shared";
|
import { ErrorBoundary, GlobalMutationLoadingOverlay } from "./components/shared";
|
||||||
import { LoginView, RegisterView } from "./views/AuthViews";
|
import { LoginView, RegisterView } from "./views/AuthViews";
|
||||||
import { AcceptEventInviteView } from "./views/AcceptEventInviteView";
|
import { AcceptEventInviteView } from "./views/AcceptEventInviteView";
|
||||||
|
|
@ -19,6 +20,7 @@ export default function App() {
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<PermissionProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<GlobalMutationLoadingOverlay />
|
<GlobalMutationLoadingOverlay />
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
@ -41,6 +43,7 @@ export default function App() {
|
||||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</PermissionProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
} from "./global/GlobalStubPage";
|
} from "./global/GlobalStubPage";
|
||||||
import { PaymentSettingsPage } from "./global/PaymentSettingsPage";
|
import { PaymentSettingsPage } from "./global/PaymentSettingsPage";
|
||||||
import { SettingsPage } from "./global/SettingsPage";
|
import { SettingsPage } from "./global/SettingsPage";
|
||||||
|
import { RolesPage } from "./roles/RolesPage";
|
||||||
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
|
import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage";
|
||||||
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
|
import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage";
|
||||||
import { SearchResultsPage } from "./search/SearchResultsPage";
|
import { SearchResultsPage } from "./search/SearchResultsPage";
|
||||||
|
|
@ -127,6 +128,7 @@ export function MainContent() {
|
||||||
return <MessagingPage subPage={activeNav} />;
|
return <MessagingPage subPage={activeNav} />;
|
||||||
if (activeNav === "payment") return <PaymentSettingsPage />;
|
if (activeNav === "payment") return <PaymentSettingsPage />;
|
||||||
if (activeNav === "user-management") return <UserManagementPage />;
|
if (activeNav === "user-management") return <UserManagementPage />;
|
||||||
|
if (activeNav === "roles-management") return <RolesPage />;
|
||||||
if (activeNav === "settings")
|
if (activeNav === "settings")
|
||||||
return (
|
return (
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,19 @@ import {
|
||||||
BarChart2, Trophy, ClipboardCheck, Zap, CalendarDays,
|
BarChart2, Trophy, ClipboardCheck, Zap, CalendarDays,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useBreakpoint } from "../hooks/useBreakpoint";
|
import { useBreakpoint } from "../hooks/useBreakpoint";
|
||||||
|
import { navItemAllowed, usePermission } from "../contexts/PermissionContext";
|
||||||
|
|
||||||
|
/** Event nav tab → ACL module for read access */
|
||||||
|
const EVENT_TAB_MODULES: Record<string, string> = {
|
||||||
|
dashboard: "events",
|
||||||
|
agenda: "sessions",
|
||||||
|
participants: "participants",
|
||||||
|
attendance: "participants",
|
||||||
|
"event-activities": "live_polls",
|
||||||
|
rewards: "gamification",
|
||||||
|
analytics: "events",
|
||||||
|
permissions: "user_management",
|
||||||
|
};
|
||||||
|
|
||||||
/* ─────────────────────────────────────
|
/* ─────────────────────────────────────
|
||||||
Event-specific nav items
|
Event-specific nav items
|
||||||
|
|
@ -37,6 +50,12 @@ interface NavBarProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const visibleTabs = EVENT_TABS.filter((tab) => {
|
||||||
|
const mod = EVENT_TAB_MODULES[tab.id];
|
||||||
|
if (!mod) return true;
|
||||||
|
return canModule(mod, "read");
|
||||||
|
});
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
|
|
@ -93,7 +112,7 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) {
|
||||||
msOverflowStyle: "none" as any,
|
msOverflowStyle: "none" as any,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{EVENT_TABS.map(tab => {
|
{visibleTabs.map(tab => {
|
||||||
const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz"));
|
const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz"));
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useBreakpoint } from "../hooks/useBreakpoint";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTheme } from "../contexts/ThemeContext";
|
import { useTheme } from "../contexts/ThemeContext";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { navItemAllowed, usePermission } from "../contexts/PermissionContext";
|
||||||
|
|
||||||
/* ─────────────────────────────────────
|
/* ─────────────────────────────────────
|
||||||
Types
|
Types
|
||||||
|
|
@ -135,7 +136,9 @@ function NavList({
|
||||||
selectedEventId?: string | null;
|
selectedEventId?: string | null;
|
||||||
onEventSelect?: (id: string) => void;
|
onEventSelect?: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const defaultOpen = PORTFOLIO_NAV.filter(n => n.children).reduce<Record<string, boolean>>((acc, item) => {
|
const { canModule } = usePermission();
|
||||||
|
const visibleNav = PORTFOLIO_NAV.filter((item) => navItemAllowed(item.id, canModule));
|
||||||
|
const defaultOpen = visibleNav.filter(n => n.children).reduce<Record<string, boolean>>((acc, item) => {
|
||||||
acc[item.id] = isChildActive(item, activeItem);
|
acc[item.id] = isChildActive(item, activeItem);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
@ -166,7 +169,7 @@ function NavList({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{PORTFOLIO_NAV.map(item => {
|
{visibleNav.map(item => {
|
||||||
|
|
||||||
/* ── Special: All Events accordion ── */
|
/* ── Special: All Events accordion ── */
|
||||||
if (item.id === "events") {
|
if (item.id === "events") {
|
||||||
|
|
@ -256,7 +259,7 @@ function NavList({
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={{ paddingLeft: "14px", marginBottom: "2px" }}>
|
<div style={{ paddingLeft: "14px", marginBottom: "2px" }}>
|
||||||
<div style={{ borderLeft: "1px solid var(--sidebar-border)", paddingLeft: "8px" }}>
|
<div style={{ borderLeft: "1px solid var(--sidebar-border)", paddingLeft: "8px" }}>
|
||||||
{item.children!.map(child => {
|
{item.children!.filter((child) => navItemAllowed(child.id, canModule)).map(child => {
|
||||||
const cActive = activeItem === child.id;
|
const cActive = activeItem === child.id;
|
||||||
return (
|
return (
|
||||||
<button key={child.id} onClick={() => onNavigate(child.id)}
|
<button key={child.id} onClick={() => onNavigate(child.id)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
import { BarChart2, CalendarDays, Check, FileText, Globe, Link2, Plus, Share2, ShieldCheck, Tag, Ticket, Trophy, Upload } from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
import { eventsApi } from "../../../services/api/events";
|
import { eventsApi } from "../../../services/api/events";
|
||||||
import { eventStaffApi } from "../../../services/api/eventStaff";
|
import { eventStaffApi } from "../../../services/api/eventStaff";
|
||||||
import { quizPollsApi } from "../../../services/api/quizPolls";
|
import { quizPollsApi } from "../../../services/api/quizPolls";
|
||||||
|
|
@ -11,6 +13,7 @@ import { countConfiguredRewardRules, RewardRulesSettingsPanel } from "../rewards
|
||||||
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
import { ManageStaticMicrositePanel } from "../microsite-v2/ManageStaticMicrositePanel";
|
||||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||||
|
import { ShareFormPanel } from "../forms/ShareFormPanel";
|
||||||
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
import { CreateBadgePanel } from "../panels/CreateBadgePanel";
|
||||||
import { EventBadgesPanel } from "./EventBadgesPanel";
|
import { EventBadgesPanel } from "./EventBadgesPanel";
|
||||||
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
import { EventDocumentsPanel } from "./EventDocumentsPanel";
|
||||||
|
|
@ -129,9 +132,9 @@ function moduleButtonStyle(primary = false): React.CSSProperties {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
padding: "6px 8px",
|
padding: "6px 8px",
|
||||||
borderRadius: "var(--radius-button)",
|
borderRadius: "var(--radius-button)",
|
||||||
border: primary ? "none" : "1px solid var(--border)",
|
border: primary ? "1px solid var(--primary)" : "1px solid var(--border)",
|
||||||
background: primary ? "var(--primary)" : "var(--card)",
|
background: "var(--card)",
|
||||||
color: primary ? "var(--primary-foreground)" : "var(--card-foreground)",
|
color: primary ? "var(--primary)" : "var(--card-foreground)",
|
||||||
fontSize: "var(--text-xs)",
|
fontSize: "var(--text-xs)",
|
||||||
fontFamily: "var(--font-noto-sans)",
|
fontFamily: "var(--font-noto-sans)",
|
||||||
fontWeight: "var(--font-weight-medium)",
|
fontWeight: "var(--font-weight-medium)",
|
||||||
|
|
@ -148,6 +151,13 @@ function formDisplayType(apiType: string): string {
|
||||||
return apiType.charAt(0).toUpperCase() + apiType.slice(1);
|
return apiType.charAt(0).toUpperCase() + apiType.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NEW_FORM_OPTIONS: { value: "registration" | "feedback" | "web-check-in" | "other"; label: string }[] = [
|
||||||
|
{ value: "registration", label: "Registration" },
|
||||||
|
{ value: "feedback", label: "Feedback" },
|
||||||
|
{ value: "web-check-in", label: "Web check-in" },
|
||||||
|
{ value: "other", label: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" {
|
function formPanelMode(apiType: string): "feedback" | "registration" | "other" | "web-check-in" {
|
||||||
if (apiType === "feedback") return "feedback";
|
if (apiType === "feedback") return "feedback";
|
||||||
if (apiType === "registration") return "registration";
|
if (apiType === "registration") return "registration";
|
||||||
|
|
@ -173,11 +183,26 @@ function formLinkCopyButtonStyle(copied: boolean): React.CSSProperties {
|
||||||
color: copied ? "#15803d" : "var(--muted)",
|
color: copied ? "#15803d" : "var(--muted)",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
padding: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formHeaderIconButtonStyle(): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
...formLinkCopyButtonStyle(false),
|
||||||
|
color: "var(--muted)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventHomeModules() {
|
export function EventHomeModules() {
|
||||||
const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav();
|
const { selectedEventId, setActiveNav, setPermissionsOpenAddOnce, setOpenAgendaSessionPanelOnce } = useAppNav();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const isSuperAdmin = user?.role === "super_admin";
|
||||||
|
const canReadForms = canModule("forms", "read");
|
||||||
|
const canWriteForms = canModule("forms", "write");
|
||||||
|
const canWriteSessions = canModule("sessions", "write");
|
||||||
|
const canWriteUserMgmt = canModule("user_management", "write");
|
||||||
const bp = useBreakpoint();
|
const bp = useBreakpoint();
|
||||||
const isMobile = bp === "mobile";
|
const isMobile = bp === "mobile";
|
||||||
const isTablet = bp === "tablet";
|
const isTablet = bp === "tablet";
|
||||||
|
|
@ -189,7 +214,9 @@ export function EventHomeModules() {
|
||||||
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
|
const [eventForms, setEventForms] = useState<FormWithSubmissionCount[]>([]);
|
||||||
const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null);
|
const [formEditTarget, setFormEditTarget] = useState<FormWithSubmissionCount | null>(null);
|
||||||
const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null);
|
const [formResponsesTarget, setFormResponsesTarget] = useState<FormWithSubmissionCount | null>(null);
|
||||||
|
const [formShareTarget, setFormShareTarget] = useState<FormWithSubmissionCount | null>(null);
|
||||||
const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | "web-check-in" | null>(null);
|
const [newFormMode, setNewFormMode] = useState<"registration" | "feedback" | "other" | "web-check-in" | null>(null);
|
||||||
|
const [newFormType, setNewFormType] = useState<"registration" | "feedback" | "web-check-in" | "other">("registration");
|
||||||
const [copiedFormLinkId, setCopiedFormLinkId] = useState<string | null>(null);
|
const [copiedFormLinkId, setCopiedFormLinkId] = useState<string | null>(null);
|
||||||
const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 });
|
const [pollQuizCounts, setPollQuizCounts] = useState({ polls: 0, quizzes: 0 });
|
||||||
const [pollQuizLoading, setPollQuizLoading] = useState(false);
|
const [pollQuizLoading, setPollQuizLoading] = useState(false);
|
||||||
|
|
@ -587,11 +614,11 @@ export function EventHomeModules() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled || !canWriteSessions}
|
||||||
style={{
|
style={{
|
||||||
...moduleButtonStyle(true),
|
...moduleButtonStyle(true),
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
opacity: actionsDisabled || !canWriteSessions ? 0.55 : 1,
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
cursor: actionsDisabled || !canWriteSessions ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenAgendaSessionPanelOnce(true);
|
setOpenAgendaSessionPanelOnce(true);
|
||||||
|
|
@ -603,7 +630,7 @@ export function EventHomeModules() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{eventForms.map((form) => {
|
{canReadForms ? eventForms.map((form) => {
|
||||||
const linkCopied = copiedFormLinkId === form._id;
|
const linkCopied = copiedFormLinkId === form._id;
|
||||||
return (
|
return (
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
|
|
@ -612,32 +639,59 @@ export function EventHomeModules() {
|
||||||
title={form.name?.trim() || "Untitled form"}
|
title={form.name?.trim() || "Untitled form"}
|
||||||
meta={formMetaLine(form)}
|
meta={formMetaLine(form)}
|
||||||
headerAction={
|
headerAction={
|
||||||
<button
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: "4px", flexShrink: 0 }}>
|
||||||
type="button"
|
<button
|
||||||
title={linkCopied ? "Copied to clipboard" : "Copy public form link"}
|
type="button"
|
||||||
disabled={actionsDisabled}
|
title={linkCopied ? "Copied to clipboard" : "Copy public form link"}
|
||||||
style={{
|
disabled={actionsDisabled}
|
||||||
...formLinkCopyButtonStyle(linkCopied),
|
style={{
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
...formLinkCopyButtonStyle(linkCopied),
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
}}
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
onClick={() => copyFormPublicLink(form)}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onClick={() => copyFormPublicLink(form)}
|
||||||
if (actionsDisabled || linkCopied) return;
|
onMouseEnter={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
if (actionsDisabled || linkCopied) return;
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||||
}}
|
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||||
onMouseLeave={(e) => {
|
}}
|
||||||
if (linkCopied) return;
|
onMouseLeave={(e) => {
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
if (linkCopied) return;
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||||
}}
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
>
|
}}
|
||||||
{linkCopied ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
|
>
|
||||||
</button>
|
{linkCopied ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
|
||||||
|
</button>
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Share form"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...formHeaderIconButtonStyle(),
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setFormShareTarget(form)}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (actionsDisabled) return;
|
||||||
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
||||||
|
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Share2 size={12} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
|
{canWriteForms ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
|
|
@ -660,6 +714,8 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canReadForms ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
|
|
@ -672,11 +728,54 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
Responses
|
Responses
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
}) : null}
|
||||||
|
{canWriteForms ? (
|
||||||
|
<ModuleCard
|
||||||
|
icon={<Plus size={18} />}
|
||||||
|
title="Create New Form"
|
||||||
|
description="Add a form assigned to this event"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
value={newFormType}
|
||||||
|
onChange={(e) => setNewFormType(e.target.value as typeof newFormType)}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(),
|
||||||
|
flex: "1 1 0",
|
||||||
|
appearance: "auto",
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{NEW_FORM_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={actionsDisabled}
|
||||||
|
style={{
|
||||||
|
...moduleButtonStyle(true),
|
||||||
|
flex: "0 0 auto",
|
||||||
|
opacity: actionsDisabled ? 0.55 : 1,
|
||||||
|
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setNewFormMode(newFormType)}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<ShieldCheck size={18} />}
|
icon={<ShieldCheck size={18} />}
|
||||||
title="Users & Permissions"
|
title="Users & Permissions"
|
||||||
|
|
@ -705,6 +804,7 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
|
{canWriteUserMgmt ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
|
|
@ -720,6 +820,7 @@ export function EventHomeModules() {
|
||||||
>
|
>
|
||||||
Add New
|
Add New
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -907,83 +1008,6 @@ export function EventHomeModules() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ModuleCard
|
|
||||||
icon={<Plus size={18} />}
|
|
||||||
title="Create New Form"
|
|
||||||
description="Add a form assigned to this event"
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
style={{
|
|
||||||
...moduleButtonStyle(),
|
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => setNewFormMode("registration")}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (actionsDisabled) return;
|
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (actionsDisabled) return;
|
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Registration
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
style={{
|
|
||||||
...moduleButtonStyle(),
|
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => setNewFormMode("feedback")}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (actionsDisabled) return;
|
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--primary)";
|
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--primary)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (actionsDisabled) return;
|
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "var(--border)";
|
|
||||||
(e.currentTarget as HTMLElement).style.color = "var(--card-foreground)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Feedback
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
style={{
|
|
||||||
...moduleButtonStyle(),
|
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => setNewFormMode("web-check-in")}
|
|
||||||
>
|
|
||||||
Web check-in
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={actionsDisabled}
|
|
||||||
style={{
|
|
||||||
...moduleButtonStyle(true),
|
|
||||||
opacity: actionsDisabled ? 0.55 : 1,
|
|
||||||
cursor: actionsDisabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => setNewFormMode("other")}
|
|
||||||
>
|
|
||||||
Other
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ModuleCard
|
<ModuleCard
|
||||||
icon={<Upload size={18} />}
|
icon={<Upload size={18} />}
|
||||||
title="Upload Document"
|
title="Upload Document"
|
||||||
|
|
@ -1005,6 +1029,10 @@ export function EventHomeModules() {
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{formShareTarget ? (
|
||||||
|
<ShareFormPanel formId={formShareTarget._id} onClose={() => setFormShareTarget(null)} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{formEditTarget && selectedEventId ? (
|
{formEditTarget && selectedEventId ? (
|
||||||
<CreateFormPanel
|
<CreateFormPanel
|
||||||
mode={formPanelMode(formEditTarget.type)}
|
mode={formPanelMode(formEditTarget.type)}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type { DateRange } from "react-day-picker";
|
||||||
import { QuickCreateEventPanel } from "../panels/QuickCreateEventPanel";
|
import { QuickCreateEventPanel } from "../panels/QuickCreateEventPanel";
|
||||||
import { useTheme } from "../../contexts/ThemeContext";
|
import { useTheme } from "../../contexts/ThemeContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
import { useAppNav } from "../../contexts/AppNavContext";
|
import { useAppNav } from "../../contexts/AppNavContext";
|
||||||
import {
|
import {
|
||||||
portfolioListOrganizationId,
|
portfolioListOrganizationId,
|
||||||
|
|
@ -1196,6 +1197,9 @@ function Toast({ msg, onDone }: { msg: string; onDone: () => void }) {
|
||||||
|
|
||||||
export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId: string) => void }) {
|
export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId: string) => void }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { canModule, isOrgSuperAdmin } = usePermission();
|
||||||
|
const canWriteEvents = canModule("events", "write");
|
||||||
|
const canListUsers = canModule("user_management", "read");
|
||||||
const eventsOrgId = portfolioListOrganizationId(user);
|
const eventsOrgId = portfolioListOrganizationId(user);
|
||||||
const {
|
const {
|
||||||
setActiveNav,
|
setActiveNav,
|
||||||
|
|
@ -1230,7 +1234,6 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
|
||||||
const [onboardingSessionCount, setOnboardingSessionCount] = useState(0);
|
const [onboardingSessionCount, setOnboardingSessionCount] = useState(0);
|
||||||
|
|
||||||
const categories = useMemo(() => buildCategoriesFromApi(apiEvents), [apiEvents]);
|
const categories = useMemo(() => buildCategoriesFromApi(apiEvents), [apiEvents]);
|
||||||
const canListUsers = user?.role === "super_admin" || user?.role === "admin";
|
|
||||||
|
|
||||||
const stepDone = useMemo(
|
const stepDone = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -1299,7 +1302,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
|
||||||
if (!eventsOrgId) {
|
if (!eventsOrgId) {
|
||||||
setApiEvents([]);
|
setApiEvents([]);
|
||||||
setEventsError(
|
setEventsError(
|
||||||
user?.role === "super_admin"
|
isOrgSuperAdmin
|
||||||
? "Assign an organisation to your account to load events for that tenant."
|
? "Assign an organisation to your account to load events for that tenant."
|
||||||
: "Your profile has no organisation.",
|
: "Your profile has no organisation.",
|
||||||
);
|
);
|
||||||
|
|
@ -1314,7 +1317,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
|
||||||
})
|
})
|
||||||
.catch(() => setEventsError("Failed to load events"))
|
.catch(() => setEventsError("Failed to load events"))
|
||||||
.finally(() => setEventsLoading(false));
|
.finally(() => setEventsLoading(false));
|
||||||
}, [eventsOrgId, user?.role]);
|
}, [eventsOrgId, isOrgSuperAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
|
|
@ -1606,11 +1609,13 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Create Event CTA */}
|
{/* Create Event CTA */}
|
||||||
|
{canWriteEvents ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreatePanel(true)}
|
onClick={() => setShowCreatePanel(true)}
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
style={{ display: "flex", alignItems: "center", gap: "6px", padding: "7px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
||||||
<Plus size={14} /> Create Event
|
<Plus size={14} /> Create Event
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1660,6 +1665,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
|
||||||
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: "16px", maxWidth: "360px", marginLeft: "auto", marginRight: "auto" }}>
|
<p style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--muted)", marginBottom: "16px", maxWidth: "360px", marginLeft: "auto", marginRight: "auto" }}>
|
||||||
Create an event to see it listed here with stats, filters, and calendar view.
|
Create an event to see it listed here with stats, filters, and calendar view.
|
||||||
</p>
|
</p>
|
||||||
|
{canWriteEvents ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCreatePanel(true)}
|
onClick={() => setShowCreatePanel(true)}
|
||||||
|
|
@ -1671,6 +1677,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId
|
||||||
>
|
>
|
||||||
<Plus size={16} /> Create your first event
|
<Plus size={16} /> Create your first event
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Trash2, X } from "lucide-react";
|
||||||
|
import { rolesApi, type FormGrant, type Role } from "../../../services/api/roles";
|
||||||
|
import { usersApi } from "../../../services/api/users";
|
||||||
|
import type { User } from "../../../services/api/types";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
|
|
||||||
|
const GRANT_ACTIONS = ["read", "write", "delete"] as const;
|
||||||
|
|
||||||
|
export function ShareFormPanel({ formId, onClose }: { formId: string; onClose: () => void }) {
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const [visibility, setVisibility] = useState<"private" | "org">("org");
|
||||||
|
const [grants, setGrants] = useState<FormGrant[]>([]);
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [subjectType, setSubjectType] = useState<"user" | "role">("user");
|
||||||
|
const [subjectId, setSubjectId] = useState("");
|
||||||
|
const [newActions, setNewActions] = useState<string[]>(["read"]);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const [gRes, uRes, rRes] = await Promise.all([
|
||||||
|
rolesApi.listFormGrants(formId),
|
||||||
|
usersApi.list(),
|
||||||
|
rolesApi.list(),
|
||||||
|
]);
|
||||||
|
if (gRes.error) {
|
||||||
|
setError(gRes.error.message || "Failed to load sharing settings");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gRes.data?.defaultVisibility) {
|
||||||
|
setVisibility(gRes.data.defaultVisibility as "private" | "org");
|
||||||
|
}
|
||||||
|
setGrants(gRes.data?.grants ?? []);
|
||||||
|
if (uRes.data?.users) setUsers(uRes.data.users);
|
||||||
|
if (rRes.data?.roles) setRoles(rRes.data.roles);
|
||||||
|
setLoading(false);
|
||||||
|
}, [formId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleVisibilityChange = async (v: "private" | "org") => {
|
||||||
|
setVisibility(v);
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const res = await rolesApi.updateFormVisibility(formId, v);
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error.message || "Failed to update visibility");
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNewAction = (action: string) => {
|
||||||
|
setNewActions((prev) =>
|
||||||
|
prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddGrant = async () => {
|
||||||
|
if (!subjectId) {
|
||||||
|
setError("Select a user or role");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newActions.length) {
|
||||||
|
setError("Select at least one permission");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const res = await rolesApi.createFormGrant(formId, {
|
||||||
|
subjectType,
|
||||||
|
subjectId,
|
||||||
|
actions: newActions,
|
||||||
|
});
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error.message || "Failed to add grant");
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubjectId("");
|
||||||
|
setNewActions(["read"]);
|
||||||
|
await load();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveGrant = async (grantId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
const res = await rolesApi.removeFormGrant(formId, grantId);
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error.message || "Failed to remove grant");
|
||||||
|
} else {
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!canModule("forms", "write")) return null;
|
||||||
|
|
||||||
|
const overlayStyle: React.CSSProperties = {
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.4)",
|
||||||
|
zIndex: 200,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
const panelStyle: React.CSSProperties = {
|
||||||
|
background: "var(--card)",
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 12,
|
||||||
|
minWidth: 360,
|
||||||
|
maxWidth: 480,
|
||||||
|
width: "100%",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
overflow: "auto",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "var(--text-xs)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
marginBottom: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={overlayStyle} onClick={onClose}>
|
||||||
|
<div style={panelStyle} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: "var(--text-base)" }}>Share form</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--muted)", padding: 4 }}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "var(--destructive)", fontSize: 13, margin: "0 0 12px" }}>{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: "var(--muted)", fontSize: 13 }}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>Default visibility</label>
|
||||||
|
<select
|
||||||
|
value={visibility}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => void handleVisibilityChange(e.target.value as "private" | "org")}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="org">Organisation visible — anyone with forms access can see this form</option>
|
||||||
|
<option value="private">Private — only you and people granted below</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label style={labelStyle}>People with access</label>
|
||||||
|
{grants.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: "var(--muted)", margin: "8px 0 0" }}>
|
||||||
|
No explicit grants. {visibility === "org" ? "Form is visible to your organisation." : "Only the creator can access this form."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0 }}>
|
||||||
|
{grants.map((g) => (
|
||||||
|
<li
|
||||||
|
key={g._id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "8px 0",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>{g.subjectLabel || g.subjectId}</strong>
|
||||||
|
<span style={{ color: "var(--muted)", marginLeft: 6 }}>
|
||||||
|
({g.subjectType}) — {(g.actions || []).join(", ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void handleRemoveGrant(g._id)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: busy ? "not-allowed" : "pointer",
|
||||||
|
color: "var(--destructive)",
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
title="Remove access"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p style={{ margin: "0 0 10px", fontSize: 13, fontWeight: 600 }}>Add access</p>
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setSubjectType("user");
|
||||||
|
setSubjectId("");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: subjectType === "user" ? "var(--primary)" : "transparent",
|
||||||
|
color: subjectType === "user" ? "#fff" : "var(--foreground)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
User
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setSubjectType("role");
|
||||||
|
setSubjectId("");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "6px 10px",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: subjectType === "role" ? "var(--primary)" : "transparent",
|
||||||
|
color: subjectType === "role" ? "#fff" : "var(--foreground)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={subjectId}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => setSubjectId(e.target.value)}
|
||||||
|
style={{ ...inputStyle, marginBottom: 10 }}
|
||||||
|
>
|
||||||
|
<option value="">Select {subjectType === "user" ? "user" : "role"}…</option>
|
||||||
|
{subjectType === "user"
|
||||||
|
? users.map((u) => (
|
||||||
|
<option key={u._id} value={u._id}>
|
||||||
|
{[u.firstName, u.lastName].filter(Boolean).join(" ") || u.email} ({u.email})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
: roles.map((r) => (
|
||||||
|
<option key={r._id} value={r._id}>
|
||||||
|
{r.name}{r.isSystem ? " (system)" : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div style={{ display: "flex", gap: 12, marginBottom: 10, flexWrap: "wrap" }}>
|
||||||
|
{GRANT_ACTIONS.map((a) => (
|
||||||
|
<label key={a} style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 13, cursor: "pointer" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newActions.includes(a)}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={() => toggleNewAction(a)}
|
||||||
|
/>
|
||||||
|
{a}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !subjectId}
|
||||||
|
onClick={() => void handleAddGrant()}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "none",
|
||||||
|
background: "var(--primary)",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: busy || !subjectId ? "not-allowed" : "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: busy || !subjectId ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add grant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
background: "transparent",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import {
|
||||||
Shield, Image, Layout, Tag, Mail,
|
Shield, Image, Layout, Tag, Mail,
|
||||||
Plus, Search, X, MoreHorizontal, Upload,
|
Plus, Search, X, MoreHorizontal, Upload,
|
||||||
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
|
Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw,
|
||||||
Link2, List, Check,
|
Link2, List, Check, Share2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
import { useBreakpoint } from "../../hooks/useBreakpoint";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
@ -53,6 +53,8 @@ import { ImportParticipantsPanel } from "../panels/ImportParticipantsPanel";
|
||||||
import { EditParticipantPanel } from "../panels/EditParticipantPanel";
|
import { EditParticipantPanel } from "../panels/EditParticipantPanel";
|
||||||
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
import { CreateFormPanel } from "../panels/CreateFormPanel";
|
||||||
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel";
|
||||||
|
import { ShareFormPanel } from "../forms/ShareFormPanel";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
import { UploadFilePanel } from "../panels/UploadFilePanel";
|
import { UploadFilePanel } from "../panels/UploadFilePanel";
|
||||||
import { CreateCollectionPanel } from "../panels/CreateCollectionPanel";
|
import { CreateCollectionPanel } from "../panels/CreateCollectionPanel";
|
||||||
import { MessagingEmailPage } from "../messaging/MessagingEmailPage";
|
import { MessagingEmailPage } from "../messaging/MessagingEmailPage";
|
||||||
|
|
@ -1866,14 +1868,18 @@ function cloneSettingsForDuplicate(settings: unknown): unknown {
|
||||||
|
|
||||||
export function FormsPage({ subPage }: { subPage?: string }) {
|
export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { canModule } = usePermission();
|
||||||
const orgId = portfolioListOrganizationId(user);
|
const orgId = portfolioListOrganizationId(user);
|
||||||
const canManageForms = Boolean(orgId) || user?.role === "super_admin";
|
const canReadForms = canModule("forms", "read");
|
||||||
|
const canWriteForms = canModule("forms", "write");
|
||||||
|
const canDeleteForms = canModule("forms", "delete");
|
||||||
const filterType = subPage === "forms-feedback" ? "Feedback"
|
const filterType = subPage === "forms-feedback" ? "Feedback"
|
||||||
: subPage === "forms-registration" ? "Registration"
|
: subPage === "forms-registration" ? "Registration"
|
||||||
: subPage === "forms-other" ? "Other"
|
: subPage === "forms-other" ? "Other"
|
||||||
: null;
|
: null;
|
||||||
const [forms, setForms] = useState<FormWithSubmissionCount[]>([]);
|
const [forms, setForms] = useState<FormWithSubmissionCount[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
const [showFeedback, setShowFeedback] = useState(false);
|
const [showFeedback, setShowFeedback] = useState(false);
|
||||||
const [showReg, setShowReg] = useState(false);
|
const [showReg, setShowReg] = useState(false);
|
||||||
|
|
@ -1883,6 +1889,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
const [formActionBusy, setFormActionBusy] = useState<string | null>(null);
|
const [formActionBusy, setFormActionBusy] = useState<string | null>(null);
|
||||||
const [formPageMsg, setFormPageMsg] = useState<string | null>(null);
|
const [formPageMsg, setFormPageMsg] = useState<string | null>(null);
|
||||||
const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null);
|
const [submissionsPanelForm, setSubmissionsPanelForm] = useState<FormWithSubmissionCount | null>(null);
|
||||||
|
const [shareFormTarget, setShareFormTarget] = useState<FormWithSubmissionCount | null>(null);
|
||||||
const [copiedFormId, setCopiedFormId] = useState<string | null>(null);
|
const [copiedFormId, setCopiedFormId] = useState<string | null>(null);
|
||||||
|
|
||||||
const publicFormPageUrl = (f: FormWithSubmissionCount) => {
|
const publicFormPageUrl = (f: FormWithSubmissionCount) => {
|
||||||
|
|
@ -1899,7 +1906,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
const copyFormPublicLink = (f: FormWithSubmissionCount) => {
|
const copyFormPublicLink = (f: FormWithSubmissionCount) => {
|
||||||
const url = publicFormPageUrl(f);
|
const url = publicFormPageUrl(f);
|
||||||
void navigator.clipboard.writeText(url).then(() => {
|
void navigator.clipboard.writeText(url).then(() => {
|
||||||
setCopiedFormId(id);
|
setCopiedFormId(f._id);
|
||||||
setFormPageMsg(FORM_LINK_COPIED_MSG);
|
setFormPageMsg(FORM_LINK_COPIED_MSG);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setCopiedFormId(null);
|
setCopiedFormId(null);
|
||||||
|
|
@ -1911,7 +1918,8 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchForms = useCallback(async () => {
|
const fetchForms = useCallback(async () => {
|
||||||
if (!canManageForms) {
|
setLoadError(null);
|
||||||
|
if (!canReadForms) {
|
||||||
setForms([]);
|
setForms([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1923,12 +1931,14 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
try {
|
try {
|
||||||
const list = await formsApi.list({ organization: orgId });
|
const list = await formsApi.list({ organization: orgId });
|
||||||
setForms(list);
|
setForms(list);
|
||||||
} catch {
|
} catch (err) {
|
||||||
setForms([]);
|
setForms([]);
|
||||||
|
const msg = err instanceof Error && err.message ? err.message : "Could not load forms.";
|
||||||
|
setLoadError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [canManageForms, orgId]);
|
}, [canReadForms, orgId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchForms();
|
fetchForms();
|
||||||
|
|
@ -2026,7 +2036,7 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
{pageTitle}
|
{pageTitle}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{canManageForms ? (
|
{canWriteForms ? (
|
||||||
<button onClick={openCreate} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
<button onClick={openCreate} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "7px 14px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
||||||
<Plus size={13} /> Create Form
|
<Plus size={13} /> Create Form
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -2039,7 +2049,11 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!canManageForms ? (
|
{!canReadForms ? (
|
||||||
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
||||||
|
<EmptyState icon={<FileText size={40} />} title="Access denied" description="You do not have permission to view forms." />
|
||||||
|
</div>
|
||||||
|
) : !orgId ? (
|
||||||
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
||||||
<EmptyState icon={<FileText size={40} />} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
|
<EmptyState icon={<FileText size={40} />} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2047,6 +2061,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
||||||
<EmptyState icon={<FileText size={40} />} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} />
|
<EmptyState icon={<FileText size={40} />} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} />
|
||||||
</div>
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", padding: "32px" }}>
|
||||||
|
<EmptyState icon={<FileText size={40} />} title="Could not load forms" description={loadError} cta="Try again" onCta={() => void fetchForms()} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", gap: "6px" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr) 100px 90px 80px minmax(200px, 1.1fr)", padding: "8px 16px", background: "var(--secondary)", borderBottom: "1px solid var(--border)", gap: "6px" }}>
|
||||||
|
|
@ -2056,6 +2074,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading forms…</div>
|
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading forms…</div>
|
||||||
|
) : filteredForms.length === 0 ? (
|
||||||
|
<div style={{ padding: "32px" }}>
|
||||||
|
<EmptyState icon={<FileText size={40} />} title="No forms found" description={`There are no ${pageTitle.toLowerCase()} to show for this organization.`} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredForms.map(f => {
|
filteredForms.map(f => {
|
||||||
const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft";
|
const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft";
|
||||||
|
|
@ -2084,26 +2106,41 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
>
|
>
|
||||||
{copiedFormId === fid ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
|
{copiedFormId === fid ? <Check size={12} strokeWidth={3} /> : <Link2 size={12} />}
|
||||||
</button>
|
</button>
|
||||||
|
{canReadForms ? (
|
||||||
<button type="button" title="Submissions" onClick={() => setSubmissionsPanelForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
<button type="button" title="Submissions" onClick={() => setSubmissionsPanelForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
||||||
<List size={12} />
|
<List size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canWriteForms ? (
|
||||||
<button type="button" title="Edit" onClick={() => openEdit(fid)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
<button type="button" title="Edit" onClick={() => openEdit(fid)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
||||||
<Pencil size={12} />
|
<Pencil size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canWriteForms ? (
|
||||||
|
<button type="button" title="Share" onClick={() => setShareFormTarget(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
||||||
|
<Share2 size={12} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canWriteForms ? (
|
||||||
<button type="button" title="Duplicate" onClick={() => void duplicateForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
<button type="button" title="Duplicate" onClick={() => void duplicateForm(f)} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
||||||
<Copy size={12} />
|
<Copy size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canDeleteForms ? (
|
||||||
<button type="button" title="Delete" onClick={() => { setFormPageMsg(null); setFormDeleteTarget(f); }} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
<button type="button" title="Delete" onClick={() => { setFormPageMsg(null); setFormDeleteTarget(f); }} disabled={formActionBusy === fid} style={{ width: "28px", height: "28px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: formActionBusy === fid ? "not-allowed" : "pointer", opacity: formActionBusy === fid ? 0.6 : 1 }}
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--destructive)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}>
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--destructive)"; }}>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -2123,15 +2160,18 @@ export function FormsPage({ subPage }: { subPage?: string }) {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
{showFeedback && canManageForms && (
|
{showFeedback && canWriteForms && (
|
||||||
<CreateFormPanel formId={editFormId ?? undefined} mode="feedback" onClose={() => { setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
<CreateFormPanel formId={editFormId ?? undefined} mode="feedback" onClose={() => { setShowFeedback(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
||||||
)}
|
)}
|
||||||
{showReg && canManageForms && (
|
{showReg && canWriteForms && (
|
||||||
<CreateFormPanel formId={editFormId ?? undefined} mode="registration" onClose={() => { setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
<CreateFormPanel formId={editFormId ?? undefined} mode="registration" onClose={() => { setShowReg(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
||||||
)}
|
)}
|
||||||
{showOther && canManageForms && (
|
{showOther && canWriteForms && (
|
||||||
<CreateFormPanel formId={editFormId ?? undefined} mode="other" onClose={() => { setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
<CreateFormPanel formId={editFormId ?? undefined} mode="other" onClose={() => { setShowOther(false); setEditFormId(null); }} onSuccess={handlePanelSuccess} />
|
||||||
)}
|
)}
|
||||||
|
{shareFormTarget ? (
|
||||||
|
<ShareFormPanel formId={shareFormTarget._id} onClose={() => setShareFormTarget(null)} />
|
||||||
|
) : null}
|
||||||
{submissionsPanelForm ? (
|
{submissionsPanelForm ? (
|
||||||
<FormSubmissionsPanel
|
<FormSubmissionsPanel
|
||||||
formId={submissionsPanelForm._id}
|
formId={submissionsPanelForm._id}
|
||||||
|
|
@ -2396,6 +2436,11 @@ function userInitials(u: User): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserManagementPage() {
|
export function UserManagementPage() {
|
||||||
|
const { setActiveNav } = useAppNav();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const canReadUsers = canModule("user_management", "read");
|
||||||
|
const canWriteUsers = canModule("user_management", "write");
|
||||||
|
const canDeleteUsers = canModule("user_management", "delete");
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [hovered, setHovered] = useState<string | null>(null);
|
const [hovered, setHovered] = useState<string | null>(null);
|
||||||
|
|
@ -2420,15 +2465,28 @@ export function UserManagementPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
|
||||||
|
{!canReadUsers ? (
|
||||||
|
<div style={{ padding: 32, textAlign: "center", color: "var(--muted)" }}>You do not have permission to view user management.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between" }}>
|
||||||
<div>
|
<div>
|
||||||
<p style={labelStyle}>Portfolio</p>
|
<p style={labelStyle}>Portfolio</p>
|
||||||
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>User Management</h1>
|
<h1 style={{ fontFamily: "var(--font-inter)", fontSize: "var(--text-2xl)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>User Management</h1>
|
||||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{users.length} users across the platform</p>
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", marginTop: "4px" }}>{users.length} users across the platform</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => { setEditUser(null); setShowInvite(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Plus size={13} /> Invite User
|
{canModule("user_management", "read") ? (
|
||||||
</button>
|
<button type="button" onClick={() => setActiveNav("roles-management")} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--secondary)", color: "var(--foreground)", border: "1px solid var(--border)", cursor: "pointer", fontSize: "var(--text-sm)" }}>
|
||||||
|
<Shield size={13} /> Manage Roles
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canWriteUsers ? (
|
||||||
|
<button onClick={() => { setEditUser(null); setShowInvite(true); }} style={{ display: "flex", alignItems: "center", gap: "5px", padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)" }}>
|
||||||
|
<Plus size={13} /> Invite User
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: "var(--radius-card)", overflow: "hidden" }}>
|
||||||
|
|
@ -2439,9 +2497,11 @@ export function UserManagementPage() {
|
||||||
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading users…</div>
|
<div style={{ padding: "24px", color: "var(--muted)", fontSize: "var(--text-sm)" }}>Loading users…</div>
|
||||||
) : (
|
) : (
|
||||||
users.map((u, idx) => {
|
users.map((u, idx) => {
|
||||||
const rs = ROLE_STYLES[u.role] ?? { bg: "var(--secondary)", color: "var(--muted)" };
|
const rs = ROLE_STYLES[u.membershipRole ?? u.role] ?? ROLE_STYLES[u.role] ?? { bg: "var(--secondary)", color: "var(--muted)" };
|
||||||
|
const roleLabel = u.customRole?.name ?? ROLE_LABELS[u.membershipRole ?? u.role] ?? u.role;
|
||||||
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
|
const color = AVATAR_COLORS[idx % AVATAR_COLORS.length];
|
||||||
const eventsLabel = u.role === "super_admin" || u.role === "admin" ? "All" : "—";
|
const assigned = u.assignedEvents?.length ?? 0;
|
||||||
|
const eventsLabel = assigned > 0 ? String(assigned) : (u.role === "super_admin" || u.role === "admin" ? "All" : "—");
|
||||||
return (
|
return (
|
||||||
<div key={u._id}
|
<div key={u._id}
|
||||||
onMouseEnter={() => setHovered(u._id)}
|
onMouseEnter={() => setHovered(u._id)}
|
||||||
|
|
@ -2455,14 +2515,21 @@ export function UserManagementPage() {
|
||||||
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{userDisplayName(u)}</span>
|
<span style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: "var(--foreground)" }}>{userDisplayName(u)}</span>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{u.email}</span>
|
<span style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)" }}>{u.email}</span>
|
||||||
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: rs.bg, color: rs.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{ROLE_LABELS[u.role] ?? u.role}</span>
|
<span style={{ display: "inline-flex", width: "fit-content", padding: "2px 8px", borderRadius: "999px", background: rs.bg, color: rs.color, fontSize: "10px", fontFamily: "var(--font-noto-sans)", fontWeight: "600" }}>{roleLabel}</span>
|
||||||
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{eventsLabel}</span>
|
<span style={{ fontFamily: "var(--font-noto-sans)", fontSize: "var(--text-sm)", color: "var(--foreground)" }}>{eventsLabel}</span>
|
||||||
<div style={{ display: "flex", gap: "4px", opacity: hovered === u._id ? 1 : 0, transition: "opacity 0.15s" }}>
|
<div style={{ display: "flex", gap: "4px", opacity: hovered === u._id ? 1 : 0, transition: "opacity 0.15s" }}>
|
||||||
|
{canWriteUsers ? (
|
||||||
<button type="button" onClick={() => { setEditUser(u); setShowInvite(true); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
|
<button type="button" onClick={() => { setEditUser(u); setShowInvite(true); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--muted)", cursor: "pointer" }}
|
||||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--primary)"; (e.currentTarget as HTMLElement).style.color = "var(--primary)"; }}
|
||||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = "var(--border)"; (e.currentTarget as HTMLElement).style.color = "var(--muted)"; }}>
|
||||||
<Pencil size={11} />
|
<Pencil size={11} />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canDeleteUsers ? (
|
||||||
|
<button type="button" onClick={() => { if (window.confirm(`Remove ${userDisplayName(u)}?`)) void usersApi.remove(u._id).then(() => fetchUsers()); }} style={{ width: "26px", height: "26px", display: "flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", color: "var(--destructive)", cursor: "pointer" }}>
|
||||||
|
<Trash2 size={11} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -2470,7 +2537,9 @@ export function UserManagementPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
{showInvite && <InviteUserPanel editUser={editUser} onClose={() => { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
|
{showInvite && canWriteUsers && <InviteUserPanel editUser={editUser} onClose={() => { setShowInvite(false); setEditUser(null); }} onSuccess={fetchUsers} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { ExternalLink, Eye, Rocket, Save } from 'lucide-react';
|
import { ExternalLink, Eye, Rocket, Save } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
PanelShell,
|
PanelShell,
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
HelperText,
|
HelperText,
|
||||||
} from '../panels/PanelShell';
|
} from '../panels/PanelShell';
|
||||||
import {
|
import {
|
||||||
|
sessionsApi,
|
||||||
staticMicrositeApi,
|
staticMicrositeApi,
|
||||||
staticMicrositePublicUrl,
|
staticMicrositePublicUrl,
|
||||||
type EventStaticMicrosite,
|
type EventStaticMicrosite,
|
||||||
|
|
@ -18,6 +19,9 @@ import {
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { MicrositeConfigForm } from './MicrositeConfigForm';
|
import { MicrositeConfigForm } from './MicrositeConfigForm';
|
||||||
import { mergeConfig } from './configUtils';
|
import { mergeConfig } from './configUtils';
|
||||||
|
import { mergeScheduleDays, type ScheduleDay } from './scheduleSync';
|
||||||
|
|
||||||
|
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||||
|
|
||||||
export function ManageStaticMicrositePanel({
|
export function ManageStaticMicrositePanel({
|
||||||
eventId,
|
eventId,
|
||||||
|
|
@ -45,13 +49,17 @@ export function ManageStaticMicrositePanel({
|
||||||
const [config, setConfig] = useState<StaticMicrositeConfig>({ sections: {} });
|
const [config, setConfig] = useState<StaticMicrositeConfig>({ sections: {} });
|
||||||
const [existing, setExisting] = useState<EventStaticMicrosite | null>(null);
|
const [existing, setExisting] = useState<EventStaticMicrosite | null>(null);
|
||||||
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
|
const [publishedUrl, setPublishedUrl] = useState<string | null>(null);
|
||||||
|
const [schemaLoading, setSchemaLoading] = useState(false);
|
||||||
|
const [scheduleSyncing, setScheduleSyncing] = useState(false);
|
||||||
|
const scheduleAutoFetchedRef = useRef(false);
|
||||||
|
const configureOpenedRef = useRef(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const [tplRes, siteRes] = await Promise.all([
|
const [tplRes, siteRes] = await Promise.all([
|
||||||
staticMicrositeApi.listTemplates(),
|
staticMicrositeApi.listTemplates(eventId),
|
||||||
staticMicrositeApi.getForEvent(eventId),
|
staticMicrositeApi.getForEvent(eventId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -71,11 +79,16 @@ export function ManageStaticMicrositePanel({
|
||||||
|
|
||||||
if (tpl) {
|
if (tpl) {
|
||||||
setSelectedTemplate(tpl);
|
setSelectedTemplate(tpl);
|
||||||
const detail = await staticMicrositeApi.getTemplate(tpl._id);
|
setSchemaLoading(true);
|
||||||
const sch = detail.data?.schema;
|
try {
|
||||||
if (sch) {
|
const detail = await staticMicrositeApi.getTemplate(tpl._id, eventId);
|
||||||
|
if (detail.error) throw new Error(detail.error);
|
||||||
|
const sch = detail.data?.schema;
|
||||||
|
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
||||||
setSchema(sch);
|
setSchema(sch);
|
||||||
setConfig(mergeConfig(sch, site.config));
|
setConfig(mergeConfig(sch, site.config));
|
||||||
|
} finally {
|
||||||
|
setSchemaLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +103,15 @@ export function ManageStaticMicrositePanel({
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !existing || !schema || configureOpenedRef.current) return;
|
||||||
|
const hasConfig = Object.keys(existing.config?.sections || {}).length > 0;
|
||||||
|
if (hasConfig) {
|
||||||
|
configureOpenedRef.current = true;
|
||||||
|
acc.skipTo(3);
|
||||||
|
}
|
||||||
|
}, [loading, existing, schema]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subdomain || subdomain.length < 3) {
|
if (!subdomain || subdomain.length < 3) {
|
||||||
setSubdomainCheck(null);
|
setSubdomainCheck(null);
|
||||||
|
|
@ -107,11 +129,74 @@ export function ManageStaticMicrositePanel({
|
||||||
|
|
||||||
const selectTemplate = async (t: MicrositeV2Template) => {
|
const selectTemplate = async (t: MicrositeV2Template) => {
|
||||||
setSelectedTemplate(t);
|
setSelectedTemplate(t);
|
||||||
const res = await staticMicrositeApi.getTemplate(t._id);
|
setError('');
|
||||||
const sch = res.data?.schema;
|
setSchema(null);
|
||||||
if (sch) {
|
setSchemaLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await staticMicrositeApi.getTemplate(t._id, eventId);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
const sch = res.data?.schema;
|
||||||
|
if (!sch) throw new Error('Template configuration schema could not be loaded');
|
||||||
setSchema(sch);
|
setSchema(sch);
|
||||||
setConfig(mergeConfig(sch, config));
|
setConfig(mergeConfig(sch, config));
|
||||||
|
acc.advance(2);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load template configuration');
|
||||||
|
setSchema(null);
|
||||||
|
} finally {
|
||||||
|
setSchemaLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyAgendaToSchedule = useCallback(
|
||||||
|
(mergeWithExisting: boolean) => {
|
||||||
|
return sessionsApi.list(eventId).then((res) => {
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
const sessions = res.data?.sessions || [];
|
||||||
|
setConfig((prev) => {
|
||||||
|
const sections = prev.sections || {};
|
||||||
|
const sched = (sections[SCHEDULE_SECTION_ID] as Record<string, unknown>) || {};
|
||||||
|
const existingDays = (Array.isArray(sched.days) ? sched.days : []) as ScheduleDay[];
|
||||||
|
// On auto pre-fetch, never overwrite a schedule that already has days.
|
||||||
|
if (!mergeWithExisting && existingDays.length > 0) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
sections: {
|
||||||
|
...sections,
|
||||||
|
[SCHEDULE_SECTION_ID]: {
|
||||||
|
...sched,
|
||||||
|
show: sched.show !== false,
|
||||||
|
days: mergeScheduleDays(mergeWithExisting ? existingDays : [], sessions),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[eventId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pre-fetch the schedule from the event agenda the first time a schema with a
|
||||||
|
// schedule section is loaded, but only when no schedule has been saved yet.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!schema) return;
|
||||||
|
const hasScheduleSection = (schema.sections || []).some((s) => s.id === SCHEDULE_SECTION_ID);
|
||||||
|
if (!hasScheduleSection || scheduleAutoFetchedRef.current) return;
|
||||||
|
scheduleAutoFetchedRef.current = true;
|
||||||
|
void applyAgendaToSchedule(false).catch(() => {
|
||||||
|
/* silent: pre-fetch is best-effort */
|
||||||
|
});
|
||||||
|
}, [schema, applyAgendaToSchedule]);
|
||||||
|
|
||||||
|
const handleSyncSchedule = async () => {
|
||||||
|
setScheduleSyncing(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await applyAgendaToSchedule(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to sync agenda');
|
||||||
|
} finally {
|
||||||
|
setScheduleSyncing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -301,8 +386,18 @@ export function ManageStaticMicrositePanel({
|
||||||
config={config}
|
config={config}
|
||||||
onChange={setConfig}
|
onChange={setConfig}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
|
onSyncSchedule={() => void handleSyncSchedule()}
|
||||||
|
scheduleSyncing={scheduleSyncing}
|
||||||
/>
|
/>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
) : schemaLoading ? (
|
||||||
|
<p style={{ color: 'var(--muted)', fontSize: 'var(--text-sm)', marginTop: '8px' }}>
|
||||||
|
Loading template configuration…
|
||||||
|
</p>
|
||||||
|
) : selectedTemplate && error ? (
|
||||||
|
<p style={{ color: 'var(--destructive)', fontSize: 'var(--text-sm)', marginTop: '8px' }}>
|
||||||
|
Could not load configuration for this template. Check the error above and try again.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '20px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '8px', marginTop: '20px', flexWrap: 'wrap' }}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { ChevronDown, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const SCHEDULE_SECTION_ID = 'event_schedule';
|
||||||
import { uploadMediaFile } from '../../../services/api/media';
|
import { uploadMediaFile } from '../../../services/api/media';
|
||||||
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
|
import type { MicrositeV2FieldSchema, MicrositeV2SectionSchema, StaticMicrositeConfig } from '../../../services/api/types';
|
||||||
import { Field, Label, HelperText } from '../panels/PanelShell';
|
import { Field, Label, HelperText } from '../panels/PanelShell';
|
||||||
|
|
@ -30,7 +32,7 @@ function FieldInput({
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = React.useState(false);
|
const [uploading, setUploading] = React.useState(false);
|
||||||
|
|
||||||
const handleImage = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const res = await uploadMediaFile(file, { eventId, module: 'microsite-v2' });
|
const res = await uploadMediaFile(file, { eventId, module: 'microsite-v2' });
|
||||||
|
|
@ -56,6 +58,7 @@ function FieldInput({
|
||||||
<textarea
|
<textarea
|
||||||
rows={field.type === 'richtext' ? 6 : 3}
|
rows={field.type === 'richtext' ? 6 : 3}
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
|
placeholder={field.placeholder}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={{ ...inputStyle, resize: 'vertical' }}
|
style={{ ...inputStyle, resize: 'vertical' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -95,7 +98,7 @@ function FieldInput({
|
||||||
) : null}
|
) : null}
|
||||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => {
|
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => {
|
||||||
const f = e.target.files?.[0];
|
const f = e.target.files?.[0];
|
||||||
if (f) void handleImage(f);
|
if (f) void handleUpload(f);
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
}} />
|
}} />
|
||||||
<button
|
<button
|
||||||
|
|
@ -113,6 +116,36 @@ function FieldInput({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{value ? (
|
||||||
|
<video
|
||||||
|
src={String(value)}
|
||||||
|
controls
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '160px', borderRadius: 'var(--radius-button)', background: '#000' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<input ref={fileRef} type="file" accept="video/*" hidden onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void handleUpload(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
...inputStyle,
|
||||||
|
cursor: uploading ? 'wait' : 'pointer',
|
||||||
|
width: 'auto',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? 'Uploading…' : value ? 'Change video' : 'Upload video'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case 'array':
|
case 'array':
|
||||||
return (
|
return (
|
||||||
<ArrayFieldEditor
|
<ArrayFieldEditor
|
||||||
|
|
@ -127,6 +160,7 @@ function FieldInput({
|
||||||
<input
|
<input
|
||||||
type={field.type === 'url' ? 'url' : 'text'}
|
type={field.type === 'url' ? 'url' : 'text'}
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
|
placeholder={field.placeholder}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
|
|
@ -226,11 +260,15 @@ function SectionEditor({
|
||||||
sectionData,
|
sectionData,
|
||||||
onChange,
|
onChange,
|
||||||
eventId,
|
eventId,
|
||||||
|
onSync,
|
||||||
|
syncing,
|
||||||
}: {
|
}: {
|
||||||
section: MicrositeV2SectionSchema;
|
section: MicrositeV2SectionSchema;
|
||||||
sectionData: Record<string, unknown>;
|
sectionData: Record<string, unknown>;
|
||||||
onChange: (data: Record<string, unknown>) => void;
|
onChange: (data: Record<string, unknown>) => void;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
onSync?: () => void;
|
||||||
|
syncing?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const show = sectionData.show !== false;
|
const show = sectionData.show !== false;
|
||||||
|
|
||||||
|
|
@ -247,6 +285,15 @@ function SectionEditor({
|
||||||
return sectionData[key];
|
return sectionData[key];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFieldVisible = (field: MicrositeV2FieldSchema) => {
|
||||||
|
if (!field.showIf) return true;
|
||||||
|
const current = getField(field.showIf.field);
|
||||||
|
const expected = field.showIf.equals;
|
||||||
|
return Array.isArray(expected)
|
||||||
|
? expected.includes(String(current))
|
||||||
|
: String(current) === String(expected);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)', fontWeight: 600 }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: 'var(--text-sm)', fontWeight: 600 }}>
|
||||||
|
|
@ -257,8 +304,46 @@ function SectionEditor({
|
||||||
/>
|
/>
|
||||||
Show {section.label} section
|
Show {section.label} section
|
||||||
</label>
|
</label>
|
||||||
|
{show && onSync ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 'var(--radius-button)',
|
||||||
|
border: '1px dashed var(--border)',
|
||||||
|
background: 'color-mix(in srgb, var(--primary) 6%, transparent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={syncing}
|
||||||
|
onClick={onSync}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--radius-button)',
|
||||||
|
border: '1px solid var(--primary)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--primary)',
|
||||||
|
cursor: syncing ? 'wait' : 'pointer',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} style={syncing ? { animation: 'msv2-spin 1s linear infinite' } : undefined} />
|
||||||
|
{syncing ? 'Syncing…' : 'Sync from event agenda'}
|
||||||
|
</button>
|
||||||
|
<style>{`@keyframes msv2-spin { from { transform:rotate(0deg) } to { transform:rotate(360deg) } }`}</style>
|
||||||
|
<HelperText text="Pulls sessions from the event agenda. New sessions are added and existing synced rows are refreshed; rows you added manually are kept." />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{show
|
{show
|
||||||
? section.fields.map((field) => (
|
? section.fields.filter(isFieldVisible).map((field) => (
|
||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
<Label text={field.label || field.key} />
|
<Label text={field.label || field.key} />
|
||||||
<FieldInput
|
<FieldInput
|
||||||
|
|
@ -274,11 +359,93 @@ function SectionEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConfigAccordion({
|
||||||
|
title,
|
||||||
|
badge,
|
||||||
|
defaultOpen = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
badge?: React.ReactNode;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-button)',
|
||||||
|
background: 'var(--card, transparent)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '12px 14px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--foreground)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{title}
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={16}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
color: 'var(--muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div style={{ padding: '4px 14px 16px', borderTop: '1px solid var(--border)' }}>{children}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HiddenBadge() {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '999px',
|
||||||
|
padding: '1px 8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hidden
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MicrositeConfigForm({
|
export function MicrositeConfigForm({
|
||||||
schema,
|
schema,
|
||||||
config,
|
config,
|
||||||
onChange,
|
onChange,
|
||||||
eventId,
|
eventId,
|
||||||
|
onSyncSchedule,
|
||||||
|
scheduleSyncing,
|
||||||
}: {
|
}: {
|
||||||
schema: {
|
schema: {
|
||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
|
|
@ -288,14 +455,16 @@ export function MicrositeConfigForm({
|
||||||
config: StaticMicrositeConfig;
|
config: StaticMicrositeConfig;
|
||||||
onChange: (c: StaticMicrositeConfig) => void;
|
onChange: (c: StaticMicrositeConfig) => void;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
|
onSyncSchedule?: () => void;
|
||||||
|
scheduleSyncing?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const theme = config.theme || {};
|
const theme = config.theme || {};
|
||||||
const sections = config.sections || {};
|
const sections = config.sections || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||||
{schema.meta && Object.keys(schema.meta).length > 0 ? (
|
{schema.meta && Object.keys(schema.meta).length > 0 ? (
|
||||||
<div>
|
<ConfigAccordion title="Page title" defaultOpen>
|
||||||
<Label text="Page title" />
|
<Label text="Page title" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -304,12 +473,11 @@ export function MicrositeConfigForm({
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
/>
|
/>
|
||||||
<HelperText text="Browser tab title for the microsite." />
|
<HelperText text="Browser tab title for the microsite." />
|
||||||
</div>
|
</ConfigAccordion>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(schema.theme?.fields || []).length > 0 ? (
|
{(schema.theme?.fields || []).length > 0 ? (
|
||||||
<div>
|
<ConfigAccordion title="Theme">
|
||||||
<p style={{ fontSize: 'var(--text-sm)', fontWeight: 600, margin: '0 0 8px' }}>Theme</p>
|
|
||||||
{schema.theme!.fields!.map((field) => (
|
{schema.theme!.fields!.map((field) => (
|
||||||
<div key={field.key} style={{ marginBottom: '12px' }}>
|
<div key={field.key} style={{ marginBottom: '12px' }}>
|
||||||
<Label text={field.label || field.key} />
|
<Label text={field.label || field.key} />
|
||||||
|
|
@ -321,31 +489,34 @@ export function MicrositeConfigForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ConfigAccordion>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{schema.sections.map((section) => (
|
{schema.sections.map((section) => {
|
||||||
<div
|
const sectionData = (sections[section.id] as Record<string, unknown>) || { show: true };
|
||||||
key={section.id}
|
const isShown = sectionData.show !== false;
|
||||||
style={{
|
return (
|
||||||
borderTop: '1px solid var(--border)',
|
<ConfigAccordion
|
||||||
paddingTop: '16px',
|
key={section.id}
|
||||||
}}
|
title={section.label}
|
||||||
>
|
badge={isShown ? undefined : <HiddenBadge />}
|
||||||
<p style={{ fontSize: 'var(--text-sm)', fontWeight: 600, margin: '0 0 12px' }}>{section.label}</p>
|
>
|
||||||
<SectionEditor
|
<SectionEditor
|
||||||
section={section}
|
section={section}
|
||||||
sectionData={(sections[section.id] as Record<string, unknown>) || { show: true }}
|
sectionData={sectionData}
|
||||||
onChange={(data) =>
|
onChange={(data) =>
|
||||||
onChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
sections: { ...sections, [section.id]: data },
|
sections: { ...sections, [section.id]: data },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
eventId={eventId}
|
eventId={eventId}
|
||||||
/>
|
onSync={section.id === SCHEDULE_SECTION_ID ? onSyncSchedule : undefined}
|
||||||
</div>
|
syncing={scheduleSyncing}
|
||||||
))}
|
/>
|
||||||
|
</ConfigAccordion>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Save } from 'lucide-react';
|
import { Save } from 'lucide-react';
|
||||||
import { PanelShell, Label, Field, HelperText } from '../panels/PanelShell';
|
import { PanelShell, Label, Field, HelperText } from '../panels/PanelShell';
|
||||||
import { staticMicrositeApi, type MicrositeV2Template } from '../../../services/api';
|
import { platformAdminApi, staticMicrositeApi, type MicrositeV2Template } from '../../../services/api';
|
||||||
|
import type { OrganizationRecord } from '../../../services/api/types';
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -14,6 +15,11 @@ const inputStyle: React.CSSProperties = {
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function orgIdsFromTemplate(template: MicrositeV2Template | null): string[] {
|
||||||
|
if (!template?.allowedOrganizations?.length) return [];
|
||||||
|
return template.allowedOrganizations.map((o) => (typeof o === 'string' ? o : o._id));
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadMicrositeV2TemplatePanel({
|
export function UploadMicrositeV2TemplatePanel({
|
||||||
template,
|
template,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -28,6 +34,11 @@ export function UploadMicrositeV2TemplatePanel({
|
||||||
const [slug, setSlug] = useState(template?.slug || '');
|
const [slug, setSlug] = useState(template?.slug || '');
|
||||||
const [name, setName] = useState(template?.name || '');
|
const [name, setName] = useState(template?.name || '');
|
||||||
const [description, setDescription] = useState(template?.description || '');
|
const [description, setDescription] = useState(template?.description || '');
|
||||||
|
const [visibility, setVisibility] = useState<'public' | 'private'>(template?.visibility || 'public');
|
||||||
|
const [allowedOrgIds, setAllowedOrgIds] = useState<string[]>(() => orgIdsFromTemplate(template));
|
||||||
|
const [orgSearch, setOrgSearch] = useState('');
|
||||||
|
const [orgOptions, setOrgOptions] = useState<OrganizationRecord[]>([]);
|
||||||
|
const [orgLoading, setOrgLoading] = useState(false);
|
||||||
const [templateHtml, setTemplateHtml] = useState<File | null>(null);
|
const [templateHtml, setTemplateHtml] = useState<File | null>(null);
|
||||||
const [templateJson, setTemplateJson] = useState<File | null>(null);
|
const [templateJson, setTemplateJson] = useState<File | null>(null);
|
||||||
const [thumbnail, setThumbnail] = useState<File | null>(null);
|
const [thumbnail, setThumbnail] = useState<File | null>(null);
|
||||||
|
|
@ -35,11 +46,41 @@ export function UploadMicrositeV2TemplatePanel({
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const loadOrgs = useCallback(async (search: string) => {
|
||||||
|
setOrgLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await platformAdminApi.listOrganizations({ page: 1, limit: 50, search: search || undefined });
|
||||||
|
setOrgOptions(data.organizations);
|
||||||
|
} catch {
|
||||||
|
setOrgOptions([]);
|
||||||
|
} finally {
|
||||||
|
setOrgLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visibility !== 'private') return;
|
||||||
|
const t = window.setTimeout(() => void loadOrgs(orgSearch), 250);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [visibility, orgSearch, loadOrgs]);
|
||||||
|
|
||||||
|
const toggleOrg = (orgId: string) => {
|
||||||
|
setAllowedOrgIds((prev) => (prev.includes(orgId) ? prev.filter((id) => id !== orgId) : [...prev, orgId]));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
if (visibility === 'private' && allowedOrgIds.length === 0) {
|
||||||
|
throw new Error('Select at least one account for a private template');
|
||||||
|
}
|
||||||
|
|
||||||
let templateId = template?._id;
|
let templateId = template?._id;
|
||||||
|
const visibilityPayload = {
|
||||||
|
visibility,
|
||||||
|
allowedOrganizations: visibility === 'private' ? allowedOrgIds : [],
|
||||||
|
};
|
||||||
|
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
if (!slug.trim() || !name.trim()) {
|
if (!slug.trim() || !name.trim()) {
|
||||||
|
|
@ -49,13 +90,15 @@ export function UploadMicrositeV2TemplatePanel({
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
|
...visibilityPayload,
|
||||||
});
|
});
|
||||||
if (createRes.error || !createRes.data) throw new Error(createRes.error || 'Create failed');
|
if (createRes.error || !createRes.data) throw new Error(createRes.error || 'Create failed');
|
||||||
templateId = createRes.data._id;
|
templateId = createRes.data._id;
|
||||||
} else if (name !== template?.name || description !== template?.description) {
|
} else {
|
||||||
const updateRes = await staticMicrositeApi.updateTemplate(template!._id, {
|
const updateRes = await staticMicrositeApi.updateTemplate(template!._id, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
|
...visibilityPayload,
|
||||||
});
|
});
|
||||||
if (updateRes.error) throw new Error(updateRes.error);
|
if (updateRes.error) throw new Error(updateRes.error);
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +128,14 @@ export function UploadMicrositeV2TemplatePanel({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const selectedOrgLabels = allowedOrgIds.map((id) => {
|
||||||
|
const fromOptions = orgOptions.find((o) => o._id === id);
|
||||||
|
if (fromOptions) return fromOptions.name;
|
||||||
|
const fromTemplate = template?.allowedOrganizations?.find((o) => (typeof o === 'string' ? o : o._id) === id);
|
||||||
|
if (fromTemplate && typeof fromTemplate !== 'string') return fromTemplate.name || fromTemplate.email || id;
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
title={isEdit ? 'Edit template' : 'New microsite template'}
|
title={isEdit ? 'Edit template' : 'New microsite template'}
|
||||||
|
|
@ -127,6 +178,98 @@ export function UploadMicrositeV2TemplatePanel({
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label text="Visibility" />
|
||||||
|
<Field>
|
||||||
|
<select
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => setVisibility(e.target.value as 'public' | 'private')}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="public">Public — available to all accounts</option>
|
||||||
|
<option value="private">Private — selected accounts only</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibility === 'private' ? (
|
||||||
|
<div>
|
||||||
|
<Label text="Allowed accounts" />
|
||||||
|
{allowedOrgIds.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginBottom: '8px' }}>
|
||||||
|
{selectedOrgLabels.map((label, i) => (
|
||||||
|
<span
|
||||||
|
key={allowedOrgIds[i]}
|
||||||
|
style={{
|
||||||
|
fontSize: 'var(--text-xs)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 'var(--radius-button)',
|
||||||
|
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleOrg(allowedOrgIds[i]!)}
|
||||||
|
style={{ marginLeft: '6px', border: 'none', background: 'none', cursor: 'pointer', color: 'var(--muted)' }}
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Field>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={orgSearch}
|
||||||
|
onChange={(e) => setOrgSearch(e.target.value)}
|
||||||
|
placeholder="Search organizations…"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
maxHeight: '160px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius-button)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{orgLoading ? (
|
||||||
|
<p style={{ padding: '10px', margin: 0, fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>Loading…</p>
|
||||||
|
) : orgOptions.length === 0 ? (
|
||||||
|
<p style={{ padding: '10px', margin: 0, fontSize: 'var(--text-sm)', color: 'var(--muted)' }}>No organizations found.</p>
|
||||||
|
) : (
|
||||||
|
orgOptions.map((org) => (
|
||||||
|
<label
|
||||||
|
key={org._id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 'var(--text-sm)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={allowedOrgIds.includes(org._id)} onChange={() => toggleOrg(org._id)} />
|
||||||
|
<span>
|
||||||
|
{org.name}
|
||||||
|
{org.email ? <span style={{ color: 'var(--muted)', marginLeft: '6px' }}>{org.email}</span> : null}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<HelperText text="Only selected organizations will see this template when building event microsites." />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label text={`template.html ${isEdit ? '(optional)' : '(required)'}`} />
|
<Label text={`template.html ${isEdit ? '(optional)' : '(required)'}`} />
|
||||||
<input type="file" accept=".html,text/html" onChange={(e) => setTemplateHtml(e.target.files?.[0] || null)} />
|
<input type="file" accept=".html,text/html" onChange={(e) => setTemplateHtml(e.target.files?.[0] || null)} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import type { ApiSession } from '../../../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the event agenda (Session[]) into the microsite "event_schedule" section
|
||||||
|
* shape, and merges fresh agenda data into an already-configured schedule.
|
||||||
|
*
|
||||||
|
* Each synced session carries a hidden `sourceId` (the agenda session _id) and
|
||||||
|
* each synced day carries a hidden `sourceDateKey` (YYYY-MM-DD). These extra
|
||||||
|
* fields are ignored by the Handlebars template (which only reads time/type/
|
||||||
|
* title and label/date) but let us re-sync without losing manual entries.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ScheduleSession {
|
||||||
|
time: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
/** Agenda session _id for synced rows; absent for manually added rows. */
|
||||||
|
sourceId?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleDay {
|
||||||
|
label: string;
|
||||||
|
date: string;
|
||||||
|
/** Calendar day (YYYY-MM-DD) this day was synced from. */
|
||||||
|
sourceDateKey?: string;
|
||||||
|
sessions: ScheduleSession[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD in the local timezone, or '' when the date is missing/invalid. */
|
||||||
|
export function toDateKey(iso?: string): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable date line, e.g. "Wednesday, Jul 8, 2026". */
|
||||||
|
export function toDateLabel(dateKey: string): string {
|
||||||
|
if (!dateKey || dateKey === '_nodate') return '';
|
||||||
|
const d = new Date(`${dateKey}T00:00:00`);
|
||||||
|
if (Number.isNaN(d.getTime())) return dateKey;
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats a start–end range, e.g. "9:00 AM – 10:30 AM". */
|
||||||
|
export function formatTimeRange(startIso?: string, endIso?: string): string {
|
||||||
|
const fmt = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
};
|
||||||
|
const start = fmt(startIso);
|
||||||
|
const end = fmt(endIso);
|
||||||
|
if (start && end) return `${start} – ${end}`;
|
||||||
|
return start || end || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives the schedule "type" column: track name, else capitalized session type. */
|
||||||
|
export function sessionTypeLabel(session: ApiSession): string {
|
||||||
|
const track = session.track;
|
||||||
|
if (track && typeof track === 'object' && 'name' in track && track.name) {
|
||||||
|
return String(track.name);
|
||||||
|
}
|
||||||
|
if (session.type) return session.type.charAt(0).toUpperCase() + session.type.slice(1);
|
||||||
|
return 'Main programme';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSession(session: ApiSession): ScheduleSession {
|
||||||
|
return {
|
||||||
|
time: formatTimeRange(session.startTime, session.endTime),
|
||||||
|
type: sessionTypeLabel(session),
|
||||||
|
title: session.title,
|
||||||
|
sourceId: session._id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDateKey(sessions: ApiSession[]): Map<string, ApiSession[]> {
|
||||||
|
const sorted = [...sessions].sort((a, b) => (a.startTime || '').localeCompare(b.startTime || ''));
|
||||||
|
const groups = new Map<string, ApiSession[]>();
|
||||||
|
for (const session of sorted) {
|
||||||
|
const key = toDateKey(session.startTime) || '_nodate';
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(session);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the current agenda into an existing set of schedule days.
|
||||||
|
*
|
||||||
|
* - Sessions already present (matched by `sourceId`) are refreshed from the
|
||||||
|
* agenda (time/type/title) — a sync overrides earlier custom edits on synced
|
||||||
|
* rows, as requested.
|
||||||
|
* - New agenda sessions are appended to their matching day (created if needed).
|
||||||
|
* - Days/sessions that are NOT in the agenda (manually added, or removed from
|
||||||
|
* the agenda) are kept untouched.
|
||||||
|
*
|
||||||
|
* Passing an empty `existingDays` produces a clean build from the agenda.
|
||||||
|
*/
|
||||||
|
export function mergeScheduleDays(existingDays: ScheduleDay[], sessions: ApiSession[]): ScheduleDay[] {
|
||||||
|
const days: ScheduleDay[] = (Array.isArray(existingDays) ? existingDays : []).map((d) => ({
|
||||||
|
...d,
|
||||||
|
sessions: Array.isArray(d.sessions) ? d.sessions.map((s) => ({ ...s })) : [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bySourceId = new Map<string, ScheduleSession>();
|
||||||
|
for (const day of days) {
|
||||||
|
for (const session of day.sessions) {
|
||||||
|
if (session.sourceId) bySourceId.set(String(session.sourceId), session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupByDateKey(sessions);
|
||||||
|
const sortedKeys = [...groups.keys()].sort((a, b) => {
|
||||||
|
if (a === '_nodate') return 1;
|
||||||
|
if (b === '_nodate') return -1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
let dayCount = days.length;
|
||||||
|
for (const dateKey of sortedKeys) {
|
||||||
|
let day = days.find((d) => d.sourceDateKey === dateKey);
|
||||||
|
if (!day) {
|
||||||
|
dayCount += 1;
|
||||||
|
day = {
|
||||||
|
label: `Day ${String(dayCount).padStart(2, '0')}`,
|
||||||
|
date: toDateLabel(dateKey),
|
||||||
|
sourceDateKey: dateKey,
|
||||||
|
sessions: [],
|
||||||
|
};
|
||||||
|
days.push(day);
|
||||||
|
}
|
||||||
|
for (const session of groups.get(dateKey)!) {
|
||||||
|
const existing = bySourceId.get(session._id);
|
||||||
|
if (existing) {
|
||||||
|
existing.time = formatTimeRange(session.startTime, session.endTime);
|
||||||
|
existing.type = sessionTypeLabel(session);
|
||||||
|
existing.title = session.title;
|
||||||
|
} else {
|
||||||
|
const mapped = mapSession(session);
|
||||||
|
day.sessions.push(mapped);
|
||||||
|
bySourceId.set(session._id, mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,16 @@ import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||||
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
|
import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGroup, HelperText, IS, SS, focusIn, focusOut } from "./PanelShell";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
|
import { eventsApi, formsApi, participantListsApi, portfolioListOrganizationId } from "../../../services/api";
|
||||||
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
|
import type { Form, FormField, ParticipantListRecord } from "../../../services/api";
|
||||||
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
|
import { assignUniqueFieldNames } from "../../utils/formFieldNames";
|
||||||
|
import {
|
||||||
|
EVENT_HEADER_TOGGLE_LABELS,
|
||||||
|
eventHeaderFromApi,
|
||||||
|
eventHeaderToApi,
|
||||||
|
type EventHeaderToggleKey,
|
||||||
|
} from "../../utils/formEventHeaderDisplay";
|
||||||
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
|
import { DropdownOtherConfigEditor } from "../forms/DropdownOtherConfigEditor";
|
||||||
|
|
||||||
type FormMode = "feedback" | "registration" | "other" | "web-check-in";
|
type FormMode = "feedback" | "registration" | "other" | "web-check-in";
|
||||||
|
|
@ -453,6 +460,12 @@ interface FForm {
|
||||||
confirmationEmail: "Yes" | "No";
|
confirmationEmail: "Yes" | "No";
|
||||||
onSubmitTags: string;
|
onSubmitTags: string;
|
||||||
onSubmitListId: string;
|
onSubmitListId: string;
|
||||||
|
headerShowLogo: "Yes" | "No";
|
||||||
|
headerShowTitle: "Yes" | "No";
|
||||||
|
headerShowDateRange: "Yes" | "No";
|
||||||
|
headerShowEventType: "Yes" | "No";
|
||||||
|
headerShowLocationType: "Yes" | "No";
|
||||||
|
headerShowVenue: "Yes" | "No";
|
||||||
}
|
}
|
||||||
|
|
||||||
const newQ = (types: string[]): Question => {
|
const newQ = (types: string[]): Question => {
|
||||||
|
|
@ -483,6 +496,12 @@ const initialForm = (qTypes: string[], isFeedback: boolean): FForm => ({
|
||||||
confirmationEmail: "Yes",
|
confirmationEmail: "Yes",
|
||||||
onSubmitTags: "",
|
onSubmitTags: "",
|
||||||
onSubmitListId: "",
|
onSubmitListId: "",
|
||||||
|
headerShowLogo: "Yes",
|
||||||
|
headerShowTitle: "Yes",
|
||||||
|
headerShowDateRange: "Yes",
|
||||||
|
headerShowEventType: "Yes",
|
||||||
|
headerShowLocationType: "Yes",
|
||||||
|
headerShowVenue: "Yes",
|
||||||
});
|
});
|
||||||
|
|
||||||
function typeFromApi(
|
function typeFromApi(
|
||||||
|
|
@ -702,6 +721,9 @@ export function CreateFormPanel({
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const canWriteForms = canModule("forms", "write");
|
||||||
|
const readOnly = Boolean(formId) && !canWriteForms;
|
||||||
const listOrgId = portfolioListOrganizationId(user);
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
const isFeedback = mode === "feedback";
|
const isFeedback = mode === "feedback";
|
||||||
const isReg = mode === "registration";
|
const isReg = mode === "registration";
|
||||||
|
|
@ -780,6 +802,7 @@ export function CreateFormPanel({
|
||||||
: fromApiFields(data.fields, qTypes, loadMode);
|
: fromApiFields(data.fields, qTypes, loadMode);
|
||||||
const s = data.settings ?? {};
|
const s = data.settings ?? {};
|
||||||
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
|
const tags = Array.isArray(s.onSubmitAddTags) ? s.onSubmitAddTags.join(", ") : "";
|
||||||
|
const header = eventHeaderFromApi(s.eventHeader);
|
||||||
setForm({
|
setForm({
|
||||||
name: data.name ?? "",
|
name: data.name ?? "",
|
||||||
description: data.description ?? "",
|
description: data.description ?? "",
|
||||||
|
|
@ -794,6 +817,12 @@ export function CreateFormPanel({
|
||||||
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
|
confirmationEmail: s.sendConfirmationEmail === false ? "No" : "Yes",
|
||||||
onSubmitTags: tags,
|
onSubmitTags: tags,
|
||||||
onSubmitListId: s.onSubmitParticipantListId ?? "",
|
onSubmitListId: s.onSubmitParticipantListId ?? "",
|
||||||
|
headerShowLogo: header.showLogo,
|
||||||
|
headerShowTitle: header.showTitle,
|
||||||
|
headerShowDateRange: header.showDateRange,
|
||||||
|
headerShowEventType: header.showEventType,
|
||||||
|
headerShowLocationType: header.showLocationType,
|
||||||
|
headerShowVenue: header.showVenue,
|
||||||
});
|
});
|
||||||
setSelectedEventId(eventIdFromFormRef(data.event));
|
setSelectedEventId(eventIdFromFormRef(data.event));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -881,6 +910,14 @@ export function CreateFormPanel({
|
||||||
paymentEnabled: form.paymentEnabled === "Yes",
|
paymentEnabled: form.paymentEnabled === "Yes",
|
||||||
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
|
paymentAmount: form.paymentEnabled === "Yes" && form.amount.trim() ? form.amount.trim() : "",
|
||||||
sendConfirmationEmail: form.confirmationEmail === "Yes",
|
sendConfirmationEmail: form.confirmationEmail === "Yes",
|
||||||
|
eventHeader: eventHeaderToApi({
|
||||||
|
showLogo: form.headerShowLogo,
|
||||||
|
showTitle: form.headerShowTitle,
|
||||||
|
showDateRange: form.headerShowDateRange,
|
||||||
|
showEventType: form.headerShowEventType,
|
||||||
|
showLocationType: form.headerShowLocationType,
|
||||||
|
showVenue: form.headerShowVenue,
|
||||||
|
}),
|
||||||
...(isReg
|
...(isReg
|
||||||
? {
|
? {
|
||||||
onSubmitAddTags: tagArr,
|
onSubmitAddTags: tagArr,
|
||||||
|
|
@ -954,9 +991,15 @@ export function CreateFormPanel({
|
||||||
completedCount={acc.done.size}
|
completedCount={acc.done.size}
|
||||||
totalSections={TOTAL}
|
totalSections={TOTAL}
|
||||||
ctaLabel="Save"
|
ctaLabel="Save"
|
||||||
ctaDisabled={!form.name.trim() || !selectedEventId.trim() || submitting || (!formId && orgEvents.length === 0)}
|
ctaDisabled={readOnly || !form.name.trim() || !selectedEventId.trim() || submitting || (!formId && orgEvents.length === 0)}
|
||||||
|
hidePrimaryCta={readOnly}
|
||||||
onCtaClick={handleSave}
|
onCtaClick={handleSave}
|
||||||
>
|
>
|
||||||
|
{readOnly ? (
|
||||||
|
<p style={{ padding: "12px 16px", margin: "0 0 12px", background: "var(--secondary)", borderRadius: 8, fontSize: 13, color: "var(--muted)" }}>
|
||||||
|
You have read-only access to this form. Fields cannot be edited.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
n={1}
|
n={1}
|
||||||
title="Form Details"
|
title="Form Details"
|
||||||
|
|
@ -970,7 +1013,7 @@ export function CreateFormPanel({
|
||||||
>
|
>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Form Name" required />
|
<Label text="Form Name" required />
|
||||||
<input style={IS} placeholder={isFeedbackOrOther ? "e.g. Post-Event Feedback 2026" : "e.g. Conference Registration 2026"} value={form.name} onChange={e => set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} />
|
<input style={IS} placeholder={isFeedbackOrOther ? "e.g. Post-Event Feedback 2026" : "e.g. Conference Registration 2026"} value={form.name} onChange={e => set("name", e.target.value)} onFocus={focusIn} onBlur={focusOut} readOnly={readOnly} disabled={readOnly} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Event" required />
|
<Label text="Event" required />
|
||||||
|
|
@ -1151,6 +1194,36 @@ export function CreateFormPanel({
|
||||||
<HelperText text="Shown to respondents after they submit the form." />
|
<HelperText text="Shown to respondents after they submit the form." />
|
||||||
</Field>
|
</Field>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
<Field>
|
||||||
|
<Label text="Event header on public form" />
|
||||||
|
<HelperText text="Choose which linked event details appear above the form on the public page." />
|
||||||
|
<Grid cols={2}>
|
||||||
|
{(Object.keys(EVENT_HEADER_TOGGLE_LABELS) as EventHeaderToggleKey[]).map((key) => {
|
||||||
|
const formKey = {
|
||||||
|
showLogo: "headerShowLogo",
|
||||||
|
showTitle: "headerShowTitle",
|
||||||
|
showDateRange: "headerShowDateRange",
|
||||||
|
showEventType: "headerShowEventType",
|
||||||
|
showLocationType: "headerShowLocationType",
|
||||||
|
showVenue: "headerShowVenue",
|
||||||
|
}[key] as keyof FForm;
|
||||||
|
return (
|
||||||
|
<Field key={key}>
|
||||||
|
<Label text={EVENT_HEADER_TOGGLE_LABELS[key]} />
|
||||||
|
<RadioGroup
|
||||||
|
name={`header-${key}`}
|
||||||
|
value={form[formKey] as "Yes" | "No"}
|
||||||
|
onChange={(v) => set(formKey, v as FForm[typeof formKey])}
|
||||||
|
options={[
|
||||||
|
{ label: "Show", value: "Yes" },
|
||||||
|
{ label: "Hide", value: "No" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Field>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ import { PanelShell, AccordionSection, useAccordion, Label, Field, Grid, RadioGr
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
import { usersApi } from "../../../services/api";
|
import { usersApi } from "../../../services/api";
|
||||||
import { eventsApi, portfolioListOrganizationId } from "../../../services/api";
|
import { eventsApi, portfolioListOrganizationId } from "../../../services/api";
|
||||||
|
import { rolesApi, type Role } from "../../../services/api/roles";
|
||||||
import type { Event, User } from "../../../services/api";
|
import type { Event, User } from "../../../services/api";
|
||||||
|
|
||||||
const ROLES = [
|
const SYSTEM_ROLES = [
|
||||||
{ value: "admin", label: "Admin", desc: "Full access to all events and settings." },
|
{ value: "admin", label: "Admin", desc: "Full access to all events and settings." },
|
||||||
{ value: "moderator", label: "Moderator", desc: "Can manage sessions, participants, and messaging." },
|
{ value: "moderator", label: "Moderator", desc: "Can manage sessions, participants, and messaging." },
|
||||||
{ value: "viewer", label: "Viewer", desc: "Can view events and dashboards only." },
|
{ value: "viewer", label: "Viewer", desc: "Can view events and dashboards only." },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface FForm {
|
interface FForm {
|
||||||
|
|
@ -22,6 +23,8 @@ interface FForm {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
roleMode: "system" | "custom";
|
||||||
|
customRoleId: string;
|
||||||
assignedEvents: string[];
|
assignedEvents: string[];
|
||||||
sendInviteNow: "Yes" | "No";
|
sendInviteNow: "Yes" | "No";
|
||||||
personalMessage: string;
|
personalMessage: string;
|
||||||
|
|
@ -33,6 +36,8 @@ const initialForm: FForm = {
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "moderator",
|
role: "moderator",
|
||||||
|
roleMode: "system",
|
||||||
|
customRoleId: "",
|
||||||
assignedEvents: [],
|
assignedEvents: [],
|
||||||
sendInviteNow: "Yes",
|
sendInviteNow: "Yes",
|
||||||
personalMessage: "",
|
personalMessage: "",
|
||||||
|
|
@ -42,46 +47,62 @@ function isValidEmail(s: string): boolean {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formFromUser(editUser: User): FForm {
|
||||||
|
const customRole = editUser.customRole;
|
||||||
|
const assigned = editUser.assignedEvents ?? [];
|
||||||
|
if (customRole && !customRole.isSystem) {
|
||||||
|
return {
|
||||||
|
firstName: editUser.firstName ?? "",
|
||||||
|
lastName: editUser.lastName ?? "",
|
||||||
|
email: editUser.email ?? "",
|
||||||
|
password: "",
|
||||||
|
role: editUser.membershipRole ?? editUser.role ?? "moderator",
|
||||||
|
roleMode: "custom",
|
||||||
|
customRoleId: customRole._id,
|
||||||
|
assignedEvents: assigned,
|
||||||
|
sendInviteNow: "Yes",
|
||||||
|
personalMessage: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
firstName: editUser.firstName ?? "",
|
||||||
|
lastName: editUser.lastName ?? "",
|
||||||
|
email: editUser.email ?? "",
|
||||||
|
password: "",
|
||||||
|
role: editUser.membershipRole ?? editUser.role ?? "moderator",
|
||||||
|
roleMode: "system",
|
||||||
|
customRoleId: "",
|
||||||
|
assignedEvents: assigned,
|
||||||
|
sendInviteNow: "Yes",
|
||||||
|
personalMessage: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: User | null; onClose: () => void; onSuccess?: () => void }) {
|
export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: User | null; onClose: () => void; onSuccess?: () => void }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const listOrgId = portfolioListOrganizationId(user);
|
const listOrgId = portfolioListOrganizationId(user);
|
||||||
const isEdit = !!editUser;
|
const isEdit = !!editUser;
|
||||||
const TOTAL = 3;
|
const TOTAL = 3;
|
||||||
const acc = useAccordion(TOTAL);
|
const acc = useAccordion(TOTAL);
|
||||||
const [form, setForm] = useState<FForm>(() =>
|
const [form, setForm] = useState<FForm>(() => (editUser ? formFromUser(editUser) : initialForm));
|
||||||
editUser
|
|
||||||
? {
|
|
||||||
firstName: editUser.firstName ?? "",
|
|
||||||
lastName: editUser.lastName ?? "",
|
|
||||||
email: editUser.email ?? "",
|
|
||||||
password: "",
|
|
||||||
role: editUser.role ?? "moderator",
|
|
||||||
assignedEvents: [],
|
|
||||||
sendInviteNow: "Yes",
|
|
||||||
personalMessage: "",
|
|
||||||
}
|
|
||||||
: initialForm
|
|
||||||
);
|
|
||||||
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
|
const [sectionErrors, setSectionErrors] = useState<Record<number, string>>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [eventsLoading, setEventsLoading] = useState(true);
|
const [eventsLoading, setEventsLoading] = useState(true);
|
||||||
|
const [customRoles, setCustomRoles] = useState<Role[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editUser) {
|
if (editUser) setForm(formFromUser(editUser));
|
||||||
setForm({
|
|
||||||
firstName: editUser.firstName ?? "",
|
|
||||||
lastName: editUser.lastName ?? "",
|
|
||||||
email: editUser.email ?? "",
|
|
||||||
password: "",
|
|
||||||
role: editUser.role ?? "moderator",
|
|
||||||
assignedEvents: [],
|
|
||||||
sendInviteNow: "Yes",
|
|
||||||
personalMessage: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [editUser?._id]);
|
}, [editUser?._id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rolesApi.list().then((r) => {
|
||||||
|
if (r.data?.roles) {
|
||||||
|
setCustomRoles(r.data.roles.filter((role) => !role.isSystem));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -118,7 +139,10 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
if (isEdit && f.password.length > 0 && f.password.length < 6) return false;
|
if (isEdit && f.password.length > 0 && f.password.length < 6) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (n === 2) return true;
|
if (n === 2) {
|
||||||
|
if (f.roleMode === "custom" && !f.customRoleId) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (n === 3) return true;
|
if (n === 3) return true;
|
||||||
return true;
|
return true;
|
||||||
}, [isEdit]);
|
}, [isEdit]);
|
||||||
|
|
@ -129,21 +153,31 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
if (valid) acc.advance(n);
|
if (valid) acc.advance(n);
|
||||||
}, [form, validateSection, acc]);
|
}, [form, validateSection, acc]);
|
||||||
|
|
||||||
|
const buildPayload = useCallback(() => {
|
||||||
|
const base = {
|
||||||
|
email: form.email.trim(),
|
||||||
|
firstName: form.firstName.trim() || undefined,
|
||||||
|
lastName: form.lastName.trim() || undefined,
|
||||||
|
assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
|
||||||
|
};
|
||||||
|
if (form.roleMode === "custom" && form.customRoleId) {
|
||||||
|
return { ...base, role: form.role, customRoleId: form.customRoleId };
|
||||||
|
}
|
||||||
|
return { ...base, role: form.role };
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
if (!validateSection(form, 1)) {
|
if (!validateSection(form, 1) || !validateSection(form, 2)) {
|
||||||
setSectionErrors(prev => ({ ...prev, 1: "Please complete all required fields." }));
|
setSectionErrors(prev => ({ ...prev, 1: "Please complete all required fields.", 2: form.roleMode === "custom" && !form.customRoleId ? "Select a custom role." : "" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSectionErrors({});
|
setSectionErrors({});
|
||||||
|
const payload = buildPayload();
|
||||||
if (isEdit && editUser) {
|
if (isEdit && editUser) {
|
||||||
const res = await usersApi.update(editUser._id, {
|
const res = await usersApi.update(editUser._id, {
|
||||||
email: form.email.trim(),
|
...payload,
|
||||||
firstName: form.firstName.trim() || undefined,
|
|
||||||
lastName: form.lastName.trim() || undefined,
|
|
||||||
role: form.role,
|
|
||||||
...(form.password ? { password: form.password } : {}),
|
...(form.password ? { password: form.password } : {}),
|
||||||
assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
|
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
|
|
@ -152,12 +186,8 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await usersApi.create({
|
const res = await usersApi.create({
|
||||||
email: form.email.trim(),
|
...payload,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
firstName: form.firstName.trim() || undefined,
|
|
||||||
lastName: form.lastName.trim() || undefined,
|
|
||||||
role: form.role,
|
|
||||||
assignedEvents: form.assignedEvents.length ? form.assignedEvents : undefined,
|
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
|
|
@ -167,7 +197,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
}
|
}
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
onClose();
|
||||||
}, [form, validateSection, isEdit, editUser, onSuccess, onClose]);
|
}, [form, validateSection, isEdit, editUser, onSuccess, onClose, buildPayload]);
|
||||||
|
|
||||||
const onSaveLast = useCallback(() => {
|
const onSaveLast = useCallback(() => {
|
||||||
if (!validateSection(form, 3)) {
|
if (!validateSection(form, 3)) {
|
||||||
|
|
@ -184,8 +214,11 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
form.email.trim() &&
|
form.email.trim() &&
|
||||||
isValidEmail(form.email) &&
|
isValidEmail(form.email) &&
|
||||||
!submitting &&
|
!submitting &&
|
||||||
|
(form.roleMode !== "custom" || Boolean(form.customRoleId)) &&
|
||||||
(!isEdit ? form.password.length >= 6 : (form.password.length === 0 || form.password.length >= 6));
|
(!isEdit ? form.password.length >= 6 : (form.password.length === 0 || form.password.length >= 6));
|
||||||
const selectedRole = ROLES.find(r => r.value === form.role);
|
|
||||||
|
const selectedSystemRole = SYSTEM_ROLES.find(r => r.value === form.role);
|
||||||
|
const selectedCustomRole = customRoles.find(r => r._id === form.customRoleId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<PanelShell
|
||||||
|
|
@ -199,7 +232,6 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
ctaDisabled={!canSave}
|
ctaDisabled={!canSave}
|
||||||
onCtaClick={handleSave}
|
onCtaClick={handleSave}
|
||||||
>
|
>
|
||||||
{/* 1. User Details */}
|
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
n={1}
|
n={1}
|
||||||
title="User Details"
|
title="User Details"
|
||||||
|
|
@ -226,14 +258,13 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
<HelperText text="An invitation link will be sent to this address." />
|
<HelperText text="An invitation link will be sent to this address." />
|
||||||
</Field>
|
</Field>
|
||||||
<Field span2>
|
<Field span2>
|
||||||
<Label text="Initial password" required />
|
<Label text={isEdit ? "New password (optional)" : "Initial password"} required={!isEdit} />
|
||||||
<input type="password" style={IS} placeholder="Min 6 characters" value={form.password} onChange={e => set("password", e.target.value)} onFocus={focusIn} onBlur={focusOut} minLength={6} />
|
<input type="password" style={IS} placeholder="Min 6 characters" value={form.password} onChange={e => set("password", e.target.value)} onFocus={focusIn} onBlur={focusOut} minLength={6} />
|
||||||
<HelperText text="User will need this to sign in. They can change it later." />
|
<HelperText text={isEdit ? "Leave blank to keep the current password." : "User will need this to sign in. They can change it later."} />
|
||||||
</Field>
|
</Field>
|
||||||
</Grid>
|
</Grid>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* 2. Role & Permissions */}
|
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
n={2}
|
n={2}
|
||||||
title="Role & Permissions"
|
title="Role & Permissions"
|
||||||
|
|
@ -245,30 +276,85 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
nextLabel="Event Access"
|
nextLabel="Event Access"
|
||||||
sectionError={sectionErrors[2]}
|
sectionError={sectionErrors[2]}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
{ROLES.map(r => (
|
<button
|
||||||
<button key={r.value} type="button" onClick={() => set("role", r.value)}
|
type="button"
|
||||||
style={{ display: "flex", alignItems: "flex-start", gap: "12px", padding: "12px 14px", borderRadius: "var(--radius-button)", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--accent)" : "var(--card)", cursor: "pointer", textAlign: "left", transition: "all .12s" }}>
|
onClick={() => set("roleMode", "system")}
|
||||||
<div style={{ width: "16px", height: "16px", borderRadius: "50%", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--primary)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", marginTop: "1px", flexShrink: 0 }}>
|
style={{
|
||||||
{form.role === r.value && <div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#fff" }} />}
|
flex: 1,
|
||||||
</div>
|
padding: "8px 12px",
|
||||||
<div>
|
borderRadius: "var(--radius-button)",
|
||||||
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: form.role === r.value ? "var(--primary)" : "var(--foreground)", margin: "0 0 2px" }}>{r.label}</p>
|
border: `2px solid ${form.roleMode === "system" ? "var(--primary)" : "var(--border)"}`,
|
||||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>{r.desc}</p>
|
background: form.roleMode === "system" ? "var(--accent)" : "var(--card)",
|
||||||
</div>
|
cursor: "pointer",
|
||||||
</button>
|
fontSize: "var(--text-sm)",
|
||||||
))}
|
}}
|
||||||
|
>
|
||||||
|
System role
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => set("roleMode", "custom")}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "var(--radius-button)",
|
||||||
|
border: `2px solid ${form.roleMode === "custom" ? "var(--primary)" : "var(--border)"}`,
|
||||||
|
background: form.roleMode === "custom" ? "var(--accent)" : "var(--card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "var(--text-sm)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Custom role
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{selectedRole && (
|
{form.roleMode === "system" ? (
|
||||||
<div style={{ padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)" }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
{SYSTEM_ROLES.map(r => (
|
||||||
|
<button key={r.value} type="button" onClick={() => set("role", r.value)}
|
||||||
|
style={{ display: "flex", alignItems: "flex-start", gap: "12px", padding: "12px 14px", borderRadius: "var(--radius-button)", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--accent)" : "var(--card)", cursor: "pointer", textAlign: "left", transition: "all .12s" }}>
|
||||||
|
<div style={{ width: "16px", height: "16px", borderRadius: "50%", border: `2px solid ${form.role === r.value ? "var(--primary)" : "var(--border)"}`, background: form.role === r.value ? "var(--primary)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center", marginTop: "1px", flexShrink: 0 }}>
|
||||||
|
{form.role === r.value && <div style={{ width: "6px", height: "6px", borderRadius: "50%", background: "#fff" }} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: "var(--text-sm)", fontFamily: "var(--font-noto-sans)", fontWeight: "var(--font-weight-medium)", color: form.role === r.value ? "var(--primary)" : "var(--foreground)", margin: "0 0 2px" }}>{r.label}</p>
|
||||||
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>{r.desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Field>
|
||||||
|
<Label text="Custom role" required />
|
||||||
|
<select
|
||||||
|
value={form.customRoleId}
|
||||||
|
onChange={(e) => set("customRoleId", e.target.value)}
|
||||||
|
style={{ ...IS, width: "100%" }}
|
||||||
|
>
|
||||||
|
<option value="">Select a role…</option>
|
||||||
|
{customRoles.map((r) => (
|
||||||
|
<option key={r._id} value={r._id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{customRoles.length === 0 && (
|
||||||
|
<HelperText text="No custom roles yet. Create roles under User Management → Manage Roles." />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{(selectedSystemRole && form.roleMode === "system") || (selectedCustomRole && form.roleMode === "custom") ? (
|
||||||
|
<div style={{ padding: "10px 14px", borderRadius: "var(--radius-button)", background: "var(--secondary)", border: "1px solid var(--border)", marginTop: 12 }}>
|
||||||
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
<p style={{ fontSize: "var(--text-xs)", fontFamily: "var(--font-noto-sans)", color: "var(--muted)", margin: 0 }}>
|
||||||
<strong style={{ color: "var(--foreground)" }}>{selectedRole.label}:</strong> {selectedRole.desc}
|
<strong style={{ color: "var(--foreground)" }}>
|
||||||
|
{form.roleMode === "custom" ? selectedCustomRole?.name : selectedSystemRole?.label}:
|
||||||
|
</strong>{" "}
|
||||||
|
{form.roleMode === "custom"
|
||||||
|
? selectedCustomRole?.description || "Custom permissions apply."
|
||||||
|
: selectedSystemRole?.desc}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
{/* 3. Event Access & Invite */}
|
|
||||||
<AccordionSection
|
<AccordionSection
|
||||||
n={3}
|
n={3}
|
||||||
title="Event Access & Invite"
|
title="Event Access & Invite"
|
||||||
|
|
@ -309,6 +395,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
)}
|
)}
|
||||||
<HelperText text="Leave empty to grant access to all events based on their role." />
|
<HelperText text="Leave empty to grant access to all events based on their role." />
|
||||||
</Field>
|
</Field>
|
||||||
|
{!isEdit ? (
|
||||||
<Grid cols={1}>
|
<Grid cols={1}>
|
||||||
<Field>
|
<Field>
|
||||||
<Label text="Send Invite Now" required />
|
<Label text="Send Invite Now" required />
|
||||||
|
|
@ -320,6 +407,7 @@ export function InviteUserPanel({ editUser, onClose, onSuccess }: { editUser?: U
|
||||||
<HelperText text={`${form.personalMessage.length}/300 characters.`} />
|
<HelperText text={`${form.personalMessage.length}/300 characters.`} />
|
||||||
</Field>
|
</Field>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
) : null}
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
</PanelShell>
|
</PanelShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -286,5 +286,10 @@ export function useAccordion(total: number) {
|
||||||
if (n < total) setOpen(n + 1);
|
if (n < total) setOpen(n + 1);
|
||||||
};
|
};
|
||||||
const toggle = (n: number) => setOpen(prev => prev === n ? 0 : n);
|
const toggle = (n: number) => setOpen(prev => prev === n ? 0 : n);
|
||||||
return { open, done, advance, toggle };
|
const skipTo = (n: number) => {
|
||||||
|
if (n < 1) return;
|
||||||
|
setDone(new Set(Array.from({ length: n - 1 }, (_, i) => i + 1)));
|
||||||
|
setOpen(n);
|
||||||
|
};
|
||||||
|
return { open, done, advance, toggle, skipTo };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,10 @@ import {
|
||||||
type PendingInvite,
|
type PendingInvite,
|
||||||
type AgendaTrack,
|
type AgendaTrack,
|
||||||
} from "../../../services/api/eventStaff";
|
} from "../../../services/api/eventStaff";
|
||||||
|
import { buildEventAccessPayload } from "../../utils/eventAccessPayload";
|
||||||
const ROLES = ["Moderator", "Presenter", "Co-organiser", "Viewer"];
|
import { buildEventRoleOptions } from "../../utils/eventRoleOptions";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
|
import { rolesApi, type Role } from "../../../services/api/roles";
|
||||||
|
|
||||||
function getInitials(firstName?: string, lastName?: string, email?: string): string {
|
function getInitials(firstName?: string, lastName?: string, email?: string): string {
|
||||||
if (firstName || lastName) {
|
if (firstName || lastName) {
|
||||||
|
|
@ -133,9 +135,10 @@ function SessionTreeSection({
|
||||||
/* Add existing org users to event */
|
/* Add existing org users to event */
|
||||||
function AddUsersModal({
|
function AddUsersModal({
|
||||||
eventId,
|
eventId,
|
||||||
|
roleOptions,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: { eventId: string; onClose: () => void; onSuccess: () => void }) {
|
}: { eventId: string; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
|
||||||
const [step, setStep] = useState<"users" | "sessions">("users");
|
const [step, setStep] = useState<"users" | "sessions">("users");
|
||||||
const [available, setAvailable] = useState<{ _id: string; email: string; firstName?: string; lastName?: string; role: string }[]>([]);
|
const [available, setAvailable] = useState<{ _id: string; email: string; firstName?: string; lastName?: string; role: string }[]>([]);
|
||||||
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
|
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
|
||||||
|
|
@ -187,9 +190,11 @@ function AddUsersModal({
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
|
||||||
const res = await eventStaffApi.addStaff(eventId, {
|
const res = await eventStaffApi.addStaff(eventId, {
|
||||||
userIds: Array.from(selectedUserIds),
|
userIds: Array.from(selectedUserIds),
|
||||||
sessionIds: Array.from(sessionIds),
|
sessionIds: sids,
|
||||||
|
trackIds: tids,
|
||||||
eventRole,
|
eventRole,
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
@ -228,7 +233,7 @@ function AddUsersModal({
|
||||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 8 }}>Event role (applies to all selected)</p>
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 8 }}>Event role (applies to all selected)</p>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
|
<select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
|
||||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
{roleOptions.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 10 }}>Select users</p>
|
<p style={{ fontSize: "var(--text-xs)", color: "var(--muted)", marginBottom: 10 }}>Select users</p>
|
||||||
|
|
@ -278,9 +283,10 @@ function AddUsersModal({
|
||||||
interface InviteRow { id: string; email: string; role: string; }
|
interface InviteRow { id: string; email: string; role: string; }
|
||||||
function InviteModal({
|
function InviteModal({
|
||||||
eventId,
|
eventId,
|
||||||
|
roleOptions,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: { eventId: string; onClose: () => void; onSuccess: () => void }) {
|
}: { eventId: string; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
|
||||||
const [rows, setRows] = useState<InviteRow[]>([{ id: "r1", email: "", role: "Viewer" }]);
|
const [rows, setRows] = useState<InviteRow[]>([{ id: "r1", email: "", role: "Viewer" }]);
|
||||||
const [step, setStep] = useState<"users" | "sessions">("users");
|
const [step, setStep] = useState<"users" | "sessions">("users");
|
||||||
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
|
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
|
||||||
|
|
@ -288,6 +294,7 @@ function InviteModal({
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [result, setResult] = useState<{ sent: number; errors: { email: string; message: string }[] } | null>(null);
|
const [result, setResult] = useState<{ sent: number; errors: { email: string; message: string }[] } | null>(null);
|
||||||
|
const [requestError, setRequestError] = useState<string | null>(null);
|
||||||
|
|
||||||
const allSessionIds = useMemo(() => allSessionIdsFromTracks(tracks), [tracks]);
|
const allSessionIds = useMemo(() => allSessionIdsFromTracks(tracks), [tracks]);
|
||||||
|
|
||||||
|
|
@ -314,17 +321,25 @@ function InviteModal({
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
setRequestError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
|
||||||
const res = await eventStaffApi.sendInvites(eventId, {
|
const res = await eventStaffApi.sendInvites(eventId, {
|
||||||
invites: validRows.map((r) => ({ email: r.email.trim(), eventRole: r.role || "Viewer" })),
|
invites: validRows.map((r) => ({ email: r.email.trim(), eventRole: r.role || "Viewer" })),
|
||||||
sessionIds: Array.from(sessionIds),
|
sessionIds: sids,
|
||||||
|
trackIds: tids,
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (res.data) setResult(res.data);
|
if (res.error && !res.data) {
|
||||||
if (res.error && !res.data) return;
|
setRequestError(res.error);
|
||||||
if (res.data && (res.data.sent > 0 || res.data.errors?.length === 0)) {
|
return;
|
||||||
onSuccess();
|
}
|
||||||
onClose();
|
if (res.data) {
|
||||||
|
setResult(res.data);
|
||||||
|
if (res.data.sent > 0 && (!res.data.errors || res.data.errors.length === 0)) {
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -362,7 +377,7 @@ function InviteModal({
|
||||||
<p style={{ fontSize: "var(--text-xs)", marginBottom: 5 }}>Role <span style={{ color: "var(--destructive)" }}>*</span></p>
|
<p style={{ fontSize: "var(--text-xs)", marginBottom: 5 }}>Role <span style={{ color: "var(--destructive)" }}>*</span></p>
|
||||||
<select value={row.role} onChange={(e) => updateRow(row.id, "role", e.target.value)}
|
<select value={row.role} onChange={(e) => updateRow(row.id, "role", e.target.value)}
|
||||||
style={{ width: "100%", height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
|
style={{ width: "100%", height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
|
||||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
{roleOptions.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{rows.length > 1 && (
|
{rows.length > 1 && (
|
||||||
|
|
@ -380,6 +395,11 @@ function InviteModal({
|
||||||
loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />
|
loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{requestError && (
|
||||||
|
<div style={{ padding: "8px 24px", background: "#fef2f2", color: "#b91c1c", fontSize: "var(--text-sm)" }}>
|
||||||
|
{requestError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{result?.errors && result.errors.length > 0 && (
|
{result?.errors && result.errors.length > 0 && (
|
||||||
<div style={{ padding: "8px 24px", background: "#fef2f2", color: "#b91c1c", fontSize: "var(--text-sm)" }}>
|
<div style={{ padding: "8px 24px", background: "#fef2f2", color: "#b91c1c", fontSize: "var(--text-sm)" }}>
|
||||||
{result.errors.map((e, i) => <div key={i}>{e.email}: {e.message}</div>)}
|
{result.errors.map((e, i) => <div key={i}>{e.email}: {e.message}</div>)}
|
||||||
|
|
@ -411,9 +431,10 @@ function InviteModal({
|
||||||
function EditAccessModal({
|
function EditAccessModal({
|
||||||
eventId,
|
eventId,
|
||||||
member,
|
member,
|
||||||
|
roleOptions,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}: { eventId: string; member: EventStaffMember; onClose: () => void; onSuccess: () => void }) {
|
}: { eventId: string; member: EventStaffMember; roleOptions: string[]; onClose: () => void; onSuccess: () => void }) {
|
||||||
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
|
const [tracks, setTracks] = useState<AgendaTrack[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
@ -435,7 +456,12 @@ function EditAccessModal({
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const res = await eventStaffApi.updateStaff(eventId, member.userId, { sessionIds: Array.from(sessionIds), eventRole });
|
const { sessionIds: sids, trackIds: tids } = buildEventAccessPayload(sessionIds, tracks);
|
||||||
|
const res = await eventStaffApi.updateStaff(eventId, member.userId, {
|
||||||
|
sessionIds: sids,
|
||||||
|
trackIds: tids,
|
||||||
|
eventRole,
|
||||||
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
if (!res.error) {
|
if (!res.error) {
|
||||||
onSuccess();
|
onSuccess();
|
||||||
|
|
@ -457,7 +483,7 @@ function EditAccessModal({
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<label style={{ fontSize: "var(--text-xs)", color: "var(--muted)", display: "block", marginBottom: 6 }}>Event role</label>
|
<label style={{ fontSize: "var(--text-xs)", color: "var(--muted)", display: "block", marginBottom: 6 }}>Event role</label>
|
||||||
<select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
|
<select value={eventRole} onChange={(e) => setEventRole(e.target.value)} style={{ width: "100%", maxWidth: 200, height: 36, padding: "0 10px", borderRadius: "var(--radius-button)", border: "1px solid var(--border)", background: "var(--card)", fontSize: "var(--text-sm)" }}>
|
||||||
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
|
{roleOptions.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />}
|
{loading ? <p style={{ color: "var(--muted)" }}>Loading sessions…</p> : <SessionTreeSection tracks={tracks} selected={sessionIds} onToggle={toggleSession} onSelectGroup={selectGroup} onSelectAll={selectAllSessions} />}
|
||||||
|
|
@ -473,6 +499,8 @@ function EditAccessModal({
|
||||||
|
|
||||||
export function PermissionsPage() {
|
export function PermissionsPage() {
|
||||||
const { selectedEventId, permissionsOpenAddOnce, setPermissionsOpenAddOnce } = useAppNav();
|
const { selectedEventId, permissionsOpenAddOnce, setPermissionsOpenAddOnce } = useAppNav();
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const canWriteStaff = canModule("user_management", "write");
|
||||||
const [eventName, setEventName] = useState<string | null>(null);
|
const [eventName, setEventName] = useState<string | null>(null);
|
||||||
const [members, setMembers] = useState<EventStaffMember[]>([]);
|
const [members, setMembers] = useState<EventStaffMember[]>([]);
|
||||||
const [invites, setInvites] = useState<PendingInvite[]>([]);
|
const [invites, setInvites] = useState<PendingInvite[]>([]);
|
||||||
|
|
@ -484,6 +512,13 @@ export function PermissionsPage() {
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
const [editMember, setEditMember] = useState<EventStaffMember | null>(null);
|
const [editMember, setEditMember] = useState<EventStaffMember | null>(null);
|
||||||
|
const [orgRoles, setOrgRoles] = useState<Role[]>([]);
|
||||||
|
|
||||||
|
const eventRoleOptions = useMemo(() => buildEventRoleOptions(orgRoles), [orgRoles]);
|
||||||
|
const editRoleOptions = useMemo(
|
||||||
|
() => buildEventRoleOptions(orgRoles, editMember?.eventRole),
|
||||||
|
[orgRoles, editMember?.eventRole],
|
||||||
|
);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!selectedEventId) {
|
if (!selectedEventId) {
|
||||||
|
|
@ -515,6 +550,15 @@ export function PermissionsPage() {
|
||||||
loadData();
|
loadData();
|
||||||
}, [selectedEventId]);
|
}, [selectedEventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAdd && !showInvite && !editMember) return;
|
||||||
|
let cancelled = false;
|
||||||
|
rolesApi.list().then((res) => {
|
||||||
|
if (!cancelled && res.data?.roles) setOrgRoles(res.data.roles);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [showAdd, showInvite, editMember]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!permissionsOpenAddOnce) return;
|
if (!permissionsOpenAddOnce) return;
|
||||||
setShowAdd(true);
|
setShowAdd(true);
|
||||||
|
|
@ -580,12 +624,16 @@ export function PermissionsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
{canWriteStaff ? (
|
||||||
<button type="button" onClick={() => setShowAdd(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--primary)", background: "transparent", color: "var(--primary)", fontSize: "var(--text-sm)", fontWeight: 600, cursor: "pointer" }}>
|
<button type="button" onClick={() => setShowAdd(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", border: "1px solid var(--primary)", background: "transparent", color: "var(--primary)", fontSize: "var(--text-sm)", fontWeight: 600, cursor: "pointer" }}>
|
||||||
<Users size={14} /> Add users
|
<Users size={14} /> Add users
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canWriteStaff ? (
|
||||||
<button type="button" onClick={() => setShowInvite(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontWeight: 600 }}>
|
<button type="button" onClick={() => setShowInvite(true)} style={{ display: "flex", alignItems: "center", gap: 6, padding: "8px 16px", borderRadius: "var(--radius-button)", background: "var(--primary)", color: "var(--primary-foreground)", border: "none", cursor: "pointer", fontSize: "var(--text-sm)", fontWeight: 600 }}>
|
||||||
<UserPlus size={14} /> Invite by email
|
<UserPlus size={14} /> Invite by email
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -625,13 +673,17 @@ export function PermissionsPage() {
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>{m.email}</span>
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>{m.email}</span>
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#e0e7ff", color: "#3730a3", fontSize: 11, fontWeight: 600 }}>{m.eventRole || "Viewer"}</span>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#e0e7ff", color: "#3730a3", fontSize: 11, fontWeight: 600 }}>{m.eventRole || "Viewer"}</span>
|
||||||
<div style={{ display: "flex", gap: 6, opacity: hoveredId === m.userId ? 1 : 0 }}>
|
<div style={{ display: "flex", gap: 6, opacity: hoveredId === m.userId && canWriteStaff ? 1 : 0 }}>
|
||||||
|
{canWriteStaff ? (
|
||||||
|
<>
|
||||||
<button type="button" onClick={() => setEditMember(m)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
<button type="button" onClick={() => setEditMember(m)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
||||||
<Pencil size={12} /> Edit
|
<Pencil size={12} /> Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handleRemoveMember(m.userId)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}>
|
<button type="button" onClick={() => handleRemoveMember(m.userId)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -648,13 +700,17 @@ export function PermissionsPage() {
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>Pending invite</span>
|
<span style={{ fontSize: "var(--text-sm)", color: "var(--muted)" }}>Pending invite</span>
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#fef9c3", color: "#92400e", fontSize: 11, fontWeight: 600 }}>{inv.eventRole}</span>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "3px 10px", borderRadius: 999, width: "fit-content", background: "#fef9c3", color: "#92400e", fontSize: 11, fontWeight: 600 }}>{inv.eventRole}</span>
|
||||||
<div style={{ display: "flex", gap: 6, opacity: hoveredId === `inv-${inv.id}` ? 1 : 0 }}>
|
<div style={{ display: "flex", gap: 6, opacity: hoveredId === `inv-${inv.id}` && canWriteStaff ? 1 : 0 }}>
|
||||||
|
{canWriteStaff ? (
|
||||||
|
<>
|
||||||
<button type="button" onClick={() => handleResendInvite(inv.id)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
<button type="button" onClick={() => handleResendInvite(inv.id)} style={{ display: "flex", alignItems: "center", gap: 4, padding: "5px 11px", borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--card-foreground)", fontSize: "var(--text-xs)", cursor: "pointer" }}>
|
||||||
<MailCheck size={12} /> Resend
|
<MailCheck size={12} /> Resend
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handleCancelInvite(inv.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}>
|
<button type="button" onClick={() => handleCancelInvite(inv.id)} style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-button)", background: "var(--card)", border: "1px solid var(--border)", color: "var(--muted)", cursor: "pointer" }}>
|
||||||
<Trash2 size={12} />
|
<Trash2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -662,9 +718,9 @@ export function PermissionsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showAdd && selectedEventId && <AddUsersModal eventId={selectedEventId} onClose={() => setShowAdd(false)} onSuccess={loadData} />}
|
{showAdd && selectedEventId && <AddUsersModal eventId={selectedEventId} roleOptions={eventRoleOptions} onClose={() => setShowAdd(false)} onSuccess={loadData} />}
|
||||||
{showInvite && selectedEventId && <InviteModal eventId={selectedEventId} onClose={() => setShowInvite(false)} onSuccess={loadData} />}
|
{showInvite && selectedEventId && <InviteModal eventId={selectedEventId} roleOptions={eventRoleOptions} onClose={() => setShowInvite(false)} onSuccess={loadData} />}
|
||||||
{editMember && selectedEventId && <EditAccessModal eventId={selectedEventId} member={editMember} onClose={() => setEditMember(null)} onSuccess={loadData} />}
|
{editMember && selectedEventId && <EditAccessModal eventId={selectedEventId} member={editMember} roleOptions={editRoleOptions} onClose={() => setEditMember(null)} onSuccess={loadData} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Shield, Trash2, Pencil } from "lucide-react";
|
||||||
|
import { rolesApi, type Role, type AclModuleCatalogEntry, type RolePermission } from "../../../services/api/roles";
|
||||||
|
import { usePermission } from "../../hooks/usePermission";
|
||||||
|
|
||||||
|
const ACTIONS = ["read", "write", "delete"] as const;
|
||||||
|
|
||||||
|
export function RolesPage() {
|
||||||
|
const { canModule } = usePermission();
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [catalog, setCatalog] = useState<AclModuleCatalogEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState<Role | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: "", description: "", permissions: [] as RolePermission[] });
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [r, c] = await Promise.all([rolesApi.list(), rolesApi.catalog()]);
|
||||||
|
if (r.data?.roles) setRoles(r.data.roles);
|
||||||
|
if (c.data?.modules) setCatalog(c.data.modules);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!canModule("user_management", "read")) {
|
||||||
|
return <p style={{ padding: 24, color: "var(--muted)" }}>You do not have access to role management.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePerm = (module: string, action: string) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const perms = [...f.permissions];
|
||||||
|
let entry = perms.find((p) => p.module === module);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
module,
|
||||||
|
actions: [],
|
||||||
|
scope: catalog.find((m) => m.key === module)?.rowLevel ? "assigned" : "all",
|
||||||
|
};
|
||||||
|
perms.push(entry);
|
||||||
|
}
|
||||||
|
const has = entry.actions.includes(action);
|
||||||
|
entry.actions = has ? entry.actions.filter((a) => a !== action) : [...entry.actions, action];
|
||||||
|
if (!entry.actions.length) return { ...f, permissions: perms.filter((p) => p.module !== module) };
|
||||||
|
return { ...f, permissions: perms };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError("Name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = editing ? await rolesApi.update(editing._id, form) : await rolesApi.create(form);
|
||||||
|
if (res.error) {
|
||||||
|
setError(res.error.message || "Save failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
setForm({ name: "", description: "", permissions: [] });
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (role: Role) => {
|
||||||
|
if (role.isSystem) return;
|
||||||
|
setEditing(role);
|
||||||
|
setForm({ name: role.name, description: role.description || "", permissions: role.permissions || [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
if (!window.confirm("Delete this role?")) return;
|
||||||
|
const res = await rolesApi.remove(id);
|
||||||
|
if (res.error) alert(res.error.message);
|
||||||
|
else load();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "24px", maxWidth: 960 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 20 }}>
|
||||||
|
<Shield size={22} color="var(--primary)" />
|
||||||
|
<h1 style={{ margin: 0, fontSize: "var(--text-xl)" }}>Custom Roles</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canModule("user_management", "write") && (
|
||||||
|
<div style={{ background: "var(--card)", border: "1px solid var(--border)", borderRadius: 12, padding: 16, marginBottom: 24 }}>
|
||||||
|
<h2 style={{ margin: "0 0 12px", fontSize: "var(--text-base)" }}>{editing ? "Edit role" : "New role"}</h2>
|
||||||
|
{error && <p style={{ color: "var(--destructive)", fontSize: 13 }}>{error}</p>}
|
||||||
|
<input
|
||||||
|
placeholder="Role name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
style={{ width: "100%", marginBottom: 8, padding: 8, borderRadius: 8, border: "1px solid var(--border)" }}
|
||||||
|
/>
|
||||||
|
<div style={{ overflowX: "auto", marginBottom: 12 }}>
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ textAlign: "left", padding: 6 }}>Module</th>
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<th key={a} style={{ padding: 6 }}>{a}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{catalog.slice(0, 24).map((m) => {
|
||||||
|
const entry = form.permissions.find((p) => p.module === m.key);
|
||||||
|
return (
|
||||||
|
<tr key={m.key}>
|
||||||
|
<td style={{ padding: 6 }}>{m.label}</td>
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<td key={a} style={{ textAlign: "center" }}>
|
||||||
|
<input type="checkbox" checked={entry?.actions.includes(a) ?? false} onChange={() => togglePerm(m.key, a)} />
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={save} style={{ padding: "8px 16px", background: "var(--primary)", color: "#fff", border: "none", borderRadius: 8 }}>
|
||||||
|
{editing ? "Update" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p>Loading roles…</p>
|
||||||
|
) : (
|
||||||
|
roles.map((r) => (
|
||||||
|
<div key={r._id} style={{ display: "flex", justifyContent: "space-between", padding: 12, border: "1px solid var(--border)", borderRadius: 8, marginBottom: 8 }}>
|
||||||
|
<span><strong>{r.name}</strong>{r.isSystem ? " (system)" : ""}</span>
|
||||||
|
{!r.isSystem && canModule("user_management", "write") && (
|
||||||
|
<span>
|
||||||
|
<button type="button" onClick={() => startEdit(r)}><Pencil size={16} /></button>
|
||||||
|
{canModule("user_management", "delete") && (
|
||||||
|
<button type="button" onClick={() => remove(r._id)}><Trash2 size={16} /></button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ function builderIframeSrc(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function apiBaseUrlForChild(): string {
|
function apiBaseUrlForChild(): string {
|
||||||
const base = import.meta.env.VITE_API_URL || "http://localhost:3002/api";
|
const base = import.meta.env.VITE_API_URL || "http://localhost:3001/api";
|
||||||
return normalizeBaseUrl(base);
|
return normalizeBaseUrl(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,12 +118,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (d && "user" in d && d.user) {
|
if (d && "user" in d && d.user) {
|
||||||
setStoredUser(d.user);
|
await refreshUser();
|
||||||
setState((s) => ({ ...s, user: d.user }));
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
return { error: "Login failed" };
|
return { error: "Login failed" };
|
||||||
}, []);
|
}, [refreshUser]);
|
||||||
|
|
||||||
const loginWithGoogleCredential = useCallback(async (idToken: string): Promise<LoginResult> => {
|
const loginWithGoogleCredential = useCallback(async (idToken: string): Promise<LoginResult> => {
|
||||||
let res = await authApi.googleLogin(idToken);
|
let res = await authApi.googleLogin(idToken);
|
||||||
|
|
@ -140,23 +139,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (d && "user" in d && d.user) {
|
if (d && "user" in d && d.user) {
|
||||||
setStoredUser(d.user);
|
await refreshUser();
|
||||||
setState((s) => ({ ...s, user: d.user }));
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
return { error: "Google sign-in failed" };
|
return { error: "Google sign-in failed" };
|
||||||
}, []);
|
}, [refreshUser]);
|
||||||
|
|
||||||
const completeTwoFactorLogin = useCallback(async (twoFactorToken: string, code: string) => {
|
const completeTwoFactorLogin = useCallback(async (twoFactorToken: string, code: string) => {
|
||||||
const res = await authApi.completeLogin2fa(twoFactorToken, code.replace(/\s/g, ""));
|
const res = await authApi.completeLogin2fa(twoFactorToken, code.replace(/\s/g, ""));
|
||||||
if (res.error) return { error: res.error };
|
if (res.error) return { error: res.error };
|
||||||
if (res.data?.user) {
|
if (res.data?.user) {
|
||||||
setStoredUser(res.data.user);
|
await refreshUser();
|
||||||
setState((s) => ({ ...s, user: res.data!.user }));
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return { error: "Verification failed" };
|
return { error: "Verification failed" };
|
||||||
}, []);
|
}, [refreshUser]);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (payload: {
|
async (payload: {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
import type { ResolvedPermissions } from "../../services/api/types";
|
||||||
|
|
||||||
|
type AclAction = "read" | "write" | "delete";
|
||||||
|
|
||||||
|
interface PermissionContextValue {
|
||||||
|
permissions: ResolvedPermissions | null;
|
||||||
|
canModule: (moduleKey: string, action: AclAction) => boolean;
|
||||||
|
moduleScope: (moduleKey: string) => "all" | "assigned" | null;
|
||||||
|
isOrgSuperAdmin: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_IMPLIES: Record<AclAction, AclAction[]> = {
|
||||||
|
read: ["read"],
|
||||||
|
write: ["read", "write"],
|
||||||
|
delete: ["read", "write", "delete"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PermissionContext = createContext<PermissionContextValue | null>(null);
|
||||||
|
|
||||||
|
export function PermissionProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, loading, checked } = useAuth();
|
||||||
|
const permissions = user?.permissions ?? null;
|
||||||
|
|
||||||
|
const canModule = useCallback(
|
||||||
|
(moduleKey: string, action: AclAction) => {
|
||||||
|
if (!permissions) return user?.role === "super_admin";
|
||||||
|
if (permissions.isOrgSuperAdmin) return true;
|
||||||
|
const actions = permissions.modules?.[moduleKey] || [];
|
||||||
|
return (ACTION_IMPLIES[action] || [action]).some((a) => actions.includes(a));
|
||||||
|
},
|
||||||
|
[permissions, user?.role],
|
||||||
|
);
|
||||||
|
|
||||||
|
const moduleScope = useCallback(
|
||||||
|
(moduleKey: string) => {
|
||||||
|
if (!permissions) return null;
|
||||||
|
const s = permissions.scopes?.[moduleKey];
|
||||||
|
return s === "all" || s === "assigned" ? s : null;
|
||||||
|
},
|
||||||
|
[permissions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
permissions,
|
||||||
|
canModule,
|
||||||
|
moduleScope,
|
||||||
|
isOrgSuperAdmin: Boolean(permissions?.isOrgSuperAdmin || user?.role === "super_admin"),
|
||||||
|
loading: loading || !checked,
|
||||||
|
}),
|
||||||
|
[permissions, canModule, moduleScope, user?.role, loading, checked],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PermissionContext.Provider value={value}>{children}</PermissionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermission() {
|
||||||
|
const ctx = useContext(PermissionContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("usePermission must be used within PermissionProvider");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nav module key mapping for portfolio sections */
|
||||||
|
export const NAV_MODULE_MAP: Record<string, string> = {
|
||||||
|
home: "events",
|
||||||
|
events: "events",
|
||||||
|
activities: "live_polls",
|
||||||
|
faculty: "speaker_management",
|
||||||
|
"all-participants": "participants",
|
||||||
|
forms: "forms",
|
||||||
|
"forms-feedback": "forms",
|
||||||
|
"forms-registration": "forms",
|
||||||
|
"forms-other": "forms",
|
||||||
|
content: "content_management",
|
||||||
|
"content-library": "content_management",
|
||||||
|
"content-collections": "content_management",
|
||||||
|
messaging: "email_outreach_campaigns",
|
||||||
|
"messaging-email": "email_outreach_campaigns",
|
||||||
|
"messaging-whatsapp": "custom_template_for_wa_messaging",
|
||||||
|
payment: "billing",
|
||||||
|
"user-management": "user_management",
|
||||||
|
"roles-management": "user_management",
|
||||||
|
"participant-lists": "participants",
|
||||||
|
"messaging-whatsapp-chatbots": "custom_template_for_wa_messaging",
|
||||||
|
settings: "settings",
|
||||||
|
design: "custom_badge_and_print_file_creation",
|
||||||
|
"design-badges": "custom_badge_and_print_file_creation",
|
||||||
|
"design-emailers": "email_outreach_campaigns",
|
||||||
|
"design-whatsapp-templates": "custom_template_for_wa_messaging",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function navItemAllowed(navId: string, canModule: (m: string, a: AclAction) => boolean): boolean {
|
||||||
|
const mod = NAV_MODULE_MAP[navId];
|
||||||
|
if (!mod) return true;
|
||||||
|
return canModule(mod, "read");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { usePermission, PermissionProvider, navItemAllowed, NAV_MODULE_MAP } from "../contexts/PermissionContext";
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { AgendaTrack } from "../../../services/api/eventStaff";
|
||||||
|
|
||||||
|
/** Derive trackIds when every session in a track is selected (row-level track scope). */
|
||||||
|
export function buildEventAccessPayload(
|
||||||
|
sessionIds: Set<string> | string[],
|
||||||
|
tracks: AgendaTrack[],
|
||||||
|
): { sessionIds: string[]; trackIds: string[] } {
|
||||||
|
const sidSet = sessionIds instanceof Set ? sessionIds : new Set(sessionIds);
|
||||||
|
const sids = Array.from(sidSet);
|
||||||
|
const tids = tracks
|
||||||
|
.filter(
|
||||||
|
(t) =>
|
||||||
|
t.trackId &&
|
||||||
|
t.sessions.length > 0 &&
|
||||||
|
t.sessions.every((s) => sidSet.has(s.sessionId)),
|
||||||
|
)
|
||||||
|
.map((t) => t.trackId as string);
|
||||||
|
return { sessionIds: sids, trackIds: tids };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Role } from "../../services/api/roles";
|
||||||
|
|
||||||
|
/** Default event access labels used before custom roles existed. */
|
||||||
|
export const DEFAULT_EVENT_ROLES = ["Moderator", "Presenter", "Co-organiser", "Viewer"];
|
||||||
|
|
||||||
|
/** Merge built-in event labels with organisation custom roles (excludes system roles). */
|
||||||
|
export function buildEventRoleOptions(roles: Role[], extra?: string | null): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const options: string[] = [];
|
||||||
|
const customRoles = roles.filter((role) => !role.isSystem);
|
||||||
|
const systemRoleNames = new Set(
|
||||||
|
roles.filter((role) => role.isSystem).map((role) => role.name.trim().toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const add = (name: string | undefined | null) => {
|
||||||
|
const trimmed = String(name ?? "").trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
options.push(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const label of DEFAULT_EVENT_ROLES) add(label);
|
||||||
|
for (const role of customRoles) add(role.name);
|
||||||
|
if (extra && !systemRoleNames.has(extra.trim().toLowerCase())) add(extra);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
export interface FormEventHeaderDisplay {
|
||||||
|
showLogo?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDateRange?: boolean;
|
||||||
|
showEventType?: boolean;
|
||||||
|
showLocationType?: boolean;
|
||||||
|
showVenue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedFormEventHeaderDisplay = Required<FormEventHeaderDisplay>;
|
||||||
|
|
||||||
|
export function resolveFormEventHeaderDisplay(
|
||||||
|
display?: FormEventHeaderDisplay | null,
|
||||||
|
): ResolvedFormEventHeaderDisplay {
|
||||||
|
return {
|
||||||
|
showLogo: display?.showLogo !== false,
|
||||||
|
showTitle: display?.showTitle !== false,
|
||||||
|
showDateRange: display?.showDateRange !== false,
|
||||||
|
showEventType: display?.showEventType !== false,
|
||||||
|
showLocationType: display?.showLocationType !== false,
|
||||||
|
showVenue: display?.showVenue !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventHeaderToggleKey = keyof ResolvedFormEventHeaderDisplay;
|
||||||
|
|
||||||
|
export const EVENT_HEADER_TOGGLE_LABELS: Record<EventHeaderToggleKey, string> = {
|
||||||
|
showLogo: "Event logo / banner",
|
||||||
|
showTitle: "Event title",
|
||||||
|
showDateRange: "Date range",
|
||||||
|
showEventType: "Event type",
|
||||||
|
showLocationType: "In person / virtual",
|
||||||
|
showVenue: "Venue",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function eventHeaderFromApi(raw?: FormEventHeaderDisplay | null): Record<EventHeaderToggleKey, "Yes" | "No"> {
|
||||||
|
const resolved = resolveFormEventHeaderDisplay(raw);
|
||||||
|
return {
|
||||||
|
showLogo: resolved.showLogo ? "Yes" : "No",
|
||||||
|
showTitle: resolved.showTitle ? "Yes" : "No",
|
||||||
|
showDateRange: resolved.showDateRange ? "Yes" : "No",
|
||||||
|
showEventType: resolved.showEventType ? "Yes" : "No",
|
||||||
|
showLocationType: resolved.showLocationType ? "Yes" : "No",
|
||||||
|
showVenue: resolved.showVenue ? "Yes" : "No",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventHeaderToApi(
|
||||||
|
toggles: Record<EventHeaderToggleKey, "Yes" | "No">,
|
||||||
|
): FormEventHeaderDisplay {
|
||||||
|
return {
|
||||||
|
showLogo: toggles.showLogo === "Yes",
|
||||||
|
showTitle: toggles.showTitle === "Yes",
|
||||||
|
showDateRange: toggles.showDateRange === "Yes",
|
||||||
|
showEventType: toggles.showEventType === "Yes",
|
||||||
|
showLocationType: toggles.showLocationType === "Yes",
|
||||||
|
showVenue: toggles.showVenue === "Yes",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Eye, Pencil, Plus, Trash2 } from 'lucide-react';
|
import { Copy, Eye, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||||
import { staticMicrositeApi, type MicrositeV2Template } from '../../services/api';
|
import { staticMicrositeApi, type MicrositeV2Template } from '../../services/api';
|
||||||
import { UploadMicrositeV2TemplatePanel } from '../components/microsite-v2/UploadMicrositeV2TemplatePanel';
|
import { UploadMicrositeV2TemplatePanel } from '../components/microsite-v2/UploadMicrositeV2TemplatePanel';
|
||||||
|
|
||||||
|
|
@ -70,6 +70,29 @@ export function SuperAdminMicrositeV2TemplatesPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (t: MicrositeV2Template) => {
|
||||||
|
const suggestedSlug = `${t.slug}-copy`;
|
||||||
|
const slug = window.prompt('New slug for the duplicate:', suggestedSlug)?.trim();
|
||||||
|
if (!slug) return;
|
||||||
|
const name = window.prompt('Name for the duplicate:', `${t.name} (Copy)`)?.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const res = await staticMicrositeApi.duplicateTemplate(t._id, { slug, name });
|
||||||
|
if (res.error) {
|
||||||
|
alert(res.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibilityLabel = (t: MicrositeV2Template) => {
|
||||||
|
if (t.visibility === 'private') {
|
||||||
|
const count = t.allowedOrganizations?.length || 0;
|
||||||
|
return `Private (${count} account${count === 1 ? '' : 's'})`;
|
||||||
|
}
|
||||||
|
return 'Public';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
|
@ -122,6 +145,7 @@ export function SuperAdminMicrositeV2TemplatesPage() {
|
||||||
<th style={{ padding: '10px 8px' }}>Name</th>
|
<th style={{ padding: '10px 8px' }}>Name</th>
|
||||||
<th style={{ padding: '10px 8px' }}>Slug</th>
|
<th style={{ padding: '10px 8px' }}>Slug</th>
|
||||||
<th style={{ padding: '10px 8px' }}>Version</th>
|
<th style={{ padding: '10px 8px' }}>Version</th>
|
||||||
|
<th style={{ padding: '10px 8px' }}>Visibility</th>
|
||||||
<th style={{ padding: '10px 8px' }}>Active</th>
|
<th style={{ padding: '10px 8px' }}>Active</th>
|
||||||
<th style={{ padding: '10px 8px' }}>Actions</th>
|
<th style={{ padding: '10px 8px' }}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -139,6 +163,7 @@ export function SuperAdminMicrositeV2TemplatesPage() {
|
||||||
<td style={{ padding: '10px 8px' }}>{t.name}</td>
|
<td style={{ padding: '10px 8px' }}>{t.name}</td>
|
||||||
<td style={{ padding: '10px 8px', fontFamily: 'monospace' }}>{t.slug}</td>
|
<td style={{ padding: '10px 8px', fontFamily: 'monospace' }}>{t.slug}</td>
|
||||||
<td style={{ padding: '10px 8px' }}>v{t.version}</td>
|
<td style={{ padding: '10px 8px' }}>v{t.version}</td>
|
||||||
|
<td style={{ padding: '10px 8px', fontSize: 'var(--text-xs)' }}>{visibilityLabel(t)}</td>
|
||||||
<td style={{ padding: '10px 8px' }}>
|
<td style={{ padding: '10px 8px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -160,6 +185,9 @@ export function SuperAdminMicrositeV2TemplatesPage() {
|
||||||
<button type="button" title="Edit" onClick={() => { setEditTarget(t); setPanelOpen(true); }} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
|
<button type="button" title="Edit" onClick={() => { setEditTarget(t); setPanelOpen(true); }} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" title="Duplicate" onClick={() => void handleDuplicate(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||||
|
<Copy size={16} />
|
||||||
|
</button>
|
||||||
<button type="button" title="Preview" onClick={() => void handlePreview(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
|
<button type="button" title="Preview" onClick={() => void handlePreview(t)} style={{ border: 'none', background: 'none', cursor: 'pointer' }}>
|
||||||
<Eye size={16} />
|
<Eye size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
|
|
||||||
export type { ApiRequestInit };
|
export type { ApiRequestInit };
|
||||||
|
|
||||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3002/api';
|
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||||
const BASE_URL = API_BASE_URL;
|
const BASE_URL = API_BASE_URL;
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export interface EventStaffMember {
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
role: string;
|
role: string;
|
||||||
sessionIds: string[];
|
sessionIds: string[];
|
||||||
|
trackIds?: string[];
|
||||||
eventRole: string;
|
eventRole: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,16 +38,16 @@ export const eventStaffApi = {
|
||||||
agendaTree(eventId: string) {
|
agendaTree(eventId: string) {
|
||||||
return api.get<{ tracks: AgendaTrack[] }>(`/events/${eventId}/agenda-tree`);
|
return api.get<{ tracks: AgendaTrack[] }>(`/events/${eventId}/agenda-tree`);
|
||||||
},
|
},
|
||||||
addStaff(eventId: string, body: { userIds: string[]; sessionIds?: string[]; eventRole?: string }) {
|
addStaff(eventId: string, body: { userIds: string[]; sessionIds?: string[]; trackIds?: string[]; eventRole?: string }) {
|
||||||
return api.post<null>(`/events/${eventId}/staff`, body);
|
return api.post<null>(`/events/${eventId}/staff`, body);
|
||||||
},
|
},
|
||||||
updateStaff(eventId: string, userId: string, body: { sessionIds?: string[]; eventRole?: string }) {
|
updateStaff(eventId: string, userId: string, body: { sessionIds?: string[]; trackIds?: string[]; eventRole?: string }) {
|
||||||
return api.put<null>(`/events/${eventId}/staff/${userId}`, body);
|
return api.put<null>(`/events/${eventId}/staff/${userId}`, body);
|
||||||
},
|
},
|
||||||
removeStaff(eventId: string, userId: string) {
|
removeStaff(eventId: string, userId: string) {
|
||||||
return api.delete<null>(`/events/${eventId}/staff/${userId}`);
|
return api.delete<null>(`/events/${eventId}/staff/${userId}`);
|
||||||
},
|
},
|
||||||
sendInvites(eventId: string, body: { invites: { email: string; eventRole?: string }[]; sessionIds?: string[] }) {
|
sendInvites(eventId: string, body: { invites: { email: string; eventRole?: string }[]; sessionIds?: string[]; trackIds?: string[] }) {
|
||||||
return api.post<{ sent: number; errors: { email: string; message: string }[] }>(`/events/${eventId}/staff/invites`, body);
|
return api.post<{ sent: number; errors: { email: string; message: string }[] }>(`/events/${eventId}/staff/invites`, body);
|
||||||
},
|
},
|
||||||
cancelInvite(eventId: string, inviteId: string) {
|
cancelInvite(eventId: string, inviteId: string) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ export const formsApi = {
|
||||||
fields?: unknown[];
|
fields?: unknown[];
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
event: string;
|
event: string;
|
||||||
|
defaultVisibility?: "private" | "org";
|
||||||
}): Promise<Form> {
|
}): Promise<Form> {
|
||||||
return unwrapForm(await api.post<Form>('/forms', payload));
|
return unwrapForm(await api.post<Form>('/forms', payload));
|
||||||
},
|
},
|
||||||
|
|
@ -56,6 +57,7 @@ export const formsApi = {
|
||||||
fields?: unknown[];
|
fields?: unknown[];
|
||||||
settings?: unknown;
|
settings?: unknown;
|
||||||
event: string;
|
event: string;
|
||||||
|
defaultVisibility?: "private" | "org";
|
||||||
}
|
}
|
||||||
): Promise<Form> {
|
): Promise<Form> {
|
||||||
return unwrapForm(await api.put<Form>(`/forms/${id}`, payload));
|
return unwrapForm(await api.put<Form>(`/forms/${id}`, payload));
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export {
|
||||||
} from './eventTicketingManual';
|
} from './eventTicketingManual';
|
||||||
export { speakersApi } from './speakers';
|
export { speakersApi } from './speakers';
|
||||||
export { formsApi } from './forms';
|
export { formsApi } from './forms';
|
||||||
|
export { rolesApi } from './roles';
|
||||||
export { usersApi } from './users';
|
export { usersApi } from './users';
|
||||||
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
|
export { tracksApi, type ApiTrack, type TrackPayload } from './tracks';
|
||||||
export { sessionsApi, type ApiSession, type SessionPayload } from './sessions';
|
export { sessionsApi, type ApiSession, type SessionPayload } from './sessions';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export interface RolePermission {
|
||||||
|
module: string;
|
||||||
|
actions: ("read" | "write" | "delete")[];
|
||||||
|
scope?: "all" | "assigned";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
isSystem?: boolean;
|
||||||
|
systemKey?: string;
|
||||||
|
permissions: RolePermission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AclModuleCatalogEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
rowLevel: boolean;
|
||||||
|
actions: string[];
|
||||||
|
scopes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormGrant {
|
||||||
|
_id: string;
|
||||||
|
subjectType: "user" | "role";
|
||||||
|
subjectId: string;
|
||||||
|
subjectLabel?: string;
|
||||||
|
actions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rolesApi = {
|
||||||
|
catalog: () => api.get<{ modules: AclModuleCatalogEntry[]; actions: string[] }>("/roles/catalog"),
|
||||||
|
|
||||||
|
list: () => api.get<{ roles: Role[] }>("/roles"),
|
||||||
|
|
||||||
|
create: (payload: { name: string; description?: string; permissions: RolePermission[] }) =>
|
||||||
|
api.post<Role>("/roles", payload),
|
||||||
|
|
||||||
|
update: (id: string, payload: Partial<{ name: string; description: string; permissions: RolePermission[] }>) =>
|
||||||
|
api.put<Role>(`/roles/${id}`, payload),
|
||||||
|
|
||||||
|
remove: (id: string) => api.delete<null>(`/roles/${id}`),
|
||||||
|
|
||||||
|
listFormGrants: (formId: string) =>
|
||||||
|
api.get<{ grants: FormGrant[]; defaultVisibility: string }>(`/forms/${formId}/grants`),
|
||||||
|
|
||||||
|
createFormGrant: (
|
||||||
|
formId: string,
|
||||||
|
payload: { subjectType: "user" | "role"; subjectId: string; actions: string[] },
|
||||||
|
) => api.post(`/forms/${formId}/grants`, payload),
|
||||||
|
|
||||||
|
removeFormGrant: (formId: string, grantId: string) =>
|
||||||
|
api.delete<null>(`/forms/${formId}/grants/${grantId}`),
|
||||||
|
|
||||||
|
updateFormVisibility: (formId: string, defaultVisibility: "private" | "org") =>
|
||||||
|
api.put(`/forms/${formId}/visibility`, { defaultVisibility }),
|
||||||
|
};
|
||||||
|
|
@ -7,29 +7,49 @@ import type {
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const staticMicrositeApi = {
|
export const staticMicrositeApi = {
|
||||||
listTemplates() {
|
listTemplates(eventId?: string) {
|
||||||
return api.get<MicrositeV2Template[]>('/microsite-v2-templates');
|
const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
||||||
|
return api.get<MicrositeV2Template[]>(`/microsite-v2-templates${q}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
listAdminTemplates() {
|
listAdminTemplates() {
|
||||||
return api.get<MicrositeV2Template[]>('/microsite-v2-templates/admin');
|
return api.get<MicrositeV2Template[]>('/microsite-v2-templates/admin');
|
||||||
},
|
},
|
||||||
|
|
||||||
getTemplate(id: string) {
|
getTemplate(id: string, eventId?: string) {
|
||||||
return api.get<MicrositeV2Template>(`/microsite-v2-templates/${id}`);
|
const q = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
||||||
|
return api.get<MicrositeV2Template>(`/microsite-v2-templates/${id}${q}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
createTemplate(payload: { slug: string; name: string; description?: string; version?: number }) {
|
createTemplate(payload: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
version?: number;
|
||||||
|
visibility?: 'public' | 'private';
|
||||||
|
allowedOrganizations?: string[];
|
||||||
|
}) {
|
||||||
return api.post<MicrositeV2Template>('/microsite-v2-templates', payload);
|
return api.post<MicrositeV2Template>('/microsite-v2-templates', payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTemplate(
|
updateTemplate(
|
||||||
id: string,
|
id: string,
|
||||||
payload: Partial<{ name: string; description: string; isActive: boolean; version: number }>,
|
payload: Partial<{
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isActive: boolean;
|
||||||
|
version: number;
|
||||||
|
visibility: 'public' | 'private';
|
||||||
|
allowedOrganizations: string[];
|
||||||
|
}>,
|
||||||
) {
|
) {
|
||||||
return api.put<MicrositeV2Template>(`/microsite-v2-templates/${id}`, payload);
|
return api.put<MicrositeV2Template>(`/microsite-v2-templates/${id}`, payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
duplicateTemplate(id: string, payload?: { slug?: string; name?: string }) {
|
||||||
|
return api.post<MicrositeV2Template>(`/microsite-v2-templates/${id}/duplicate`, payload || {});
|
||||||
|
},
|
||||||
|
|
||||||
deleteTemplate(id: string) {
|
deleteTemplate(id: string) {
|
||||||
return api.delete<null>(`/microsite-v2-templates/${id}`);
|
return api.delete<null>(`/microsite-v2-templates/${id}`);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,24 @@ export type ApiResponse<T> = ApiSuccess<T> | ApiError;
|
||||||
|
|
||||||
export interface EventAccess {
|
export interface EventAccess {
|
||||||
event: string;
|
event: string;
|
||||||
|
trackIds?: string[];
|
||||||
sessionIds?: string[];
|
sessionIds?: string[];
|
||||||
|
eventRole?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedPermissions {
|
||||||
|
modules: Record<string, string[]>;
|
||||||
|
scopes: Record<string, string>;
|
||||||
|
isOrgSuperAdmin?: boolean;
|
||||||
|
eventAccess?: EventAccess[];
|
||||||
|
customRoleId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizationMembership {
|
export interface OrganizationMembership {
|
||||||
organization: { _id: string; name: string };
|
organization: { _id: string; name: string };
|
||||||
role: string;
|
role: string;
|
||||||
eventAccess: EventAccess[];
|
eventAccess: EventAccess[];
|
||||||
|
customRole?: { _id: string; name: string; isSystem?: boolean; systemKey?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
|
|
@ -58,6 +69,10 @@ export interface User {
|
||||||
notificationPreferencesSavedAt?: string;
|
notificationPreferencesSavedAt?: string;
|
||||||
twoFactorEnabled?: boolean;
|
twoFactorEnabled?: boolean;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
permissions?: ResolvedPermissions;
|
||||||
|
customRole?: { _id: string; name: string; isSystem?: boolean; systemKey?: string };
|
||||||
|
membershipRole?: string;
|
||||||
|
assignedEvents?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OrganizationRecord {
|
export interface OrganizationRecord {
|
||||||
|
|
@ -528,6 +543,14 @@ export interface FormSettings {
|
||||||
sendConfirmationEmail?: boolean;
|
sendConfirmationEmail?: boolean;
|
||||||
onSubmitAddTags?: string[];
|
onSubmitAddTags?: string[];
|
||||||
onSubmitParticipantListId?: string;
|
onSubmitParticipantListId?: string;
|
||||||
|
eventHeader?: {
|
||||||
|
showLogo?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDateRange?: boolean;
|
||||||
|
showEventType?: boolean;
|
||||||
|
showLocationType?: boolean;
|
||||||
|
showVenue?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Populated from GET /forms list when `event` is populated. */
|
/** Populated from GET /forms list when `event` is populated. */
|
||||||
|
|
@ -537,6 +560,8 @@ export interface Form {
|
||||||
_id: string;
|
_id: string;
|
||||||
tenantOrganization?: string;
|
tenantOrganization?: string;
|
||||||
event?: FormEventRef;
|
event?: FormEventRef;
|
||||||
|
createdBy?: string;
|
||||||
|
defaultVisibility?: "private" | "org";
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -576,6 +601,7 @@ export interface PublicFormDefinition {
|
||||||
showThankYou?: boolean;
|
showThankYou?: boolean;
|
||||||
collectEmail?: boolean;
|
collectEmail?: boolean;
|
||||||
allowAnonymous?: boolean;
|
allowAnonymous?: boolean;
|
||||||
|
eventHeader?: FormSettings['eventHeader'];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -767,16 +793,24 @@ export type MicrositeV2FieldType =
|
||||||
| 'color'
|
| 'color'
|
||||||
| 'boolean'
|
| 'boolean'
|
||||||
| 'image'
|
| 'image'
|
||||||
|
| 'video'
|
||||||
| 'select'
|
| 'select'
|
||||||
| 'array';
|
| 'array';
|
||||||
|
|
||||||
|
export interface MicrositeV2FieldShowIf {
|
||||||
|
field: string;
|
||||||
|
equals: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface MicrositeV2FieldSchema {
|
export interface MicrositeV2FieldSchema {
|
||||||
key: string;
|
key: string;
|
||||||
type: MicrositeV2FieldType;
|
type: MicrositeV2FieldType;
|
||||||
label?: string;
|
label?: string;
|
||||||
default?: unknown;
|
default?: unknown;
|
||||||
|
placeholder?: string;
|
||||||
options?: { value: string; label: string }[];
|
options?: { value: string; label: string }[];
|
||||||
itemFields?: MicrositeV2FieldSchema[];
|
itemFields?: MicrositeV2FieldSchema[];
|
||||||
|
showIf?: MicrositeV2FieldShowIf;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MicrositeV2SectionSchema {
|
export interface MicrositeV2SectionSchema {
|
||||||
|
|
@ -804,6 +838,8 @@ export interface MicrositeV2Template {
|
||||||
schema?: MicrositeV2TemplateSchema;
|
schema?: MicrositeV2TemplateSchema;
|
||||||
assetsBaseUrl?: string;
|
assetsBaseUrl?: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
visibility?: 'public' | 'private';
|
||||||
|
allowedOrganizations?: Array<string | { _id: string; name?: string; email?: string }>;
|
||||||
version: number;
|
version: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
|
@ -824,6 +860,9 @@ export interface EventStaticMicrosite {
|
||||||
config: StaticMicrositeConfig;
|
config: StaticMicrositeConfig;
|
||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
publishedHtmlUrl?: string;
|
publishedHtmlUrl?: string;
|
||||||
|
publishedHtmlPath?: string;
|
||||||
|
publishedConfigPath?: string;
|
||||||
|
publishTarget?: 's3' | 'ec2';
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
publicUrl?: string;
|
publicUrl?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface CreateUserPayload {
|
||||||
role?: string;
|
role?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
assignedEvents?: string[];
|
assignedEvents?: string[];
|
||||||
|
customRoleId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { formsApi } from "../services/api";
|
import { formsApi } from "../services/api";
|
||||||
import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "../services/api";
|
import type { FormField, PublicFormDefinition, PublicFormEventSummary } from "../services/api";
|
||||||
|
import type { FormEventHeaderDisplay } from "../app/utils/formEventHeaderDisplay";
|
||||||
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
|
import { assignUniqueFieldNames, fieldDataKey, fieldDisplayLabel, countryStateKeys, isCountryStateField } from "../app/utils/formFieldNames";
|
||||||
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
|
import { SelectWithOtherInput } from "../app/components/forms/SelectWithOtherInput";
|
||||||
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
|
import { ensureRegistrationCoreFields } from "../app/utils/registrationFormFields";
|
||||||
|
|
@ -49,15 +50,81 @@ function shouldShowLocation(ev: PublicFormEventSummary): boolean {
|
||||||
function PublicFormEventHeader({
|
function PublicFormEventHeader({
|
||||||
event,
|
event,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
|
display,
|
||||||
}: {
|
}: {
|
||||||
event: PublicFormEventSummary;
|
event: PublicFormEventSummary;
|
||||||
variant?: "default" | "embedded";
|
variant?: "default" | "embedded";
|
||||||
|
display?: FormEventHeaderDisplay | null;
|
||||||
}) {
|
}) {
|
||||||
|
const d = {
|
||||||
|
showLogo: display?.showLogo !== false,
|
||||||
|
showTitle: display?.showTitle !== false,
|
||||||
|
showDateRange: display?.showDateRange !== false,
|
||||||
|
showEventType: display?.showEventType !== false,
|
||||||
|
showLocationType: display?.showLocationType !== false,
|
||||||
|
showVenue: display?.showVenue !== false,
|
||||||
|
};
|
||||||
const typeLabel = formatEventType(event.eventType);
|
const typeLabel = formatEventType(event.eventType);
|
||||||
const dateLine = formatEventDateRange(event);
|
const dateLine = formatEventDateRange(event);
|
||||||
const kind = formatLocationKind(event.locationType);
|
const kind = formatLocationKind(event.locationType);
|
||||||
const showLoc = shouldShowLocation(event);
|
const showLoc = d.showVenue && shouldShowLocation(event);
|
||||||
const embedded = variant === "embedded";
|
const embedded = variant === "embedded";
|
||||||
|
const showBanner = d.showLogo && !!event.imageUrl && !event.logoUrl;
|
||||||
|
const showLogo = d.showLogo && !!event.logoUrl;
|
||||||
|
const showTitle = d.showTitle && !!event.name;
|
||||||
|
const showSubtitle = d.showTitle && !!event.subtitle?.trim();
|
||||||
|
|
||||||
|
const metaItems: Array<{ key: string; node: React.ReactNode }> = [];
|
||||||
|
if (d.showDateRange && dateLine) {
|
||||||
|
metaItems.push({
|
||||||
|
key: "date",
|
||||||
|
node: <span style={{ color: "var(--foreground, #334155)", fontWeight: 500 }}>{dateLine}</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (d.showEventType && typeLabel) {
|
||||||
|
metaItems.push({ key: "type", node: <span>{typeLabel}</span> });
|
||||||
|
}
|
||||||
|
if (d.showLocationType && kind) {
|
||||||
|
metaItems.push({
|
||||||
|
key: "kind",
|
||||||
|
node: (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background:
|
||||||
|
event.locationType === "virtual"
|
||||||
|
? "rgba(59, 130, 246, 0.12)"
|
||||||
|
: event.locationType === "hybrid"
|
||||||
|
? "rgba(147, 51, 234, 0.12)"
|
||||||
|
: "rgba(22, 163, 74, 0.12)",
|
||||||
|
color:
|
||||||
|
event.locationType === "virtual"
|
||||||
|
? "#1d4ed8"
|
||||||
|
: event.locationType === "hybrid"
|
||||||
|
? "#7e22ce"
|
||||||
|
: "#15803d",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{kind}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (showLoc) {
|
||||||
|
metaItems.push({
|
||||||
|
key: "venue",
|
||||||
|
node: <span style={{ color: "var(--foreground, #475569)" }}>{event.location!.trim()}</span>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showBanner && !showLogo && !showTitle && !showSubtitle && metaItems.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -67,7 +134,7 @@ function PublicFormEventHeader({
|
||||||
borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)",
|
borderBottom: embedded ? "none" : "1px solid var(--border, #e2e8f0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{event.imageUrl && !event.logoUrl ? (
|
{showBanner ? (
|
||||||
<div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}>
|
<div style={{ marginBottom: 16, borderRadius: 10, overflow: "hidden", maxHeight: 160 }}>
|
||||||
<img
|
<img
|
||||||
src={event.imageUrl}
|
src={event.imageUrl}
|
||||||
|
|
@ -77,7 +144,7 @@ function PublicFormEventHeader({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", alignItems: "flex-start", gap: 16, flexWrap: "wrap" }}>
|
||||||
{event.logoUrl ? (
|
{showLogo ? (
|
||||||
<img
|
<img
|
||||||
src={event.logoUrl}
|
src={event.logoUrl}
|
||||||
alt={event.name}
|
alt={event.name}
|
||||||
|
|
@ -92,19 +159,21 @@ function PublicFormEventHeader({
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ minWidth: 0, flex: "1 1 200px" }}>
|
<div style={{ minWidth: 0, flex: "1 1 200px" }}>
|
||||||
<h1
|
{showTitle ? (
|
||||||
style={{
|
<h1
|
||||||
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
style={{
|
||||||
fontSize: "1.375rem",
|
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
||||||
fontWeight: 600,
|
fontSize: "1.375rem",
|
||||||
margin: "0 0 6px",
|
fontWeight: 600,
|
||||||
lineHeight: 1.25,
|
margin: "0 0 6px",
|
||||||
color: "var(--foreground, #0f172a)",
|
lineHeight: 1.25,
|
||||||
}}
|
color: "var(--foreground, #0f172a)",
|
||||||
>
|
}}
|
||||||
{event.name}
|
>
|
||||||
</h1>
|
{event.name}
|
||||||
{event.subtitle ? (
|
</h1>
|
||||||
|
) : null}
|
||||||
|
{showSubtitle ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
margin: "0 0 10px",
|
margin: "0 0 10px",
|
||||||
|
|
@ -116,62 +185,30 @@ function PublicFormEventHeader({
|
||||||
{event.subtitle}
|
{event.subtitle}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
{metaItems.length > 0 ? (
|
||||||
style={{
|
<div
|
||||||
display: "flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px 10px",
|
|
||||||
fontSize: "0.8125rem",
|
|
||||||
color: "var(--muted, #64748b)",
|
|
||||||
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--foreground, #334155)", fontWeight: 500 }}>{dateLine}</span>
|
|
||||||
{typeLabel ? (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true" style={{ opacity: 0.5 }}>
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
<span>{typeLabel}</span>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
<span aria-hidden="true" style={{ opacity: 0.5 }}>
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "2px 8px",
|
gap: "6px 10px",
|
||||||
borderRadius: 999,
|
fontSize: "0.8125rem",
|
||||||
background:
|
color: "var(--muted, #64748b)",
|
||||||
event.locationType === "virtual"
|
fontFamily: "var(--font-noto-sans, system-ui, sans-serif)",
|
||||||
? "rgba(59, 130, 246, 0.12)"
|
|
||||||
: event.locationType === "hybrid"
|
|
||||||
? "rgba(147, 51, 234, 0.12)"
|
|
||||||
: "rgba(22, 163, 74, 0.12)",
|
|
||||||
color:
|
|
||||||
event.locationType === "virtual"
|
|
||||||
? "#1d4ed8"
|
|
||||||
: event.locationType === "hybrid"
|
|
||||||
? "#7e22ce"
|
|
||||||
: "#15803d",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{kind}
|
{metaItems.map((item, index) => (
|
||||||
</span>
|
<React.Fragment key={item.key}>
|
||||||
{showLoc ? (
|
{index > 0 ? (
|
||||||
<>
|
<span aria-hidden="true" style={{ opacity: 0.5 }}>
|
||||||
<span aria-hidden="true" style={{ opacity: 0.5 }}>
|
·
|
||||||
·
|
</span>
|
||||||
</span>
|
) : null}
|
||||||
<span style={{ color: "var(--foreground, #475569)" }}>{event.location.trim()}</span>
|
{item.node}
|
||||||
</>
|
</React.Fragment>
|
||||||
) : null}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -388,7 +425,7 @@ export function PublicFormView() {
|
||||||
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ev ? <PublicFormEventHeader event={ev} variant="embedded" /> : null}
|
{ev ? <PublicFormEventHeader event={ev} variant="embedded" display={form.settings?.eventHeader} /> : null}
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
||||||
|
|
@ -399,7 +436,21 @@ export function PublicFormView() {
|
||||||
>
|
>
|
||||||
{form.name}
|
{form.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ margin: 0, fontSize: "0.95rem", lineHeight: 1.5, color: "var(--foreground)" }}>{msg}</p>
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: 6,
|
||||||
|
backgroundColor: "#d1e7dd",
|
||||||
|
color: "#198754",
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: "0.95rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
border: "1px solid #badbcc",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -438,7 +489,7 @@ export function PublicFormView() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 520, margin: "0 auto" }}>
|
<div style={{ maxWidth: 520, margin: "0 auto" }}>
|
||||||
{ev ? <PublicFormEventHeader event={ev} /> : null}
|
{ev ? <PublicFormEventHeader event={ev} display={form.settings?.eventHeader} /> : null}
|
||||||
<h1
|
<h1
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
fontFamily: "var(--font-inter, system-ui, sans-serif)",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue