diff --git a/apps/web/app/[lng]/(dashboard)/datasets/page.tsx b/apps/web/app/[lng]/(dashboard)/datasets/page.tsx index 6ce077f4..a5215a95 100644 --- a/apps/web/app/[lng]/(dashboard)/datasets/page.tsx +++ b/apps/web/app/[lng]/(dashboard)/datasets/page.tsx @@ -28,6 +28,7 @@ import type { GetDatasetSchema } from "@/lib/validations/layer"; import { AddLayerSourceType } from "@/types/common"; +import { useAuthZ } from "@/hooks/auth/AuthZ"; import { useJobStatus } from "@/hooks/jobs/JobStatus"; import ContentSearchBar from "@/components/dashboard/common/ContentSearchbar"; @@ -55,6 +56,7 @@ const Datasets = () => { isError: _isDatasetError, } = useLayers(queryParams, datasetSchema); + const { isOrgEditor } = useAuthZ(); useJobStatus(mutate); const [addDatasetModal, setAddDatasetModal] = useState(null); @@ -107,12 +109,14 @@ const Datasets = () => { mb: 8, }}> {t("datasets")} - + {isOrgEditor && ( + + )} { { const newQueryParams = { ...queryParams, page: 1 }; delete newQueryParams.team_id; @@ -181,6 +187,7 @@ const Datasets = () => { items={datasets?.items ?? []} isLoading={isDatasetLoading} type="layer" + enableActions={isOrgEditor} onClick={(item) => { if (item && item.id) { router.push(`/datasets/${item.id}`); diff --git a/apps/web/app/[lng]/(dashboard)/projects/page.tsx b/apps/web/app/[lng]/(dashboard)/projects/page.tsx index 7be72a89..aef284de 100644 --- a/apps/web/app/[lng]/(dashboard)/projects/page.tsx +++ b/apps/web/app/[lng]/(dashboard)/projects/page.tsx @@ -11,6 +11,8 @@ import { useTranslation } from "@/i18n/client"; import { useProjects } from "@/lib/api/projects"; import type { GetProjectsQueryParams } from "@/lib/validations/project"; +import { useAuthZ } from "@/hooks/auth/AuthZ"; + import ContentSearchBar from "@/components/dashboard/common/ContentSearchbar"; import FoldersTreeView from "@/components/dashboard/common/FoldersTreeView"; import TileGrid from "@/components/dashboard/common/TileGrid"; @@ -30,6 +32,7 @@ const Projects = () => { const { projects, isLoading: isProjectLoading, isError: _isProjectError } = useProjects(queryParams); const [openProjectModal, setOpenProjectModal] = useState(false); + const { isOrgEditor } = useAuthZ(); return ( @@ -42,14 +45,16 @@ const Projects = () => { mb: 8, }}> {t("projects")} - + {isOrgEditor && ( + + )} @@ -65,6 +70,8 @@ const Projects = () => { { const newQueryParams = { ...params, page: 1 }; delete newQueryParams?.["team_id"]; @@ -83,6 +90,7 @@ const Projects = () => { { diff --git a/apps/web/app/[lng]/(dashboard)/settings/layout.tsx b/apps/web/app/[lng]/(dashboard)/settings/layout.tsx index 3404f480..16bc7377 100644 --- a/apps/web/app/[lng]/(dashboard)/settings/layout.tsx +++ b/apps/web/app/[lng]/(dashboard)/settings/layout.tsx @@ -23,8 +23,7 @@ import { ICON_NAME, Icon } from "@p4b/ui/components/Icon"; import { useTranslation } from "@/i18n/client"; -import { useUserProfile } from "@/lib/api/users"; -import { isOrgAdmin } from "@/lib/utils/auth"; +import { useAuthZ } from "@/hooks/auth/AuthZ"; interface SettingsLayoutProps { children: React.ReactNode; @@ -34,9 +33,8 @@ const SettingsLayout = (props: SettingsLayoutProps) => { const { children } = props; const pathname = usePathname(); const theme = useTheme(); - const { userProfile, isLoading: isUserProfileLoading } = useUserProfile(); const { t, i18n } = useTranslation("common"); - + const { isOrgAdmin, isLoading: isUserProfileLoading } = useAuthZ(); const navigation = useMemo( () => [ { @@ -56,24 +54,24 @@ const SettingsLayout = (props: SettingsLayoutProps) => { icon: ICON_NAME.ORGANIZATION, label: t("organization"), current: pathname?.includes("/organization"), - auth: isOrgAdmin(userProfile?.roles), + auth: isOrgAdmin, }, { link: "/settings/usage", icon: ICON_NAME.CHART_PIE, label: t("usage_and_quotas"), current: pathname?.includes("/usage"), - auth: isOrgAdmin(userProfile?.roles), + auth: isOrgAdmin, }, { link: "/settings/billing", icon: ICON_NAME.CREDIT_CARD, label: t("billing"), current: pathname?.includes("/billing"), - auth: isOrgAdmin(userProfile?.roles), + auth: isOrgAdmin, }, ], - [pathname, t, userProfile?.roles] + [pathname, t, isOrgAdmin] ); return ( diff --git a/apps/web/app/[lng]/(dashboard)/settings/teams/[teamId]/page.tsx b/apps/web/app/[lng]/(dashboard)/settings/teams/[teamId]/page.tsx index c5b6af35..364b83af 100644 --- a/apps/web/app/[lng]/(dashboard)/settings/teams/[teamId]/page.tsx +++ b/apps/web/app/[lng]/(dashboard)/settings/teams/[teamId]/page.tsx @@ -14,12 +14,12 @@ import { ICON_NAME, Icon } from "@p4b/ui/components/Icon"; import { useOrganizationMembers } from "@/lib/api/organizations"; import { deleteTeam, updateTeam, useTeam, useTeamMembers } from "@/lib/api/teams"; import { useOrganization } from "@/lib/api/users"; -import { isTeamOwner } from "@/lib/utils/auth"; import type { Team, TeamMember, TeamUpdate } from "@/lib/validations/team"; import { teamRoleEnum, teamUpdateSchema } from "@/lib/validations/team"; import type { TeamMemberActions } from "@/types/common"; +import { useAuthZ } from "@/hooks/auth/AuthZ"; import { useMemberSettingsMoreMenu } from "@/hooks/dashboard/SettingsHooks"; import { CustomTabPanel, a11yProps } from "@/components/common/CustomTabPanel"; @@ -36,6 +36,9 @@ function TeamProfile({ team }: { team: Team }) { const [isTeamUpdateBusy, setIsTeamUpdateBusy] = useState(false); const [confirmDeleteOrLeaveTeamDialogOpen, setConfirmDeleteOrLeaveTeamDialogOpen] = useState(false); const router = useRouter(); + const { isTeamOwner } = useAuthZ({ + team, + }); const { register: registerTeamUpdate, @@ -104,7 +107,7 @@ function TeamProfile({ team }: { team: Team }) { {t("team_information")} - {isTeamOwner(team.role) + {isTeamOwner ? t("update_team_information_description") : t("overview_team_information_description")} @@ -116,12 +119,12 @@ function TeamProfile({ team }: { team: Team }) { control={control} title={t("team_avatar")} avatar={team?.avatar ?? ""} - readOnly={!isTeamOwner(team.role)} + readOnly={!isTeamOwner} /> - {isTeamOwner(team.role) && ( + {isTeamOwner && ( , ul:
    , li:
  • }} /> @@ -179,14 +180,14 @@ function TeamProfile({ team }: { team: Team }) { await _deleteOrLeaveTeam(); }} closeText={t("close")} - confirmText={isTeamOwner(team.role) ? t("delete_team") : t("leave_team")} + confirmText={isTeamOwner ? t("delete_team") : t("leave_team")} /> {t("danger_zone")} - {isTeamOwner(team.role) ? t("danger_zone_delete_team_description") : t("leave_team")} + {isTeamOwner ? t("danger_zone_delete_team_description") : t("leave_team")} @@ -194,9 +195,7 @@ function TeamProfile({ team }: { team: Team }) { , a: }} /> @@ -206,10 +205,7 @@ function TeamProfile({ team }: { team: Team }) { + } variant="outlined" color="error" @@ -221,7 +217,7 @@ function TeamProfile({ team }: { team: Team }) { setConfirmDeleteOrLeaveTeamDialogOpen(true); }}> - {isTeamOwner(team.role) ? t("delete_team") : t("leave_team")} + {isTeamOwner ? t("delete_team") : t("leave_team")} @@ -244,7 +240,9 @@ function TeamMembers({ team }: { team: Team }) { const teamMemberIds = new Set((teamMembers || []).map((member) => member.id)); return (organizationMembers || []).filter((member) => !teamMemberIds.has(member.id)); }, [teamMembers, organizationMembers]); - + const { isTeamOwner } = useAuthZ({ + team, + }); const { activeMemberMoreMenuOptions, pendingInvitationMoreMenuOptions, @@ -269,7 +267,7 @@ function TeamMembers({ team }: { team: Team }) { }} /> )} - {organizationMembers && teamMembers && isTeamOwner(team.role) && ( + {organizationMembers && teamMembers && isTeamOwner && ( { @@ -285,15 +283,15 @@ function TeamMembers({ team }: { team: Team }) { - {isTeamOwner(team.role) ? t("team_manage_members") : t("members")} + {isTeamOwner ? t("team_manage_members") : t("members")} - {isTeamOwner(team.role) ? t("team_manage_members_description") : t("team_members_description")} + {isTeamOwner ? t("team_manage_members_description") : t("team_members_description")} - {isTeamOwner(team.role) && ( + {isTeamOwner && ( diff --git a/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx b/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx index 0ce4c3a8..a362cf10 100644 --- a/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx +++ b/apps/web/app/[lng]/(dashboard)/settings/teams/page.tsx @@ -24,17 +24,17 @@ import { ICON_NAME, Icon } from "@p4b/ui/components/Icon"; import { useTranslation } from "@/i18n/client"; import { useTeams } from "@/lib/api/teams"; -import { useUserProfile } from "@/lib/api/users"; -import { isOrgEditor } from "@/lib/utils/auth"; + +import { useAuthZ } from "@/hooks/auth/AuthZ"; import TeamCreateModal from "@/components/modals/settings/TeamCreateModal"; export default function Teams() { const { t } = useTranslation("common"); const theme = useTheme(); - const { userProfile, isLoading: isUserProfileLoading } = useUserProfile(); const [openTeamCreateModal, setOpenTeamCreateModal] = useState(false); const { teams, mutate: mutateTeams, isLoading } = useTeams(); + const { isOrgEditor, isLoading: isUserProfileLoading } = useAuthZ(); const router = useRouter(); return ( <> @@ -55,7 +55,7 @@ export default function Teams() { mutateTeams(); }} /> - {isOrgEditor(userProfile?.roles) && ( + {isOrgEditor && ( state.map.selectedScenarioLayer); const selectedScenarioEditLayer = useMemo(() => { return projectLayers?.find((layer) => layer.id === _selectedScenarioEditLayer?.value); @@ -81,8 +81,8 @@ export default function MapPage({ params: { projectId } }) { const { scenarioFeatures } = useProjectScenarioFeatures(projectId, project?.active_scenario_id); const isLoading = useMemo( - () => isProjectLoading || isInitialViewLoading || areProjectLayersLoading, - [isProjectLoading, isInitialViewLoading, areProjectLayersLoading] + () => isProjectLoading || isInitialViewLoading || areProjectLayersLoading || isAuthZLoading, + [isProjectLoading, isInitialViewLoading, areProjectLayersLoading, isAuthZLoading] ); const hasError = useMemo( @@ -225,8 +225,7 @@ export default function MapPage({ params: { projectId } }) { sx={{ display: "flex", height: "100vh", - width: `calc(100% - ${2 * sidebarWidth}px)`, - marginLeft: `${sidebarWidth}px`, + width: "100%", [theme.breakpoints.down("sm")]: { marginLeft: "0", width: `100%`, @@ -235,13 +234,14 @@ export default function MapPage({ params: { projectId } }) { + - + {isProjectEditor && ( + + )} {popupInfo && } - {popupEditor && ( + {popupEditor && isProjectEditor && ( = ({ areFieldsLoading, displayDa {fields.some((field) => field.type === "object") && } {fields .filter((field) => field.type !== "object") - .map((field, index) => ( - + .map((field) => ( + {field.name} @@ -132,7 +133,7 @@ const DatasetTable: React.FC = ({ areFieldsLoading, displayDa )} {displayData.features?.length && - displayData.features.map((row) => )} + displayData.features.map((row) => )} )} diff --git a/apps/web/components/dashboard/common/FoldersTreeView.tsx b/apps/web/components/dashboard/common/FoldersTreeView.tsx index 1b775ec2..fa556a19 100644 --- a/apps/web/components/dashboard/common/FoldersTreeView.tsx +++ b/apps/web/components/dashboard/common/FoldersTreeView.tsx @@ -58,11 +58,12 @@ interface FoldersTreeViewProps { ) => void; queryParams: GetDatasetSchema | GetProjectsQueryParams; enableActions?: boolean; + hideMyContent?: boolean; } export default function FoldersTreeView(props: FoldersTreeViewProps) { - const { setQueryParams, queryParams, enableActions = true } = props; - const [open, setOpen] = useState([true, false, false]); + const { setQueryParams, queryParams, hideMyContent, enableActions = true } = props; + const [open, setOpen] = useState([true, true, true]); const { organization } = useOrganization(); const { teams: teamsData } = useTeams(); const { t } = useTranslation("common"); @@ -82,6 +83,18 @@ export default function FoldersTreeView(props: FoldersTreeViewProps) { } }, [folders]); + const organizationFolder = useMemo(() => { + if (organization) { + return { + type: "organization", + id: organization.id, + name: organization.name, + } as SelectedFolder; + } else { + return undefined; + } + }, [organization]); + const organizations = useMemo(() => { if (organization) { return [ @@ -148,160 +161,179 @@ export default function FoldersTreeView(props: FoldersTreeViewProps) { ); useEffect(() => { - if (!selectedFolder && folders && homeFolder) { + if (!selectedFolder && folders && homeFolder && !hideMyContent) { handleListItemClick({} as React.MouseEvent, homeFolder); } }, [folders, handleListItemClick, homeFolder, selectedFolder]); + useEffect(() => { + if (!selectedFolder && organizationFolder && hideMyContent) { + handleListItemClick({} as React.MouseEvent, organizationFolder); + } + }, [organizationFolder, selectedFolder, handleListItemClick]); + return ( <> - { - setEditModal(undefined); - }} - onEdit={() => { - if (editModal?.type === "delete") { - if (homeFolder) { - setSelectedFolder(homeFolder); + {enableActions && ( + { + setEditModal(undefined); + }} + onEdit={() => { + if (editModal?.type === "delete") { + if (homeFolder) { + setSelectedFolder(homeFolder); + } } - } - setEditModal(undefined); - }} - existingFolderNames={folders?.map((folder) => folder.name)} - selectedFolder={editModal?.selectedFolder} - /> + setEditModal(undefined); + }} + existingFolderNames={folders?.map((folder) => folder.name)} + selectedFolder={editModal?.selectedFolder} + /> + )} - {[folders ?? [], teams ?? [], organizations ?? []].map((folder, typeIndex) => ( -
    - { - setOpen((prevOpen) => { - const newOpen = [...prevOpen]; - newOpen[typeIndex] = !prevOpen[typeIndex]; - return newOpen; - }); - }}> - {open[typeIndex] ? ( - - ) : ( - - )} + {[folders ?? [], teams ?? [], organizations ?? []] + .map((folder, typeIndex) => { + // Filter out "My Content" section if hideMyContent is true + if (hideMyContent && folderTypes[typeIndex] === "folder") { + return null; + } - - - - - {selectedFolder?.type === folderTypes[typeIndex] && !open[typeIndex] - ? `${folderTypeTitles[typeIndex]} / ${selectedFolder?.name}` - : `${folderTypeTitles[typeIndex]}`} - - } - /> - {typeIndex === 0 && enableActions && ( - - { - setEditModal({ - type: "create", - open: true, - }); - event.stopPropagation(); - }}> - - - - )} - - - - {folder.map((item) => ( - - handleListItemClick(event, { - type: folderTypes[typeIndex] as "folder" | "team" | "organization", - id: item.id, - name: item.name, - }) - } + return ( +
    + { + setOpen((prevOpen) => { + const newOpen = [...prevOpen]; + newOpen[typeIndex] = !prevOpen[typeIndex]; + return newOpen; + }); + }}> + {open[typeIndex] ? ( + + ) : ( + + )} + + + + + - - - - - {folderTypes[typeIndex] === "folder" && item?.name !== "home" && enableActions && ( - - - - } - onSelect={(menuItem: PopperMenuItem) => { + }, + }} + primary={ + + {selectedFolder?.type === folderTypes[typeIndex] && !open[typeIndex] + ? `${folderTypeTitles[typeIndex]} / ${selectedFolder?.name}` + : `${folderTypeTitles[typeIndex]}`} + + } + /> + {typeIndex === 0 && enableActions && ( + + { setEditModal({ - type: menuItem.id === "rename" ? "update" : "delete", - selectedFolder: { - id: item.id, - name: item.name, - }, + type: "create", open: true, }); + event.stopPropagation(); + }}> + + + + )} + + + + {folder.map((item) => ( + + handleListItemClick(event, { + type: folderTypes[typeIndex] as "folder" | "team" | "organization", + id: item.id, + name: item.name, + }) + } + sx={{ + pl: 10, + ...(selectedFolder?.id === item.id && { + color: theme.palette.primary.main, + }), }} - /> - )} - - ))} - - -
    - ))} + key={item.id}> + + + + + {folderTypes[typeIndex] === "folder" && item?.name !== "home" && enableActions && ( + + + + } + onSelect={(menuItem: PopperMenuItem) => { + setEditModal({ + type: menuItem.id === "rename" ? "update" : "delete", + selectedFolder: { + id: item.id, + name: item.name, + }, + open: true, + }); + }} + /> + )} +
    + ))} +
    +
    +
    + ); + }) + .filter(Boolean)}
    ); diff --git a/apps/web/components/dashboard/common/TileCard.tsx b/apps/web/components/dashboard/common/TileCard.tsx index 91fa2b1f..09ed3cc3 100644 --- a/apps/web/components/dashboard/common/TileCard.tsx +++ b/apps/web/components/dashboard/common/TileCard.tsx @@ -170,7 +170,7 @@ const TileCard = (props: TileCard) => { <> {cardTitle} - {moreMenu} + {enableActions && moreMenu} {/* Created by info */} @@ -268,52 +268,47 @@ const TileCard = (props: TileCard) => { {cardType === "grid" && gridContent} {cardType === "list" && ( - {enableActions && ( - <> - - {cardTitle} - - - - {ownedBy} - - - + + {cardTitle} + + + - - {updatedAtText} - - - - - {createdAtText} - - + {ownedBy} +
    + + + + {updatedAtText} + + + + + {createdAtText} + + + {enableActions && ( {moreMenu} - - )} - {!enableActions && ( - - {cardTitle} - - )} + )} + )} diff --git a/apps/web/components/header/Header.tsx b/apps/web/components/header/Header.tsx index bc0570f8..b801bf9d 100644 --- a/apps/web/components/header/Header.tsx +++ b/apps/web/components/header/Header.tsx @@ -10,9 +10,10 @@ import { ICON_NAME, Icon } from "@p4b/ui/components/Icon"; import { useDateFnsLocale, useTranslation } from "@/i18n/client"; -import { useOrganization, useUserProfile } from "@/lib/api/users"; +import { useOrganization } from "@/lib/api/users"; import { CONTACT_US_URL, DOCS_URL } from "@/lib/constants"; -import { isOrgAdmin } from "@/lib/utils/auth"; + +import { useAuthZ } from "@/hooks/auth/AuthZ"; import UserInfoMenu from "@/components/UserInfoMenu"; import JobsPopper from "@/components/jobs/JobsPopper"; @@ -33,7 +34,7 @@ export default function Header(props: HeaderProps) { const { t } = useTranslation(["common"]); const { tags, title, lastSaved, onMenuIconClick, showHambugerMenu, height = 52 } = props; const { organization } = useOrganization(); - const { userProfile } = useUserProfile(); + const { isOrgAdmin } = useAuthZ(); const dateLocale = useDateFnsLocale(); return ( <> - {organization && organization.on_trial && userProfile && isOrgAdmin(userProfile.roles) && ( + {organization && organization.on_trial && isOrgAdmin && ( } variant="outlined" diff --git a/apps/web/components/map/controls/Legend.tsx b/apps/web/components/map/controls/Legend.tsx index 68441a8d..c6c92c24 100644 --- a/apps/web/components/map/controls/Legend.tsx +++ b/apps/web/components/map/controls/Legend.tsx @@ -1,4 +1,4 @@ -import { Stack, Tooltip, Typography } from "@mui/material"; +import { Divider, Paper, Stack, Tooltip, Typography, useTheme } from "@mui/material"; import { useMemo } from "react"; import { ICON_NAME } from "@p4b/ui/components/Icon"; @@ -16,6 +16,7 @@ import type { ProjectLayer } from "@/lib/validations/project"; import type { RGBColor } from "@/types/map/color"; import EmptySection from "@/components/common/EmptySection"; +import { LayerVisibilityToggle } from "@/components/map/panels/layer/Layer"; import { MaskedImageIcon } from "@/components/map/panels/style/other/MaskedImageIcon"; const DEFAULT_COLOR = "#000000"; @@ -23,6 +24,8 @@ export interface LegendProps { layers: ProjectLayer[]; hideLayerName?: boolean; hideZoomLevel?: boolean; + enableActions?: boolean; + onVisibilityChange?: (layer: ProjectLayer) => void; } type ColorMapItem = { @@ -380,12 +383,29 @@ export function Legend(props: LegendProps) { return ( props.layers && ( <> - {props.layers.map((layer) => ( - + {props.layers.map((layer, index) => ( + {!props.hideLayerName && ( - - {layer.name} - + + + {layer.name} + + {props.enableActions && ( + + )} + )} {!props.hideZoomLevel && ( @@ -415,6 +435,7 @@ export function Legend(props: LegendProps) { )} + {index < props.layers.length - 1 && } ))} @@ -423,3 +444,21 @@ export function Legend(props: LegendProps) { ) ); } + +export function LegendMapContainer(props: LegendProps) { + const theme = useTheme(); + return ( + + + + + + ); +} diff --git a/apps/web/components/map/panels/Legend.tsx b/apps/web/components/map/panels/Legend.tsx index 23e33653..252123fe 100644 --- a/apps/web/components/map/panels/Legend.tsx +++ b/apps/web/components/map/panels/Legend.tsx @@ -4,32 +4,54 @@ import { useTranslation } from "@/i18n/client"; import { setActiveLeftPanel } from "@/lib/store/map/slice"; -import type { PanelProps } from "@/types/map/sidebar"; - -import { useFilteredProjectLayers } from "@/hooks/map/LayerPanelHooks"; +import { useFilteredProjectLayers, useLayerActions } from "@/hooks/map/LayerPanelHooks"; import { useAppDispatch } from "@/hooks/store/ContextHooks"; -import { Legend } from "@/components/map/controls/Legend"; +import { Legend, LegendMapContainer } from "@/components/map/controls/Legend"; import Container from "@/components/map/panels/Container"; -const LegendPanel = ({ projectId }: PanelProps) => { +interface PanelProps { + projectId: string; + isFloating?: boolean; + showAllLayers?: boolean; +} + +const LegendPanel = ({ projectId, isFloating = false, showAllLayers = false }: PanelProps) => { const { t } = useTranslation("common"); const dispatch = useAppDispatch(); - const { layers: projectLayers } = useFilteredProjectLayers(projectId); - const visibleLayers = useMemo( + const { layers: projectLayers, mutate: mutateProjectLayers } = useFilteredProjectLayers(projectId); + const { toggleLayerVisibility } = useLayerActions(projectLayers); + const _layers = useMemo( () => - projectLayers?.filter((layer) => { - return layer.properties?.visibility; - }), - [projectLayers] + showAllLayers + ? projectLayers + : projectLayers?.filter((layer) => { + return layer.properties?.visibility; + }), + [projectLayers, showAllLayers] ); return ( - dispatch(setActiveLeftPanel(undefined))} - direction="left" - body={projectLayers && } - /> + <> + {!isFloating && ( + dispatch(setActiveLeftPanel(undefined))} + direction="left" + body={projectLayers && } + /> + )} + {isFloating && ( + { + const { layers: _layers, layerToUpdate: _layerToUpdate } = toggleLayerVisibility(layer); + await mutateProjectLayers(_layers, false); + }} + /> + )} + ); }; diff --git a/apps/web/components/map/panels/ProjectNavigation.tsx b/apps/web/components/map/panels/ProjectNavigation.tsx index ea76888d..f09ac2eb 100644 --- a/apps/web/components/map/panels/ProjectNavigation.tsx +++ b/apps/web/components/map/panels/ProjectNavigation.tsx @@ -11,6 +11,7 @@ import { layerType } from "@/lib/validations/common"; import { MapSidebarItemID } from "@/types/map/common"; +import { useAuthZ } from "@/hooks/auth/AuthZ"; import { useActiveLayer } from "@/hooks/map/LayerPanelHooks"; import { useAppDispatch, useAppSelector } from "@/hooks/store/ContextHooks"; @@ -19,7 +20,6 @@ import { BasemapSelector } from "@/components/map/controls/BasemapSelector"; import { Fullscren } from "@/components/map/controls/Fullscreen"; import Geocoder from "@/components/map/controls/Geocoder"; import { Zoom } from "@/components/map/controls/Zoom"; -import Charts from "@/components/map/panels/Charts"; import Legend from "@/components/map/panels/Legend"; import Filter from "@/components/map/panels/filter/Filter"; import LayerPanel from "@/components/map/panels/layer/Layer"; @@ -45,6 +45,7 @@ const ProjectNavigation = ({ projectId }) => { const prevActiveLeftRef = useRef(undefined); const prevActiveRightRef = useRef(undefined); + const { isProjectEditor } = useAuthZ(); const leftSidebar: MapSidebarProps = { topItems: [ @@ -60,12 +61,6 @@ const ProjectNavigation = ({ projectId }) => { name: t("legend"), component: , }, - { - id: MapSidebarItemID.CHARTS, - icon: ICON_NAME.CHART, - name: t("charts"), - component: , - }, ], bottomItems: [], width: sidebarWidth, @@ -144,29 +139,31 @@ const ProjectNavigation = ({ projectId }) => { return ( <> - - { - if (item.link) { - window.open(item.link, "_blank"); - return; - } else { - dispatch(setActiveLeftPanel(item.id === activeLeft ? undefined : item.id)); - } - }} - /> - + {isProjectEditor && ( + + { + if (item.link) { + window.open(item.link, "_blank"); + return; + } else { + dispatch(setActiveLeftPanel(item.id === activeLeft ? undefined : item.id)); + } + }} + /> + + )} { position: "absolute", top: 0, pointerEvents: "all", - left: sidebarWidth, + ...(isProjectEditor && { left: sidebarWidth }), [theme.breakpoints.down("sm")]: { left: "0", }, }}> - theme.zIndex.drawer + 1 }} - onExited={() => { - dispatch(setActiveLeftPanel(undefined)); - prevActiveLeftRef.current = undefined; - }}> - {(activeLeft !== undefined || prevActiveLeftRef.current !== undefined) && ( - - {activeLeftComponent} - - )} - + {isProjectEditor && ( + theme.zIndex.drawer + 1 }} + onExited={() => { + dispatch(setActiveLeftPanel(undefined)); + prevActiveLeftRef.current = undefined; + }}> + {(activeLeft !== undefined || prevActiveLeftRef.current !== undefined) && ( + + {activeLeftComponent} + + )} + + )} {/* Left Controls */} { + {!isProjectEditor && ( + + + + )} { position: "absolute", top: 0, pointerEvents: "none", - right: sidebarWidth, + right: isProjectEditor ? sidebarWidth : 0, [theme.breakpoints.down("sm")]: { right: "0", }, @@ -251,50 +255,54 @@ const ProjectNavigation = ({ projectId }) => { /> - { - dispatch(setActiveRightPanel(undefined)); - prevActiveRightRef.current = undefined; - }}> - {(activeRight !== undefined || prevActiveRightRef.current !== undefined) && ( - - {activeRightComponent} - - )} - + {isProjectEditor && ( + { + dispatch(setActiveRightPanel(undefined)); + prevActiveRightRef.current = undefined; + }}> + {(activeRight !== undefined || prevActiveRightRef.current !== undefined) && ( + + {activeRightComponent} + + )} + + )}
    - - { - if (item.link) { - window.open(item.link, "_blank"); - return; - } else { - dispatch(setActiveRightPanel(item.id === activeRight ? undefined : item.id)); - } - }} - /> - + {isProjectEditor && ( + + { + if (item.link) { + window.open(item.link, "_blank"); + return; + } else { + dispatch(setActiveRightPanel(item.id === activeRight ? undefined : item.id)); + } + }} + /> + + )} ); }; diff --git a/apps/web/components/map/panels/layer/Layer.tsx b/apps/web/components/map/panels/layer/Layer.tsx index 448119d8..3d9a21ec 100644 --- a/apps/web/components/map/panels/layer/Layer.tsx +++ b/apps/web/components/map/panels/layer/Layer.tsx @@ -261,10 +261,45 @@ const AddLayerSection = ({ projectId }: { projectId: string }) => { ); }; +export const LayerVisibilityToggle = ({ layer, toggleLayerVisibility }) => { + const { t } = useTranslation("common"); + const theme = useTheme(); + if (layer.type === "table") { + return null; + } + + return ( + + { + event.stopPropagation(); + toggleLayerVisibility(layer); + }} + sx={{ + transition: theme.transitions.create(["opacity"], { + duration: theme.transitions.duration.standard, + }), + opacity: !layer.properties?.visibility ? 1 : 0, + }}> + + + + ); +}; + const LayerPanel = ({ projectId }: PanelProps) => { const { t } = useTranslation("common"); const { map } = useMap(); - const theme = useTheme(); const dispatch = useAppDispatch(); const [previousRightPanel, setPreviousRightPanel] = useState(undefined); const activeLayerId = useAppSelector((state) => state.layers.activeLayerId); @@ -529,35 +564,10 @@ const LayerPanel = ({ projectId }: PanelProps) => { } actions={ - {layer.type === "table" ? null : ( - - { - event.stopPropagation(); - toggleLayerVisibility(layer); - }} - sx={{ - transition: theme.transitions.create(["opacity"], { - duration: theme.transitions.duration.standard, - }), - opacity: !layer.properties?.visibility ? 1 : 0, - }}> - - - - )} + {scenarioFeaturesCount && scenarioFeaturesCount[layer.id] && ( = ({ open, onClose, d limit: 50, offset: 0, }; - if (dataset["query"]) { - defaultParams["filter"] = JSON.stringify(dataset["query"]); + if (dataset["query"]?.["cql"]) { + defaultParams["filter"] = JSON.stringify(dataset["query"]["cql"]); } const [dataQueryParams, setDataQueryParams] = useState(defaultParams); const { data } = useDatasetCollectionItems(dataset["layer_id"] || dataset["id"] || "", dataQueryParams); diff --git a/apps/web/hooks/auth/AuthZ.ts b/apps/web/hooks/auth/AuthZ.ts new file mode 100644 index 00000000..42aa597f --- /dev/null +++ b/apps/web/hooks/auth/AuthZ.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; + +import { useUserProfile } from "@/lib/api/users"; +import { organizationRoles } from "@/lib/validations/organization"; +import { Team, teamRoles } from "@/lib/validations/team"; + +interface Options { + team?: Team; +} + +export function useAuthZ(options: Options = {}) { + const { userProfile, isLoading: isUserProfileLoading } = useUserProfile(); + + const roles = userProfile?.roles; + + const isOrgAdmin = useMemo(() => { + if (!roles) return false; + return roles.includes(organizationRoles.OWNER) || roles.includes(organizationRoles.ADMIN); + }, [roles]); + + const isOrgEditor = useMemo(() => { + if (!roles) return false; + return roles.includes(organizationRoles.EDITOR) || isOrgAdmin; + }, [roles, isOrgAdmin]); + + const isTeamOwner = useMemo(() => { + const { team } = options; + if (!team || !roles) return false; + return team.role === teamRoles.OWNER + }, [roles]); + + const isProjectEditor = useMemo(() => { + return isOrgEditor; + }, [isOrgEditor]); + + const isLoading = useMemo(() => { + return isUserProfileLoading; + }, [isUserProfileLoading]); + + return { + isLoading, + isOrgAdmin, + isOrgEditor, + isTeamOwner, + isProjectEditor, + }; +} diff --git a/apps/web/hooks/map/LayerPanelHooks.ts b/apps/web/hooks/map/LayerPanelHooks.ts index d43673b4..027202bb 100644 --- a/apps/web/hooks/map/LayerPanelHooks.ts +++ b/apps/web/hooks/map/LayerPanelHooks.ts @@ -181,6 +181,30 @@ export const useLayerSettingsMoreMenu = () => { }; }; +export const useLayerActions = (projectLayers: ProjectLayer[]) => { + function toggleLayerVisibility(layer: ProjectLayer) { + const layers = JSON.parse(JSON.stringify(projectLayers)); + const index = layers.findIndex((l) => l.id === layer.id); + const layerToUpdate = layers[index]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let properties = layerToUpdate.properties as any; + if (!properties) { + properties = {}; + } + + properties.visibility = !properties.visibility; + + layerToUpdate.properties = properties; + return { layers, layerToUpdate }; + } + + return { + toggleLayerVisibility, + } + +}; + + export const useActiveLayer = (projectId: string) => { const { layers: projectLayers, mutate } = useProjectLayers(projectId); const activeLayerId = useAppSelector((state) => state.layers.activeLayerId); diff --git a/apps/web/lib/utils/auth.ts b/apps/web/lib/utils/auth.ts index d43168cb..ed7fd30c 100644 --- a/apps/web/lib/utils/auth.ts +++ b/apps/web/lib/utils/auth.ts @@ -1,20 +1,4 @@ import { KEYCLOAK_CLIENT_ID, KEYCLOAK_ISSUER } from "@/lib/constants"; -import { organizationRoles } from "@/lib/validations/organization"; -import { teamRoles } from "@/lib/validations/team"; - -export function isOrgAdmin(roles?: string[]) { - if (!roles) return false; - return roles.includes(organizationRoles.OWNER) || roles.includes(organizationRoles.ADMIN); -} - -export function isOrgEditor(roles?: string[]) { - if (!roles) return false; - return roles.includes(organizationRoles.EDITOR) || isOrgAdmin(roles); -} - -export function isTeamOwner(role: string) { - return role === teamRoles.OWNER; -} //INFO: Keycloak doesn't support prompt=create in signIn endpoint. This is a hacky workaround to create a registration link manually when user is coming from the invite link. //TODO: This should be removed once Keycloak supports prompt=create in signIn endpoint.