From bad7d3877712241288fc0c5f6d25a1999e2cf6ba Mon Sep 17 00:00:00 2001 From: Ravi Gulhane <> Date: Mon, 13 Jul 2026 15:11:25 +0530 Subject: [PATCH] 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 --- dist/index.html | 2 +- src/app/App.tsx | 3 + src/app/components/MainContent.tsx | 2 + src/app/components/NavBar.tsx | 21 +- src/app/components/Sidebar.tsx | 9 +- .../components/dashboard/EventHomeModules.tsx | 246 ++++++------ src/app/components/events/EventsPage.tsx | 13 +- src/app/components/forms/ShareFormPanel.tsx | 353 ++++++++++++++++++ src/app/components/global/GlobalStubPage.tsx | 105 +++++- .../ManageStaticMicrositePanel.tsx | 111 +++++- .../microsite-v2/MicrositeConfigForm.tsx | 237 ++++++++++-- .../UploadMicrositeV2TemplatePanel.tsx | 149 +++++++- .../components/microsite-v2/scheduleSync.ts | 159 ++++++++ src/app/components/panels/CreateFormPanel.tsx | 77 +++- src/app/components/panels/InviteUserPanel.tsx | 222 +++++++---- src/app/components/panels/PanelShell.tsx | 7 +- .../permissions/PermissionsPage.tsx | 98 +++-- src/app/components/roles/RolesPage.tsx | 150 ++++++++ .../components/website/MicrositeEditor.tsx | 2 +- src/app/contexts/AuthContext.tsx | 15 +- src/app/contexts/PermissionContext.tsx | 101 +++++ src/app/hooks/usePermission.ts | 1 + src/app/utils/eventAccessPayload.ts | 19 + src/app/utils/eventRoleOptions.ts | 29 ++ src/app/utils/formEventHeaderDisplay.ts | 59 +++ .../SuperAdminMicrositeV2TemplatesPage.tsx | 30 +- src/services/api/client.ts | 2 +- src/services/api/eventStaff.ts | 7 +- src/services/api/forms.ts | 2 + src/services/api/index.ts | 1 + src/services/api/roles.ts | 61 +++ src/services/api/staticMicrosite.ts | 32 +- src/services/api/types.ts | 39 ++ src/services/api/users.ts | 1 + src/views/PublicFormView.tsx | 193 ++++++---- 35 files changed, 2196 insertions(+), 362 deletions(-) create mode 100644 src/app/components/forms/ShareFormPanel.tsx create mode 100644 src/app/components/microsite-v2/scheduleSync.ts create mode 100644 src/app/components/roles/RolesPage.tsx create mode 100644 src/app/contexts/PermissionContext.tsx create mode 100644 src/app/hooks/usePermission.ts create mode 100644 src/app/utils/eventAccessPayload.ts create mode 100644 src/app/utils/eventRoleOptions.ts create mode 100644 src/app/utils/formEventHeaderDisplay.ts create mode 100644 src/services/api/roles.ts diff --git a/dist/index.html b/dist/index.html index 1494ae00..d5b558b3 100644 --- a/dist/index.html +++ b/dist/index.html @@ -12,7 +12,7 @@ rel="stylesheet" /> - + diff --git a/src/app/App.tsx b/src/app/App.tsx index 4fde4021..0b4aea3d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { ThemeProvider } from "./contexts/ThemeContext"; import { AuthProvider } from "./contexts/AuthContext"; +import { PermissionProvider } from "./contexts/PermissionContext"; import { ErrorBoundary, GlobalMutationLoadingOverlay } from "./components/shared"; import { LoginView, RegisterView } from "./views/AuthViews"; import { AcceptEventInviteView } from "./views/AcceptEventInviteView"; @@ -19,6 +20,7 @@ export default function App() { + @@ -41,6 +43,7 @@ export default function App() { } /> + diff --git a/src/app/components/MainContent.tsx b/src/app/components/MainContent.tsx index 66e7f59a..d3cef884 100644 --- a/src/app/components/MainContent.tsx +++ b/src/app/components/MainContent.tsx @@ -30,6 +30,7 @@ import { } from "./global/GlobalStubPage"; import { PaymentSettingsPage } from "./global/PaymentSettingsPage"; import { SettingsPage } from "./global/SettingsPage"; +import { RolesPage } from "./roles/RolesPage"; import { WhatsAppTemplatesPage } from "./whatsapp/WhatsAppTemplatesPage"; import { WhatsAppChatbotsPage } from "./whatsapp-chatbot/WhatsAppChatbotsPage"; import { SearchResultsPage } from "./search/SearchResultsPage"; @@ -127,6 +128,7 @@ export function MainContent() { return ; if (activeNav === "payment") return ; if (activeNav === "user-management") return ; + if (activeNav === "roles-management") return ; if (activeNav === "settings") return ( = { + dashboard: "events", + agenda: "sessions", + participants: "participants", + attendance: "participants", + "event-activities": "live_polls", + rewards: "gamification", + analytics: "events", + permissions: "user_management", +}; /* ───────────────────────────────────── Event-specific nav items @@ -37,6 +50,12 @@ interface NavBarProps { } export function NavBar({ activeItem, onNavigate }: NavBarProps) { + const { canModule } = usePermission(); + const visibleTabs = EVENT_TABS.filter((tab) => { + const mod = EVENT_TAB_MODULES[tab.id]; + if (!mod) return true; + return canModule(mod, "read"); + }); const scrollRef = useRef(null); const bp = useBreakpoint(); const isMobile = bp === "mobile"; @@ -93,7 +112,7 @@ export function NavBar({ activeItem, onNavigate }: NavBarProps) { msOverflowStyle: "none" as any, }} > - {EVENT_TABS.map(tab => { + {visibleTabs.map(tab => { const isActive = activeItem === tab.id || (tab.id === "event-activities" && (activeItem === "questions" || activeItem === "quiz")); return ( +
+ + {isSuperAdmin ? ( + + ) : null} +
} actions={ <> + {canWriteForms ? ( + ) : null} + {canReadForms ? ( + ) : null} } /> ); - })} + }) : null} + {canWriteForms ? ( + } + title="Create New Form" + description="Add a form assigned to this event" + actions={ + <> + + + + } + /> + ) : null} } title="Users & Permissions" @@ -705,6 +804,7 @@ export function EventHomeModules() { > View + {canWriteUserMgmt ? ( + ) : null} } /> @@ -907,83 +1008,6 @@ export function EventHomeModules() { ) } /> - } - title="Create New Form" - description="Add a form assigned to this event" - actions={ - <> - - - - - - } - /> } title="Upload Document" @@ -1005,6 +1029,10 @@ export function EventHomeModules() { /> + {formShareTarget ? ( + setFormShareTarget(null)} /> + ) : null} + {formEditTarget && selectedEventId ? ( void }) { export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId: string) => void }) { const { user } = useAuth(); + const { canModule, isOrgSuperAdmin } = usePermission(); + const canWriteEvents = canModule("events", "write"); + const canListUsers = canModule("user_management", "read"); const eventsOrgId = portfolioListOrganizationId(user); const { setActiveNav, @@ -1230,7 +1234,6 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId const [onboardingSessionCount, setOnboardingSessionCount] = useState(0); const categories = useMemo(() => buildCategoriesFromApi(apiEvents), [apiEvents]); - const canListUsers = user?.role === "super_admin" || user?.role === "admin"; const stepDone = useMemo( () => ({ @@ -1299,7 +1302,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId if (!eventsOrgId) { setApiEvents([]); setEventsError( - user?.role === "super_admin" + isOrgSuperAdmin ? "Assign an organisation to your account to load events for that tenant." : "Your profile has no organisation.", ); @@ -1314,7 +1317,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId }) .catch(() => setEventsError("Failed to load events")) .finally(() => setEventsLoading(false)); - }, [eventsOrgId, user?.role]); + }, [eventsOrgId, isOrgSuperAdmin]); useEffect(() => { fetchEvents(); @@ -1606,11 +1609,13 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId {/* Create Event CTA */} + {canWriteEvents ? ( + ) : null} @@ -1660,6 +1665,7 @@ export function EventsPage({ onNavigateToEvent }: { onNavigateToEvent?: (eventId

Create an event to see it listed here with stats, filters, and calendar view.

+ {canWriteEvents ? ( + ) : null} )} diff --git a/src/app/components/forms/ShareFormPanel.tsx b/src/app/components/forms/ShareFormPanel.tsx new file mode 100644 index 00000000..6302e087 --- /dev/null +++ b/src/app/components/forms/ShareFormPanel.tsx @@ -0,0 +1,353 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Trash2, X } from "lucide-react"; +import { rolesApi, type FormGrant, type Role } from "../../../services/api/roles"; +import { usersApi } from "../../../services/api/users"; +import type { User } from "../../../services/api/types"; +import { usePermission } from "../../hooks/usePermission"; + +const GRANT_ACTIONS = ["read", "write", "delete"] as const; + +export function ShareFormPanel({ formId, onClose }: { formId: string; onClose: () => void }) { + const { canModule } = usePermission(); + const [visibility, setVisibility] = useState<"private" | "org">("org"); + const [grants, setGrants] = useState([]); + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [subjectType, setSubjectType] = useState<"user" | "role">("user"); + const [subjectId, setSubjectId] = useState(""); + const [newActions, setNewActions] = useState(["read"]); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + const [gRes, uRes, rRes] = await Promise.all([ + rolesApi.listFormGrants(formId), + usersApi.list(), + rolesApi.list(), + ]); + if (gRes.error) { + setError(gRes.error.message || "Failed to load sharing settings"); + setLoading(false); + return; + } + if (gRes.data?.defaultVisibility) { + setVisibility(gRes.data.defaultVisibility as "private" | "org"); + } + setGrants(gRes.data?.grants ?? []); + if (uRes.data?.users) setUsers(uRes.data.users); + if (rRes.data?.roles) setRoles(rRes.data.roles); + setLoading(false); + }, [formId]); + + useEffect(() => { + void load(); + }, [load]); + + const handleVisibilityChange = async (v: "private" | "org") => { + setVisibility(v); + setBusy(true); + setError(""); + const res = await rolesApi.updateFormVisibility(formId, v); + if (res.error) { + setError(res.error.message || "Failed to update visibility"); + void load(); + } + setBusy(false); + }; + + const toggleNewAction = (action: string) => { + setNewActions((prev) => + prev.includes(action) ? prev.filter((a) => a !== action) : [...prev, action], + ); + }; + + const handleAddGrant = async () => { + if (!subjectId) { + setError("Select a user or role"); + return; + } + if (!newActions.length) { + setError("Select at least one permission"); + return; + } + setBusy(true); + setError(""); + const res = await rolesApi.createFormGrant(formId, { + subjectType, + subjectId, + actions: newActions, + }); + if (res.error) { + setError(res.error.message || "Failed to add grant"); + setBusy(false); + return; + } + setSubjectId(""); + setNewActions(["read"]); + await load(); + setBusy(false); + }; + + const handleRemoveGrant = async (grantId: string) => { + setBusy(true); + setError(""); + const res = await rolesApi.removeFormGrant(formId, grantId); + if (res.error) { + setError(res.error.message || "Failed to remove grant"); + } else { + await load(); + } + setBusy(false); + }; + + if (!canModule("forms", "write")) return null; + + const overlayStyle: React.CSSProperties = { + position: "fixed", + inset: 0, + background: "rgba(0,0,0,.4)", + zIndex: 200, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: 16, + }; + + const panelStyle: React.CSSProperties = { + background: "var(--card)", + padding: 20, + borderRadius: 12, + minWidth: 360, + maxWidth: 480, + width: "100%", + maxHeight: "90vh", + overflow: "auto", + border: "1px solid var(--border)", + }; + + const labelStyle: React.CSSProperties = { + display: "block", + fontSize: "var(--text-xs)", + color: "var(--muted)", + marginBottom: 4, + }; + + const inputStyle: React.CSSProperties = { + width: "100%", + padding: 8, + borderRadius: 8, + border: "1px solid var(--border)", + background: "var(--background)", + color: "var(--foreground)", + fontSize: "var(--text-sm)", + }; + + return ( +
+
e.stopPropagation()}> +
+

Share form

+ +
+ + {error && ( +

{error}

+ )} + + {loading ? ( +

Loading…

+ ) : ( + <> +
+ + +
+ +
+ + {grants.length === 0 ? ( +

+ No explicit grants. {visibility === "org" ? "Form is visible to your organisation." : "Only the creator can access this form."} +

+ ) : ( +
    + {grants.map((g) => ( +
  • + + {g.subjectLabel || g.subjectId} + + ({g.subjectType}) — {(g.actions || []).join(", ")} + + + +
  • + ))} +
+ )} +
+ +
+

Add access

+
+ + +
+ +
+ {GRANT_ACTIONS.map((a) => ( + + ))} +
+ +
+ + )} + + +
+
+ ); +} diff --git a/src/app/components/global/GlobalStubPage.tsx b/src/app/components/global/GlobalStubPage.tsx index cb3bd4e8..e5ec9505 100644 --- a/src/app/components/global/GlobalStubPage.tsx +++ b/src/app/components/global/GlobalStubPage.tsx @@ -4,7 +4,7 @@ import { Shield, Image, Layout, Tag, Mail, Plus, Search, X, MoreHorizontal, Upload, Download, Trash2, Pencil, Copy, ChevronDown, ChevronLeft, ChevronRight, RefreshCw, - Link2, List, Check, + Link2, List, Check, Share2, } from "lucide-react"; import { useBreakpoint } from "../../hooks/useBreakpoint"; import { useAuth } from "../../contexts/AuthContext"; @@ -53,6 +53,8 @@ import { ImportParticipantsPanel } from "../panels/ImportParticipantsPanel"; import { EditParticipantPanel } from "../panels/EditParticipantPanel"; import { CreateFormPanel } from "../panels/CreateFormPanel"; import { FormSubmissionsPanel } from "../panels/FormSubmissionsPanel"; +import { ShareFormPanel } from "../forms/ShareFormPanel"; +import { usePermission } from "../../hooks/usePermission"; import { UploadFilePanel } from "../panels/UploadFilePanel"; import { CreateCollectionPanel } from "../panels/CreateCollectionPanel"; import { MessagingEmailPage } from "../messaging/MessagingEmailPage"; @@ -1866,14 +1868,18 @@ function cloneSettingsForDuplicate(settings: unknown): unknown { export function FormsPage({ subPage }: { subPage?: string }) { const { user } = useAuth(); + const { canModule } = usePermission(); const orgId = portfolioListOrganizationId(user); - const canManageForms = Boolean(orgId) || user?.role === "super_admin"; + const canReadForms = canModule("forms", "read"); + const canWriteForms = canModule("forms", "write"); + const canDeleteForms = canModule("forms", "delete"); const filterType = subPage === "forms-feedback" ? "Feedback" : subPage === "forms-registration" ? "Registration" : subPage === "forms-other" ? "Other" : null; const [forms, setForms] = useState([]); const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(null); const [hovered, setHovered] = useState(null); const [showFeedback, setShowFeedback] = useState(false); const [showReg, setShowReg] = useState(false); @@ -1883,6 +1889,7 @@ export function FormsPage({ subPage }: { subPage?: string }) { const [formActionBusy, setFormActionBusy] = useState(null); const [formPageMsg, setFormPageMsg] = useState(null); const [submissionsPanelForm, setSubmissionsPanelForm] = useState(null); + const [shareFormTarget, setShareFormTarget] = useState(null); const [copiedFormId, setCopiedFormId] = useState(null); const publicFormPageUrl = (f: FormWithSubmissionCount) => { @@ -1899,7 +1906,7 @@ export function FormsPage({ subPage }: { subPage?: string }) { const copyFormPublicLink = (f: FormWithSubmissionCount) => { const url = publicFormPageUrl(f); void navigator.clipboard.writeText(url).then(() => { - setCopiedFormId(id); + setCopiedFormId(f._id); setFormPageMsg(FORM_LINK_COPIED_MSG); window.setTimeout(() => { setCopiedFormId(null); @@ -1911,7 +1918,8 @@ export function FormsPage({ subPage }: { subPage?: string }) { }; const fetchForms = useCallback(async () => { - if (!canManageForms) { + setLoadError(null); + if (!canReadForms) { setForms([]); return; } @@ -1923,12 +1931,14 @@ export function FormsPage({ subPage }: { subPage?: string }) { try { const list = await formsApi.list({ organization: orgId }); setForms(list); - } catch { + } catch (err) { setForms([]); + const msg = err instanceof Error && err.message ? err.message : "Could not load forms."; + setLoadError(msg); } finally { setLoading(false); } - }, [canManageForms, orgId]); + }, [canReadForms, orgId]); useEffect(() => { fetchForms(); @@ -2026,7 +2036,7 @@ export function FormsPage({ subPage }: { subPage?: string }) { {pageTitle} - {canManageForms ? ( + {canWriteForms ? ( @@ -2039,7 +2049,11 @@ export function FormsPage({ subPage }: { subPage?: string }) {

) : null} - {!canManageForms ? ( + {!canReadForms ? ( +
+ } title="Access denied" description="You do not have permission to view forms." /> +
+ ) : !orgId ? (
} title="Organization required" description="Forms are managed at the organization level. Your account needs to be linked to an organization." />
@@ -2047,6 +2061,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
} title="Form Builder" description="Drag and drop fields to build your custom form. Choose from registration, feedback, other forms, and more." cta="Start Building" onCta={() => {}} />
+ ) : loadError ? ( +
+ } title="Could not load forms" description={loadError} cta="Try again" onCta={() => void fetchForms()} /> +
) : (
@@ -2056,6 +2074,10 @@ export function FormsPage({ subPage }: { subPage?: string }) {
{loading ? (
Loading forms…
+ ) : filteredForms.length === 0 ? ( +
+ } title="No forms found" description={`There are no ${pageTitle.toLowerCase()} to show for this organization.`} /> +
) : ( filteredForms.map(f => { const status = (f.settings as { isActive?: boolean })?.isActive !== false ? "Active" : "Draft"; @@ -2084,26 +2106,41 @@ export function FormsPage({ subPage }: { subPage?: string }) { > {copiedFormId === fid ? : } + {canReadForms ? ( + ) : null} + {canWriteForms ? ( + ) : null} + {canWriteForms ? ( + + ) : null} + {canWriteForms ? ( + ) : null} + {canDeleteForms ? ( + ) : null}
); @@ -2123,15 +2160,18 @@ export function FormsPage({ subPage }: { subPage?: string }) { ) : null}