diff --git a/src/App.tsx b/src/App.tsx index b84cf84..8d528d1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,8 @@ import { useEffect } from "react"; - import { BrowserRouter } from "react-router-dom"; - import "./App.css"; import { startAutoUpdater } from "./system/updater/autoUpdater"; - -import { ThemeProvider } from "./theme"; import OrgRoute from "./routes/OrgRoute"; import MemberRoutes from "./routes/MemberRoutes"; @@ -16,6 +12,10 @@ function App() { }, []); return ( + + + + diff --git a/src/features/AddMember/v1/Component/AddMemberHeader.tsx b/src/features/AddMember/v1/Component/AddMemberHeader.tsx index 2f0b3e3..e48f642 100644 --- a/src/features/AddMember/v1/Component/AddMemberHeader.tsx +++ b/src/features/AddMember/v1/Component/AddMemberHeader.tsx @@ -1,4 +1,5 @@ import { IoMdArrowRoundBack } from "react-icons/io"; +import { Member_Permissions, PermissionGate } from "@/features/Permissions/v1"; import { Link } from "react-router"; import Button from "../../../../Component/ui/Button"; import { memo } from "react"; @@ -20,12 +21,16 @@ const AddMemberHeader = () => {
-
); diff --git a/src/features/AddMember/v1/Page/AddMemberPage.tsx b/src/features/AddMember/v1/Page/AddMemberPage.tsx index cfa19d6..53e00e5 100644 --- a/src/features/AddMember/v1/Page/AddMemberPage.tsx +++ b/src/features/AddMember/v1/Page/AddMemberPage.tsx @@ -1,4 +1,10 @@ import AddMemberHeader from "../Component/AddMemberHeader"; +import { + AccessDenied, + Member_Permissions, + PermissionBoundary, + PermissionLoading, +} from "@/features/Permissions/v1"; import Administrative_MetaData from "../Component/Administrative_MetaData"; import Community_Involvement from "../Component/Community_Involment"; import PersonalInfoCard from "../Sections/PersonalInfoCard"; @@ -8,14 +14,25 @@ const AddMemberPage = () => { return (
-
-
- - - + } + unauthorizedFallback={ + + } + > +
+
+ + + +
+
- -
+
); }; diff --git a/src/features/Contact_And_Support/v1/Components/InternalSupport_Table.tsx b/src/features/Contact_And_Support/v1/Components/InternalSupport_Table.tsx index 671e462..5211c3c 100644 --- a/src/features/Contact_And_Support/v1/Components/InternalSupport_Table.tsx +++ b/src/features/Contact_And_Support/v1/Components/InternalSupport_Table.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Contact_Permissions, usePermissionMap } from "@/features/Permissions/v1"; import { FiMail, FiCopy, FiCheck } from "react-icons/fi"; type TeamMember = { @@ -132,6 +133,10 @@ const TeamMemberAvatar = ({ member }: { member: TeamMember }) => { }; const InternalSupport_Table = () => { + const { canEmail } = usePermissionMap({ + canEmail: Contact_Permissions.EMAIL_CONTACT, + }); + return (
@@ -140,7 +145,7 @@ const InternalSupport_Table = () => { - + @@ -172,21 +177,24 @@ const InternalSupport_Table = () => { diff --git a/src/features/Contact_And_Support/v1/Components/Support.tsx b/src/features/Contact_And_Support/v1/Components/Support.tsx index cb237bf..e4d9d97 100644 --- a/src/features/Contact_And_Support/v1/Components/Support.tsx +++ b/src/features/Contact_And_Support/v1/Components/Support.tsx @@ -1,5 +1,6 @@ import DropDown from "@/Component/ui/DropDown"; import Input from "@/Component/ui/Input"; +import { Contact_Permissions, usePermissionMap } from "@/features/Permissions/v1"; import { FormEvent, useState } from "react"; import { MdOutlineSupportAgent } from "react-icons/md"; import { FiAlertCircle, FiCheckCircle } from "react-icons/fi"; @@ -22,6 +23,9 @@ const priorityColor: Record = { }; const Support = () => { + const { canSubmitTicket } = usePermissionMap({ + canSubmitTicket: Contact_Permissions.SUBMIT_SUPPORT_TICKET, + }); const initialCategory = CONTACT_AND_SUPPORT_CONSTANT[0] ?? ""; const [selectedCategory, setSelectedCategory] = useState(initialCategory); const [priority, setPriority] = useState("Medium"); @@ -56,6 +60,7 @@ const Support = () => { const onSubmit = (event: FormEvent) => { event.preventDefault(); + if (!canSubmitTicket) return; if (!validateForm()) { setTicketReference(""); return; @@ -191,11 +196,18 @@ const Support = () => { )}
- - + )} +
@@ -214,6 +226,11 @@ const Support = () => { turnaround time.

+ {!canSubmitTicket && ( +

+ Ticket submission is hidden until support-request permission is granted. +

+ )} ); diff --git a/src/features/Contact_And_Support/v1/Pages/Contact.tsx b/src/features/Contact_And_Support/v1/Pages/Contact.tsx index c1b1f26..727b1fd 100644 --- a/src/features/Contact_And_Support/v1/Pages/Contact.tsx +++ b/src/features/Contact_And_Support/v1/Pages/Contact.tsx @@ -1,4 +1,10 @@ import ContactHeader from "../Section/ContactHeader"; +import { + AccessDenied, + Contact_Permissions, + PermissionBoundary, + PermissionLoading, +} from "@/features/Permissions/v1"; import InternalDirectoryTable from "../Section/InternalDirectoryTable"; import Support from "../Components/Support"; @@ -6,14 +12,25 @@ const Contact = () => { return (
-
-
- + } + unauthorizedFallback={ + + } + > +
+
+ +
+
+ +
-
- -
-
+
); }; diff --git a/src/features/Events/v1/Components/EventTable.tsx b/src/features/Events/v1/Components/EventTable.tsx index f462d44..1b824d8 100644 --- a/src/features/Events/v1/Components/EventTable.tsx +++ b/src/features/Events/v1/Components/EventTable.tsx @@ -1,5 +1,5 @@ -import { MoreVertical } from "lucide-react"; import { useState } from "react"; +import { Event_Permissions, usePermissionMap } from "@/features/Permissions/v1"; import { Event } from "../Event.type"; type EventProps = { @@ -14,11 +14,18 @@ const statusConfig: Record = { }; function EventTable({ events, itemsPerPage }: EventProps) { + const { canView, canEdit, canDelete, canPublish } = usePermissionMap({ + canView: Event_Permissions.VIEW_EVENT, + canEdit: Event_Permissions.UPDATE_EVENT, + canDelete: Event_Permissions.DELETE_EVENT, + canPublish: Event_Permissions.PUBLISH_EVENT, + }); const [currentPage, setCurrentPage] = useState(1); const totalPages = Math.ceil(events.length / itemsPerPage); const indexOfLast = currentPage * itemsPerPage; const indexOfFirst = indexOfLast - itemsPerPage; const currentItems = events.slice(indexOfFirst, indexOfLast); + const canManageActions = canView || canEdit || canDelete || canPublish; return (
Status
- + {canManageActions && } @@ -74,21 +81,26 @@ function EventTable({ events, itemsPerPage }: EventProps) { - + {canManageActions && ( + + )} ); })} diff --git a/src/features/Events/v1/Components/Judge.tsx b/src/features/Events/v1/Components/Judge.tsx index c827e26..6035e8d 100644 --- a/src/features/Events/v1/Components/Judge.tsx +++ b/src/features/Events/v1/Components/Judge.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import Input from "@/Component/ui/Input"; +import { Event_Permissions, PermissionGate } from "@/features/Permissions/v1"; import { CiSearch } from "react-icons/ci"; import { IoMdAdd } from "react-icons/io"; import JudgeCard from "./JudgeCard"; @@ -53,9 +54,11 @@ const Judge = ({ isExpanded = true, onToggleExpand }: JudgeProps) => {
- + + + {onToggleExpand && (
- + + + {onToggleExpand && (
- + + + {onToggleExpand && (
- + + + {onToggleExpand && (
); diff --git a/src/features/Events/v1/Sections/Event_View_Header.tsx b/src/features/Events/v1/Sections/Event_View_Header.tsx index 09831a7..dfb4580 100644 --- a/src/features/Events/v1/Sections/Event_View_Header.tsx +++ b/src/features/Events/v1/Sections/Event_View_Header.tsx @@ -1,4 +1,5 @@ import Button from "@/Component/ui/Button"; +import { Event_Permissions, PermissionGate } from "@/features/Permissions/v1"; import { useCallback, useState } from "react"; import { IoMdAdd } from "react-icons/io"; import { useNavigate } from "react-router-dom"; @@ -32,13 +33,15 @@ const Event_View_Header = () => { Manage all your events in one place. Create, edit, and track event details with ease.

-
-
+ +
+
+
diff --git a/src/features/Member/v1/Components/MemberHeader.tsx b/src/features/Member/v1/Components/MemberHeader.tsx index 37e9e70..cae48c8 100644 --- a/src/features/Member/v1/Components/MemberHeader.tsx +++ b/src/features/Member/v1/Components/MemberHeader.tsx @@ -1,4 +1,5 @@ import Button from "@/Component/ui/Button"; +import { Member_Permissions, PermissionGate } from "@/features/Permissions/v1"; import { IoPersonAdd } from "react-icons/io5"; import { useNavigate } from "react-router"; @@ -31,11 +32,13 @@ const MemberHeader = () => {
-
- + {canManageMembers && } @@ -55,21 +61,18 @@ const MemberTable = ({ members }: MemberTableProps) => { {member.certificates} - + {canManageMembers && ( + + )} ))} diff --git a/src/features/Member/v1/Pages/MemberPage.tsx b/src/features/Member/v1/Pages/MemberPage.tsx index 8ba6956..559a871 100644 --- a/src/features/Member/v1/Pages/MemberPage.tsx +++ b/src/features/Member/v1/Pages/MemberPage.tsx @@ -1,4 +1,10 @@ import MemberHeader from "../Components/MemberHeader"; +import { + AccessDenied, + Member_Permissions, + PermissionBoundary, + PermissionLoading, +} from "@/features/Permissions/v1"; import MemberTable from "../Components/MemberTable"; import SearchMember from "../Components/SearchMember"; @@ -41,8 +47,19 @@ const MemberPage = () => { return (
- - + } + unauthorizedFallback={ + + } + > + + +
); }; diff --git a/src/features/Permissions/v1/AccessDenied.tsx b/src/features/Permissions/v1/AccessDenied.tsx new file mode 100644 index 0000000..9daa861 --- /dev/null +++ b/src/features/Permissions/v1/AccessDenied.tsx @@ -0,0 +1,38 @@ +type AccessDeniedProps = { + title: string; + description: string; +}; + +const AccessDenied = ({ title, description }: AccessDeniedProps) => { + return ( +
+
+ + Permission required + +

+ {title} +

+

+ {description} +

+
+
+ ); +}; + +export default AccessDenied; diff --git a/src/features/Permissions/v1/PermissionBootstrap.tsx b/src/features/Permissions/v1/PermissionBootstrap.tsx new file mode 100644 index 0000000..25b795d --- /dev/null +++ b/src/features/Permissions/v1/PermissionBootstrap.tsx @@ -0,0 +1,40 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + fetchUserPermissions, + getCurrentUser, + getPermissionErrorMessage, + permissionQueryKeys, +} from "./permissions.service"; +import { usePermissionStore } from "./permissions.store"; + +export function PermissionBootstrap() { + const startLoading = usePermissionStore((state) => state.startLoading); + const setPermissions = usePermissionStore((state) => state.setPermissions); + const setError = usePermissionStore((state) => state.setError); + const user = getCurrentUser(); + + const query = useQuery({ + queryKey: permissionQueryKeys.byRole(user.role), + queryFn: () => fetchUserPermissions(user), + staleTime: 1000 * 60 * 5, + }); + + useEffect(() => { + if (query.isPending) { + startLoading(); + return; + } + + if (query.isError) { + setError(getPermissionErrorMessage(query.error)); + return; + } + + if (query.data) { + setPermissions(query.data); + } + }, [query.data, query.error, query.isError, query.isPending, setError, setPermissions, startLoading]); + + return null; +} diff --git a/src/features/Permissions/v1/PermissionBoundary.tsx b/src/features/Permissions/v1/PermissionBoundary.tsx new file mode 100644 index 0000000..e7fb839 --- /dev/null +++ b/src/features/Permissions/v1/PermissionBoundary.tsx @@ -0,0 +1,33 @@ +import { type ReactNode } from "react"; +import { useAuthorization } from "./useAuthorization"; +import { type PermissionRequirement } from "./types"; + +type PermissionBoundaryProps = { + permission?: PermissionRequirement; + requireAll?: boolean; + loadingFallback?: ReactNode; + unauthorizedFallback?: ReactNode; + children: ReactNode; +}; + +const PermissionBoundary = ({ + permission, + requireAll = false, + loadingFallback = null, + unauthorizedFallback = null, + children, +}: PermissionBoundaryProps) => { + const { hasPermission, isLoading } = useAuthorization(permission, { requireAll }); + + if (isLoading) { + return <>{loadingFallback}; + } + + if (!hasPermission) { + return <>{unauthorizedFallback}; + } + + return <>{children}; +}; + +export default PermissionBoundary; diff --git a/src/features/Permissions/v1/PermissionLoading.tsx b/src/features/Permissions/v1/PermissionLoading.tsx new file mode 100644 index 0000000..5fced94 --- /dev/null +++ b/src/features/Permissions/v1/PermissionLoading.tsx @@ -0,0 +1,33 @@ +type PermissionLoadingProps = { + title?: string; + description?: string; +}; + +const PermissionLoading = ({ + title = "Checking access", + description = "We are validating your permissions and preparing the right actions for this screen.", +}: PermissionLoadingProps) => { + return ( +
+
+
+

+ {title} +

+

+ {description} +

+
+
+ ); +}; + +export default PermissionLoading; diff --git a/src/features/Permissions/v1/constants.ts b/src/features/Permissions/v1/constants.ts new file mode 100644 index 0000000..2ec1af4 --- /dev/null +++ b/src/features/Permissions/v1/constants.ts @@ -0,0 +1,38 @@ +export const Event_Permissions = { + CREATE_EVENT: "event:create", + UPDATE_EVENT: "event:update", + DELETE_EVENT: "event:delete", + VIEW_EVENT: "event:view", + PUBLISH_EVENT: "event:publish", + JOIN_EVENT: "event:join", + LEAVE_EVENT: "event:leave", +} as const; + +export const Member_Permissions = { + CREATE_MEMBER: "member:create", + UPDATE_MEMBER: "member:update", + DELETE_MEMBER: "member:delete", + VIEW_MEMBER: "member:view", +} as const; + +export const Contact_Permissions = { + VIEW_CONTACT_DIRECTORY: "contact:view", + EMAIL_CONTACT: "contact:email", + SUBMIT_SUPPORT_TICKET: "contact:support", +} as const; + +export const Project_Permissions = { + VIEW_PROJECT: "project:view", + CREATE_PROJECT: "project:create", + UPDATE_PROJECT: "project:update", + DELETE_PROJECT: "project:delete", +} as const; + +export const App_Permissions = { + ...Event_Permissions, + ...Member_Permissions, + ...Contact_Permissions, + ...Project_Permissions, +} as const; + +export type Permission = (typeof App_Permissions)[keyof typeof App_Permissions]; diff --git a/src/features/Permissions/v1/index.ts b/src/features/Permissions/v1/index.ts new file mode 100644 index 0000000..73b41aa --- /dev/null +++ b/src/features/Permissions/v1/index.ts @@ -0,0 +1,7 @@ +export * from "./constants"; +export * from "./types"; +export * from "./useAuthorization"; +export { PermissionBootstrap } from "./PermissionBootstrap"; +export { default as AccessDenied } from "./AccessDenied"; +export { default as PermissionBoundary } from "./PermissionBoundary"; +export { default as PermissionLoading } from "./PermissionLoading"; diff --git a/src/features/Permissions/v1/permissions.service.ts b/src/features/Permissions/v1/permissions.service.ts new file mode 100644 index 0000000..a36b9cc --- /dev/null +++ b/src/features/Permissions/v1/permissions.service.ts @@ -0,0 +1,58 @@ +import useAuthStore from "@/features/Auth/v1/Store/Auth.Store"; +import { dashboardData } from "@/features/Member/v1/mock/dashboardData"; +import { type User } from "@/features/Dashboard/Member/v1/Type/dashboard"; +import { + App_Permissions, + Contact_Permissions, + Event_Permissions, + Member_Permissions, + Project_Permissions, + type Permission, +} from "./constants"; +import { type PermissionQueryUser } from "./types"; + +const ROLE_PERMISSION_MAP: Record = { + Admin: Object.values(App_Permissions), + Member: [ + Event_Permissions.VIEW_EVENT, + Event_Permissions.JOIN_EVENT, + Event_Permissions.LEAVE_EVENT, + Member_Permissions.VIEW_MEMBER, + Contact_Permissions.VIEW_CONTACT_DIRECTORY, + Contact_Permissions.SUBMIT_SUPPORT_TICKET, + Project_Permissions.VIEW_PROJECT, + ], +}; + +export const permissionQueryKeys = { + all: ["permissions"] as const, + byRole: (role: string) => [...permissionQueryKeys.all, role] as const, +}; + +export async function fetchUserPermissions( + user: PermissionQueryUser = getCurrentUser(), +): Promise { + await new Promise((resolve) => setTimeout(resolve, 250)); + return ROLE_PERMISSION_MAP[user.role as User["role"]] ?? []; +} + +export function getCurrentUser(): PermissionQueryUser { + const authenticatedUser = useAuthStore.getState().user; + + if (authenticatedUser?.role === "Admin" || authenticatedUser?.role === "Member") { + return { + name: authenticatedUser.email, + role: authenticatedUser.role, + }; + } + + return dashboardData.user; +} + +export function getPermissionErrorMessage(error: unknown) { + if (error instanceof Error) { + return error.message; + } + + return "Unable to load permissions."; +} diff --git a/src/features/Permissions/v1/permissions.store.ts b/src/features/Permissions/v1/permissions.store.ts new file mode 100644 index 0000000..8b33b25 --- /dev/null +++ b/src/features/Permissions/v1/permissions.store.ts @@ -0,0 +1,43 @@ +import { create } from "zustand"; +import { type Permission } from "./constants"; +import { type PermissionStatus } from "./types"; + +type PermissionStore = { + permissions: Permission[]; + status: PermissionStatus; + error: string | null; + lastLoadedAt: string | null; + startLoading: () => void; + setPermissions: (permissions: Permission[]) => void; + setError: (message: string) => void; + reset: () => void; +}; + +const initialState = { + permissions: [] as Permission[], + status: "idle" as PermissionStatus, + error: null as string | null, + lastLoadedAt: null as string | null, +}; + +export const usePermissionStore = create((set) => ({ + ...initialState, + startLoading: () => + set((state) => ({ + status: state.permissions.length > 0 ? "success" : "loading", + error: null, + })), + setPermissions: (permissions) => + set({ + permissions, + status: "success", + error: null, + lastLoadedAt: new Date().toISOString(), + }), + setError: (message) => + set({ + status: "error", + error: message, + }), + reset: () => set(initialState), +})); diff --git a/src/features/Permissions/v1/types.ts b/src/features/Permissions/v1/types.ts new file mode 100644 index 0000000..89873a0 --- /dev/null +++ b/src/features/Permissions/v1/types.ts @@ -0,0 +1,21 @@ +import { type Permission } from "./constants"; + +export type PermissionStatus = "idle" | "loading" | "success" | "error"; +export type PermissionRequirement = Permission | Permission[] | undefined; +export type PermissionCheckMode = "any" | "all"; + +export type PermissionQueryUser = { + name: string; + role: string; +}; + +export type PermissionAccessResult = { + hasPermission: boolean; + permissions: Permission[]; + requestedPermissions: Permission[]; + isLoading: boolean; + isReady: boolean; + isError: boolean; + status: PermissionStatus; + error: string | null; +}; diff --git a/src/features/Permissions/v1/useAuthorization.tsx b/src/features/Permissions/v1/useAuthorization.tsx new file mode 100644 index 0000000..18029a4 --- /dev/null +++ b/src/features/Permissions/v1/useAuthorization.tsx @@ -0,0 +1,118 @@ +import { type ReactNode, useMemo } from "react"; +import { type Permission } from "./constants"; +import { usePermissionStore } from "./permissions.store"; +import { + type PermissionAccessResult, + type PermissionCheckMode, + type PermissionRequirement, +} from "./types"; + +type AuthorizationOptions = { + requireAll?: boolean; +}; + +function normalizePermissions(requirement: PermissionRequirement): Permission[] { + if (!requirement) { + return []; + } + + return Array.isArray(requirement) ? requirement : [requirement]; +} + +function hasRequiredPermissions( + grantedPermissions: Permission[], + requiredPermissions: Permission[], + mode: PermissionCheckMode, +) { + if (requiredPermissions.length === 0) { + return true; + } + + if (mode === "all") { + return requiredPermissions.every((permission) => grantedPermissions.includes(permission)); + } + + return requiredPermissions.some((permission) => grantedPermissions.includes(permission)); +} + +export function useAuthorization( + requestedPermissions?: PermissionRequirement, + options: AuthorizationOptions = {}, +): PermissionAccessResult { + const permissions = usePermissionStore((state) => state.permissions); + const status = usePermissionStore((state) => state.status); + const error = usePermissionStore((state) => state.error); + + const requested = useMemo( + () => normalizePermissions(requestedPermissions), + [requestedPermissions], + ); + const mode: PermissionCheckMode = options.requireAll ? "all" : "any"; + const hasPermission = hasRequiredPermissions(permissions, requested, mode); + + return { + hasPermission, + permissions, + requestedPermissions: requested, + isLoading: status === "idle" || status === "loading", + isReady: status === "success", + isError: status === "error", + status, + error, + }; +} + +export function usePermissionMap>(config: T) { + const permissions = usePermissionStore((state) => state.permissions); + const status = usePermissionStore((state) => state.status); + const error = usePermissionStore((state) => state.error); + + const accessMap = useMemo(() => { + return Object.entries(config).reduce( + (result, [key, requirement]) => { + result[key as keyof T] = hasRequiredPermissions( + permissions, + normalizePermissions(requirement), + "any", + ); + + return result; + }, + {} as { [Key in keyof T]: boolean }, + ); + }, [config, permissions]); + + return { + ...accessMap, + isLoading: status === "idle" || status === "loading", + isReady: status === "success", + isError: status === "error", + error, + }; +} + +type PermissionGateProps = { + permission?: PermissionRequirement; + requireAll?: boolean; + fallback?: ReactNode; + loadingFallback?: ReactNode; + children: ReactNode; +}; + +export function PermissionGate({ + permission, + requireAll = false, + fallback = null, + loadingFallback = null, + children, +}: PermissionGateProps) { + const { hasPermission, isLoading } = useAuthorization(permission, { requireAll }); + + if (isLoading) { + return <>{loadingFallback}; + } + + return hasPermission ? <>{children} : <>{fallback}; +} + +export const IfAuthorized = PermissionGate; diff --git a/src/features/Projects/Pages/ProjectsPage.tsx b/src/features/Projects/Pages/ProjectsPage.tsx index 3f40849..a8e067a 100644 --- a/src/features/Projects/Pages/ProjectsPage.tsx +++ b/src/features/Projects/Pages/ProjectsPage.tsx @@ -1,15 +1,32 @@ import PagePlaceholder from "@/features/Member/v1/Components/PagePlaceholder"; +import { + AccessDenied, + PermissionBoundary, + PermissionLoading, + Project_Permissions, +} from "@/features/Permissions/v1"; const ProjectsPage = () => { return ( -
-
- } + unauthorizedFallback={ + + } + > +
+
+ +
-
+ ); }; diff --git a/src/main.tsx b/src/main.tsx index 1c1bf38..a6a0197 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,13 +1,32 @@ import App from "./App"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import App from "./App"; +import { SidebarProvider } from "./context/SidebarContext"; +import { PermissionBootstrap } from "./features/Permissions/v1"; import { ThemeProvider } from "./theme/provider"; const queryClient = new QueryClient(); const container = document.getElementById("root"); -const root = createRoot(container!); + +if (!container) { + throw new Error("Root container not found."); +} + +const root = createRoot(container); root.render( + + + + + + + + + + ,
Team Member Role / Department Email AddressActions{canEmail ? "Actions" : "Quick Copy"}
Teams SubmissionsActionsActions
{event.teams} {event.submissions} - - +
+ {canView && ( + + )} + {canEdit && ( + + )} + {canPublish && event.status !== "Completed" && ( + + )} + {canDelete && ( + + )} +
+
Status Skills CertificatesActionsActions
- - +
+ {canEdit && ( + + )} + {canDelete && ( + + )} +
+