Compare commits

...

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
6ff065d861 feat(authz): add support for dashboard pages 2026-03-03 10:09:42 -03:00
17 changed files with 713 additions and 392 deletions

View File

@@ -88,6 +88,7 @@
"chartjs-adapter-date-fns": "^2.0.0",
"chartjs-plugin-annotation": "^1.4.0",
"classnames": "2.3.2",
"clsx": "2.1.1",
"color": "^4.2.1",
"color-alpha": "1.1.3",
"cross-env": "^7.0.3",
@@ -153,6 +154,7 @@
"stream": "^0.0.2",
"style-loader": "1.3.0",
"styled-components": "^5.3.11",
"tailwind-merge": "3.5.0",
"terser-webpack-plugin": "^5.2.5",
"timestamp-nano": "^1.0.0",
"ts-node": "^10.2.1",
@@ -290,4 +292,4 @@
"on-headers": "^1.1.0",
"tmp": "0.2.4"
}
}
}

View File

@@ -0,0 +1,47 @@
import { Button, ButtonProps } from '@signozhq/button';
import { Tooltip, TooltipProvider } from '@signozhq/tooltip';
import { cn } from 'lib/cn';
export type ButtonWithTooltipProps = ButtonProps & {
tooltipTitle?: string;
tooltipDisabled?: boolean;
};
export function ButtonWithTooltip({
children,
tooltipTitle,
tooltipDisabled,
onClick,
disabled,
...props
}: ButtonWithTooltipProps): JSX.Element {
if (tooltipDisabled || !tooltipTitle) {
return <Button {...props}>{children}</Button>;
}
if (disabled) {
return (
<TooltipProvider>
<Tooltip title={tooltipTitle}>
<Button
{...props}
className={cn('disabled:pointer-events-auto', props.className)}
disabled={disabled}
>
{children}
</Button>
</Tooltip>
</TooltipProvider>
);
}
return (
<TooltipProvider>
<Tooltip title={tooltipTitle}>
<Button {...props} onClick={onClick} disabled={disabled}>
{children}
</Button>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,54 @@
import { Button } from '@signozhq/button';
import { AuthZRelation } from '../../hooks/useAuthZ/types';
import { parsePermission } from '../../hooks/useAuthZ/utils';
import {
ButtonWithTooltip,
ButtonWithTooltipProps,
} from '../ButtonWithTooltip/ButtonWithTooltip';
import { GuardAuthZ, GuardAuthZProps } from '../GuardAuthZ/GuardAuthZ';
export function GuardButton<R extends AuthZRelation>({
children,
tooltipTitle,
tooltipDisabled = true,
...props
}: ButtonWithTooltipProps &
Omit<GuardAuthZProps<R>, 'children'> & {
tooltipTitle?: string;
}): JSX.Element {
return (
<GuardAuthZ
relation={props.relation}
object={props.object}
fallbackOnLoading={
<Button {...props} loading={true}>
{children}
</Button>
}
fallbackOnNoPermissions={({ requiredPermissionName }): JSX.Element => {
const { relation, object } = parsePermission(requiredPermissionName);
return (
<ButtonWithTooltip
{...props}
disabled={true}
tooltipTitle={
tooltipTitle || `You don't have ${relation}:${object} permission.`
}
>
{children}
</ButtonWithTooltip>
);
}}
>
<ButtonWithTooltip
tooltipDisabled={tooltipDisabled}
tooltipTitle={tooltipTitle}
{...props}
>
{children}
</ButtonWithTooltip>
</GuardAuthZ>
);
}

View File

@@ -39,6 +39,15 @@ function OnNoPermissionsFallback(response: {
);
}
/**
* If you want to guard a route, you can use this function to create a guarded route.
*
* @example
* ```tsx
* createGuardedRoute(Component, 'read', 'dashboard:{id}'); // use when route has a dynamic parameter like /dashboard/:id
* createGuardedRoute(Component, 'list', 'dashboards');
* ```
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
Component: ComponentType<P>,

View File

@@ -4,6 +4,7 @@ import { Layout } from 'react-grid-layout';
import { useTranslation } from 'react-i18next';
import { useCopyToClipboard } from 'react-use';
import { PlusOutlined } from '@ant-design/icons';
import { Button as SignozButton } from '@signozhq/button';
import {
Button,
Card,
@@ -16,13 +17,14 @@ import {
} from 'antd';
import logEvent from 'api/common/logEvent';
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
import { GuardButton } from 'components/PermissionlessButton/PermissionlessButton';
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { buildObjectString } from 'hooks/useAuthZ/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { isEmpty } from 'lodash-es';
@@ -38,13 +40,10 @@ import {
PenLine,
X,
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { sortLayout } from 'providers/Dashboard/util';
import { DashboardData } from 'types/api/dashboard/getAll';
import { Props } from 'types/api/dashboard/update';
import { ROLES, USER_ROLES } from 'types/roles';
import { ComponentTypes } from 'utils/permission';
import { v4 as uuid } from 'uuid';
import DashboardHeader from '../components/DashboardHeader/DashboardHeader';
@@ -108,8 +107,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const updateDashboardMutation = useUpdateDashboard();
const { user } = useAppContext();
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] = useState<boolean>(
false,
);
@@ -124,27 +121,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
let isAuthor = false;
if (selectedDashboard && user && user.email) {
isAuthor = selectedDashboard?.createdBy === user?.email;
}
let permissions: ComponentTypes[] = ['add_panel'];
if (isDashboardLocked) {
permissions = ['add_panel_locked_dashboard'];
}
const { notifications } = useNotifications();
const userRole: ROLES | null =
selectedDashboard?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
const onEmptyWidgetHandler = useCallback(() => {
setSelectedRowWidgetId(null);
handleToggleDashboardSlider(true);
@@ -369,63 +347,82 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
content={
<div className="menu-content">
<section className="section-1">
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
selectedDashboard?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={selectedDashboard?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</Button>
</Tooltip>
)}
<GuardButton
relation="update"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
variant="ghost"
className="w-full justify-start px-2 gap-2"
prefixIcon={<LockKeyhole />}
onClick={handleLockDashboardToggle}
tooltipTitle={
selectedDashboard?.createdBy === 'integration'
? 'Dashboards created by integrations cannot be unlocked'
: undefined
}
disabled={selectedDashboard?.createdBy === 'integration'}
tooltipDisabled={selectedDashboard?.createdBy !== 'integration'}
>
{isDashboardLocked ? 'Unlock Dashboard' : 'Lock Dashboard'}
</GuardButton>
{!isDashboardLocked && editDashboard && (
<Button
type="text"
icon={<PenLine size={14} />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
Rename
</Button>
)}
<GuardButton
relation="update"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
variant="ghost"
className="w-full justify-start px-2 gap-2"
prefixIcon={<PenLine />}
onClick={(): void => {
setIsRenameDashboardOpen(true);
setIsDashbordSettingsOpen(false);
}}
tooltipTitle={
isDashboardLocked
? `You can't rename a locked dashboard.`
: undefined
}
tooltipDisabled={!isDashboardLocked}
disabled={isDashboardLocked}
>
Rename
</GuardButton>
<Button
type="text"
icon={<Fullscreen size={14} />}
<SignozButton
variant="ghost"
className="w-full justify-start px-2 gap-2"
prefixIcon={<Fullscreen />}
onClick={handle.enter}
>
Full screen
</Button>
</SignozButton>
</section>
<section className="section-2">
{!isDashboardLocked && addPanelPermission && (
<Button
type="text"
icon={<FolderKanban size={14} />}
onClick={(): void => {
setIsPanelNameModalOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
New section
</Button>
)}
<GuardButton
relation="update"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
variant="ghost"
className="w-full justify-start px-2 gap-2"
prefixIcon={<FolderKanban />}
tooltipTitle={
isDashboardLocked
? `You can't add panel to a locked dashboard.`
: undefined
}
disabled={isDashboardLocked}
tooltipDisabled={!isDashboardLocked}
onClick={(): void => {
setIsPanelNameModalOpen(true);
setIsDashbordSettingsOpen(false);
}}
>
New section
</GuardButton>
<Button
type="text"
icon={<FileJson size={14} />}
<GuardButton
relation="read"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
variant="ghost"
className="w-full justify-start px-2 gap-2"
prefixIcon={<FileJson />}
onClick={(): void => {
downloadObjectAsJson(
sanitizeDashboardData(selectedData),
@@ -435,10 +432,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}}
>
Export JSON
</Button>
<Button
type="text"
icon={<ClipboardCopy size={14} />}
</GuardButton>
<GuardButton
relation="read"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
variant="ghost"
className="w-full justify-start px-2 gap-2"
prefixIcon={<ClipboardCopy />}
onClick={(): void => {
setCopy(
JSON.stringify(sanitizeDashboardData(selectedData), null, 2),
@@ -447,7 +448,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}}
>
Copy as JSON
</Button>
</GuardButton>
</section>
<section className="delete-dashboard">
<DeleteButton
@@ -470,39 +471,50 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
data-testid="options"
/>
</Popover>
{!isDashboardLocked && editDashboard && (
<>
<Button
type="text"
className="configure-button"
icon={<ConfigureIcon />}
data-testid="show-drawer"
onClick={onConfigureClick}
>
Configure
</Button>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
</SettingsDrawer>
</>
)}
{!isDashboardLocked && addPanelPermission && (
<Button
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
icon={<PlusOutlined />}
type="primary"
data-testid="add-panel-header"
>
New Panel
</Button>
)}
<GuardButton
relation="update"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
variant="ghost"
prefixIcon={<ConfigureIcon />}
className="configure-button"
data-testid="show-drawer"
onClick={onConfigureClick}
tooltipTitle={
isDashboardLocked ? `You can't configure a locked dashboard.` : undefined
}
tooltipDisabled={!isDashboardLocked}
disabled={isDashboardLocked}
>
Configure
</GuardButton>
<SettingsDrawer
drawerTitle="Dashboard Configuration"
isOpen={isSettingsDrawerOpen}
onClose={onSettingsDrawerClose}
>
<DashboardSettings
variablesSettingsTabHandle={variablesSettingsTabHandle}
/>
</SettingsDrawer>
<GuardButton
relation="update"
object={buildObjectString('dashboard', selectedDashboard?.id || '')}
className="add-panel-btn"
onClick={onEmptyWidgetHandler}
variant="solid"
color="primary"
prefixIcon={<PlusOutlined />}
data-testid="add-panel-header"
tooltipTitle={
isDashboardLocked
? `You can't add panel to a locked dashboard.`
: undefined
}
disabled={isDashboardLocked}
tooltipDisabled={!isDashboardLocked}
>
New Panel
</GuardButton>
</div>
</section>
{(tags?.length || 0) > 0 && (

View File

@@ -31,6 +31,15 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('components/PermissionlessButton/PermissionlessButton', () => ({
GuardButton: ({ children, prefixIcon, ...props }: any): JSX.Element => (
<button type="button" {...props}>
{prefixIcon}
{children}
</button>
),
}));
// Mock data
const mockProps: WidgetGraphComponentProps = {
widget: {
@@ -205,7 +214,7 @@ describe('WidgetGraphComponent', () => {
expect(skeleton).toBeInTheDocument();
const moreOptionsButton = getByTestId('widget-header-options');
fireEvent.mouseEnter(moreOptionsButton);
fireEvent.click(moreOptionsButton);
const menu = await findByRole('menu');
expect(menu).toBeInTheDocument();

View File

@@ -39,12 +39,8 @@
}
.widget-header-more-options {
visibility: hidden;
border: none;
box-shadow: none;
cursor: pointer;
font: 14px;
font-weight: 600;
padding: 8px;
padding: 0 6px;
}
.widget-header-more-options-visible {
@@ -55,6 +51,13 @@
padding-right: 0.25rem;
}
.widget-header-menu-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 160px;
}
.lightMode {
.widget-header-container {
.ant-input-group-addon {
@@ -76,3 +79,14 @@
.info-tooltip {
cursor: pointer;
}
.widget-header-popover {
.ant-popover-inner {
padding: 0px;
border-radius: 4px;
border: 1px solid var(--l2-border);
background: var(--l2-background) !important;
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
}
}

View File

@@ -83,6 +83,15 @@ const render = (ui: React.ReactElement): ReturnType<typeof rtlRender> =>
</MemoryRouter>,
);
jest.mock('components/PermissionlessButton/PermissionlessButton', () => ({
GuardButton: ({ children, prefixIcon, ...props }: any): JSX.Element => (
<button type="button" {...props}>
{prefixIcon}
{children}
</button>
),
}));
jest.mock('hooks/queryBuilder/useCreateAlerts', () => ({
__esModule: true,
default: jest.fn(() => jest.fn()),
@@ -479,7 +488,7 @@ describe('WidgetHeader', () => {
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
expect(moreOptionsIcon).toBeInTheDocument();
await userEvent.hover(moreOptionsIcon);
await userEvent.click(moreOptionsIcon);
await screen.findByText(CREATE_ALERTS_TEXT);
@@ -513,7 +522,7 @@ describe('WidgetHeader', () => {
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
await userEvent.hover(moreOptionsIcon);
await userEvent.click(moreOptionsIcon);
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);

View File

@@ -2,25 +2,30 @@ import { ReactNode, useCallback, useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
import {
AlertOutlined,
CloudDownloadOutlined,
CopyOutlined,
DeleteOutlined,
EditFilled,
FullscreenOutlined,
InfoCircleOutlined,
MoreOutlined,
SearchOutlined,
} from '@ant-design/icons';
import { Button } from '@signozhq/button';
import { Color } from '@signozhq/design-tokens';
import { Dropdown, Input, MenuProps, Tooltip, Typography } from 'antd';
import {
ClipboardCopy,
CloudDownload,
Expand,
Pencil,
Trash,
} from '@signozhq/icons';
import { Input, Popover, Tooltip, Typography } from 'antd';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
import { GuardButton } from 'components/PermissionlessButton/PermissionlessButton';
import Spinner from 'components/Spinner';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useGetResolvedText from 'hooks/dashboard/useGetResolvedText';
import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import { buildObjectString } from 'hooks/useAuthZ/utils';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -35,10 +40,10 @@ import APIError from 'types/api/error';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { buildAbsolutePath } from 'utils/app';
import { cn } from '../../../lib/cn';
import { errorTooltipPosition } from './config';
import { MENUITEM_KEYS_VS_LABELS, MenuItemKeys } from './contants';
import { MenuItem } from './types';
import { generateMenuList, isTWidgetOptions } from './utils';
import { isTWidgetOptions } from './utils';
import './WidgetHeader.styles.scss';
@@ -62,6 +67,149 @@ interface IWidgetHeaderProps {
setSearchTerm: React.Dispatch<React.SetStateAction<string>>;
}
interface WidgetActionsMenuProps {
isViewVisible: boolean;
isEditVisible: boolean;
isCloneVisible: boolean;
isDownloadVisible: boolean;
isDeleteVisible: boolean;
isCreateAlertsVisible: boolean;
dashboardId: string;
isFetching: boolean;
canEdit: boolean;
canDelete: boolean;
onActionClick: (key: MenuItemKeys) => void;
}
function WidgetActionsMenu({
isViewVisible,
isEditVisible,
isCloneVisible,
isDownloadVisible,
isDeleteVisible,
isCreateAlertsVisible,
dashboardId,
isFetching,
canEdit,
canDelete,
onActionClick,
}: WidgetActionsMenuProps): JSX.Element {
return (
<div className="widget-header-menu-content" role="menu">
{isViewVisible && (
<GuardButton
variant="ghost"
prefixIcon={<Expand />}
role="menuitem"
relation="read"
object={buildObjectString('dashboard', dashboardId)}
disabled={isFetching}
className="w-full gap-2 px-2 justify-start"
onClick={(e): void => {
e.stopPropagation();
onActionClick(MenuItemKeys.View);
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.View]}
</GuardButton>
)}
{isEditVisible && (
<GuardButton
variant="ghost"
prefixIcon={<Pencil />}
role="menuitem"
relation="update"
object={buildObjectString('dashboard', dashboardId)}
disabled={!canEdit}
className="w-full gap-2 px-2 justify-start"
onClick={(e): void => {
e.stopPropagation();
onActionClick(MenuItemKeys.Edit);
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Edit]}
</GuardButton>
)}
{isCloneVisible && (
<GuardButton
variant="ghost"
prefixIcon={<ClipboardCopy />}
role="menuitem"
relation="update"
object={buildObjectString('dashboard', dashboardId)}
disabled={!canEdit}
className="w-full gap-2 px-2 justify-start"
onClick={(e): void => {
e.stopPropagation();
onActionClick(MenuItemKeys.Clone);
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Clone]}
</GuardButton>
)}
{isDownloadVisible && (
<GuardButton
variant="ghost"
prefixIcon={<CloudDownload />}
role="menuitem"
relation="read"
object={buildObjectString('dashboard', dashboardId)}
className="w-full gap-2 px-2 justify-start"
onClick={(e): void => {
e.stopPropagation();
onActionClick(MenuItemKeys.Download);
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Download]}
</GuardButton>
)}
{isDeleteVisible && (
<GuardButton
variant="ghost"
prefixIcon={<Trash />}
role="menuitem"
relation="update"
object={buildObjectString('dashboard', dashboardId)}
disabled={!canDelete}
className="w-full gap-2 px-2 justify-start text-bg-cherry-500"
onClick={(e): void => {
e.stopPropagation();
onActionClick(MenuItemKeys.Delete);
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Delete]}
</GuardButton>
)}
{isCreateAlertsVisible && (
<GuardButton
variant="ghost"
prefixIcon={<AlertOutlined />}
role="menuitem"
relation="read"
object={buildObjectString('dashboard', dashboardId)}
className="w-full gap-2 px-2 justify-start"
onClick={(e): void => {
e.stopPropagation();
onActionClick(MenuItemKeys.CreateAlerts);
}}
>
<span
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
width: '100%',
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
<SquareArrowOutUpRight size={10} />
</span>
</GuardButton>
)}
</div>
);
}
function WidgetHeader({
title,
widget,
@@ -125,8 +273,8 @@ function WidgetHeader({
],
);
const onMenuItemSelectHandler: MenuProps['onClick'] = useCallback(
({ key }: { key: string }): void => {
const onMenuItemSelectHandler = useCallback(
(key: string): void => {
if (isTWidgetOptions(key)) {
const functionToCall = keyMethodMapping[key];
@@ -144,86 +292,37 @@ function WidgetHeader({
user.role,
);
const actions = useMemo(
(): MenuItem[] => [
{
key: MenuItemKeys.View,
icon: <FullscreenOutlined />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.View],
isVisible: headerMenuList?.includes(MenuItemKeys.View) || false,
disabled: queryResponse.isFetching,
},
{
key: MenuItemKeys.Edit,
icon: <EditFilled />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Edit],
isVisible: headerMenuList?.includes(MenuItemKeys.Edit) || false,
disabled: !editWidget,
},
{
key: MenuItemKeys.Clone,
icon: <CopyOutlined />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Clone],
isVisible: headerMenuList?.includes(MenuItemKeys.Clone) || false,
disabled: !editWidget,
},
{
key: MenuItemKeys.Download,
icon: <CloudDownloadOutlined />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Download],
isVisible: widget.panelTypes === PANEL_TYPES.TABLE,
disabled: false,
},
{
key: MenuItemKeys.Delete,
icon: <DeleteOutlined />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.Delete],
isVisible: headerMenuList?.includes(MenuItemKeys.Delete) || false,
disabled: !deleteWidget,
danger: true,
},
{
key: MenuItemKeys.CreateAlerts,
icon: <AlertOutlined />,
label: (
<span
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
<SquareArrowOutUpRight size={10} />
</span>
),
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
disabled: false,
},
],
[
headerMenuList,
queryResponse.isFetching,
editWidget,
deleteWidget,
widget.panelTypes,
],
const dashboardResourceId = useMemo(() => {
const pathname = globalThis.window?.location?.pathname || '';
return pathname.match(/\/dashboard\/([^/]+)/)?.[1] || String(widget.id);
}, [widget.id]);
const isViewVisible = headerMenuList?.includes(MenuItemKeys.View) || false;
const isEditVisible = headerMenuList?.includes(MenuItemKeys.Edit) || false;
const isCloneVisible = headerMenuList?.includes(MenuItemKeys.Clone) || false;
const isDeleteVisible = headerMenuList?.includes(MenuItemKeys.Delete) || false;
const isCreateAlertsVisible =
headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false;
const isDownloadVisible = widget.panelTypes === PANEL_TYPES.TABLE;
const hasVisibleActions =
isViewVisible ||
isEditVisible ||
isCloneVisible ||
isDeleteVisible ||
isCreateAlertsVisible ||
isDownloadVisible;
const onWidgetActionClick = useCallback(
(key: MenuItemKeys): void => {
onMenuItemSelectHandler(key);
setIsWidgetActionsOpen(false);
},
[onMenuItemSelectHandler],
);
const updatedMenuList = useMemo(() => generateMenuList(actions), [actions]);
const [showGlobalSearch, setShowGlobalSearch] = useState(false);
const [isWidgetActionsOpen, setIsWidgetActionsOpen] = useState(false);
const globalSearchAvailable = widget.panelTypes === PANEL_TYPES.TABLE;
const menu = useMemo(
() => ({
items: updatedMenuList,
onClick: onMenuItemSelectHandler,
}),
[updatedMenuList, onMenuItemSelectHandler],
);
const { truncatedText, fullText } = useGetResolvedText({
text: widget.title as string,
maxLength: 100,
@@ -319,15 +418,41 @@ function WidgetHeader({
/>
)}
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<MoreOutlined
{hasVisibleActions && (
<Popover
open={isWidgetActionsOpen}
onOpenChange={setIsWidgetActionsOpen}
arrow={false}
rootClassName="widget-header-popover"
trigger="click"
placement="bottomRight"
content={
<WidgetActionsMenu
isViewVisible={isViewVisible}
isEditVisible={isEditVisible}
isCloneVisible={isCloneVisible}
isDownloadVisible={isDownloadVisible}
isDeleteVisible={isDeleteVisible}
isCreateAlertsVisible={isCreateAlertsVisible}
dashboardId={dashboardResourceId}
isFetching={queryResponse.isFetching}
canEdit={editWidget}
canDelete={deleteWidget}
onActionClick={onWidgetActionClick}
/>
}
>
<Button
variant="ghost"
size="icon"
data-testid="widget-header-options"
className={`widget-header-more-options ${
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
}`}
className={cn(
'widget-header-more-options',
globalSearchAvailable && 'widget-header-more-options-visible',
)}
prefixIcon={<MoreOutlined />}
/>
</Dropdown>
</Popover>
)}
</div>
</>

View File

@@ -43,7 +43,6 @@ import { Base64Icons } from 'container/DashboardContainer/DashboardSettings/Gene
import dayjs from 'dayjs';
import useDashboardsListQueryParams from 'hooks/dashboard/useDashboardsListQueryParams';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -72,7 +71,6 @@ import {
// #TODO: lucide will be removing brand icons like Github in future, in that case we can use simple icons
// see more: https://github.com/lucide-icons/lucide/issues/94
import { handleContactSupport } from 'pages/Integrations/utils';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
import {
@@ -83,6 +81,8 @@ import {
} from 'types/api/dashboard/getAll';
import APIError from 'types/api/error';
import { GuardButton } from '../../components/PermissionlessButton/PermissionlessButton';
import { buildObjectString } from '../../hooks/useAuthZ/utils';
import DashboardTemplatesModal from './DashboardTemplates/DashboardTemplatesModal';
import ImportJSON from './ImportJSON';
import { RequestDashboardBtn } from './RequestDashboardBtn';
@@ -105,7 +105,6 @@ function DashboardsList(): JSX.Element {
refetch: refetchDashboardList,
} = useGetAllDashboard();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const {
dashboardsListQueryParams,
@@ -117,10 +116,6 @@ function DashboardsList(): JSX.Element {
const [searchString, setSearchString] = useState<string>(
dashboardsListQueryParams.search || '',
);
const [action, createNewDashboard] = useComponentPermission(
['action', 'create_new_dashboards'],
user.role,
);
const [
showNewDashboardTemplatesModal,
@@ -435,78 +430,87 @@ function DashboardsList(): JSX.Element {
)}
</div>
{action && (
<Popover
trigger="click"
content={
<div className="dashboard-action-content">
<section className="section-1">
<Button
type="text"
className="action-btn"
icon={<Expand size={12} />}
onClick={onClickHandler}
>
View
</Button>
<Button
type="text"
className="action-btn"
icon={<SquareArrowOutUpRight size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
window.open(getLink(), '_blank');
}}
>
Open in New Tab
</Button>
<Button
type="text"
className="action-btn"
icon={<Link2 size={12} />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(`${window.location.origin}${getLink()}`);
}}
>
Copy Link
</Button>
<Button
type="text"
className="action-btn"
icon={<FileJson size={12} />}
onClick={handleJsonExport}
>
Export JSON
</Button>
</section>
<section className="section-2">
<DeleteButton
name={dashboard.name}
id={dashboard.id}
isLocked={dashboard.isLocked}
createdBy={dashboard.createdBy}
/>
</section>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboard-actions"
>
<EllipsisVertical
className="dashboard-action-icon"
size={14}
data-testid="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
/>
</Popover>
)}
<Popover
trigger="click"
content={
<div
className="dashboard-action-content"
onClick={(e): void => e.stopPropagation()}
>
<section className="section-1">
<GuardButton
variant="link"
prefixIcon={<Expand size={12} className="mr-2" />}
onClick={onClickHandler}
relation="read"
className="gap-2 px-2 justify-start"
object={buildObjectString('dashboard', dashboard.id)}
>
View
</GuardButton>
<GuardButton
variant="link"
relation="read"
object={buildObjectString('dashboard', dashboard.id)}
className="gap-2 px-2 justify-start"
prefixIcon={<SquareArrowOutUpRight size={12} className="mr-2" />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
window.open(getLink(), '_blank');
}}
>
Open in New Tab
</GuardButton>
<GuardButton
variant="link"
relation="read"
object={buildObjectString('dashboard', dashboard.id)}
className="gap-2 px-2 justify-start"
prefixIcon={<Link2 size={12} className="mr-2" />}
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
setCopy(`${window.location.origin}${getLink()}`);
}}
>
Copy Link
</GuardButton>
<GuardButton
variant="link"
relation="read"
object={buildObjectString('dashboard', dashboard.id)}
className="gap-2 px-2 justify-start"
prefixIcon={<FileJson size={12} className="mr-2" />}
onClick={handleJsonExport}
>
Export JSON
</GuardButton>
</section>
<section className="section-2">
<DeleteButton
name={dashboard.name}
id={dashboard.id}
isLocked={dashboard.isLocked}
createdBy={dashboard.createdBy}
/>
</section>
</div>
}
placement="bottomRight"
arrow={false}
rootClassName="dashboard-actions"
>
<EllipsisVertical
className="dashboard-action-icon"
size={14}
data-testid="dashboard-action-icon"
onClick={(e): void => {
e.stopPropagation();
e.preventDefault();
}}
/>
</Popover>
</div>
<div className="dashboard-details">
<div className="dashboard-created-at">
@@ -559,6 +563,19 @@ function DashboardsList(): JSX.Element {
const getCreateDashboardItems = useMemo(() => {
const menuItems: MenuProps['items'] = [
{
label: (
<div
className="create-dashboard-menu-item"
onClick={(): void => {
onNewDashboardHandler();
}}
>
<LayoutGrid size={14} /> Create dashboard
</div>
),
key: '0',
},
{
label: (
<div
@@ -594,24 +611,8 @@ function DashboardsList(): JSX.Element {
},
];
if (createNewDashboard) {
menuItems.unshift({
label: (
<div
className="create-dashboard-menu-item"
onClick={(): void => {
onNewDashboardHandler();
}}
>
<LayoutGrid size={14} /> Create dashboard
</div>
),
key: '0',
});
}
return menuItems;
}, [createNewDashboard, onNewDashboardHandler]);
}, [onNewDashboardHandler]);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
@@ -719,41 +720,41 @@ function DashboardsList(): JSX.Element {
</Typography.Text>
</section>
{createNewDashboard && (
<section className="actions">
<Dropdown
overlayClassName="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
placement="bottomRight"
trigger={['click']}
>
<Button
type="text"
className="new-dashboard"
icon={<Plus size={14} />}
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
New Dashboard
</Button>
</Dropdown>
<Button
type="text"
className="learn-more"
data-testid="learn-more"
<section className="actions">
<Dropdown
overlayClassName="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
placement="bottomRight"
trigger={['click']}
>
<GuardButton
relation="create"
object="dashboards"
variant="solid"
color="primary"
prefixIcon={<Plus />}
onClick={(): void => {
window.open(
'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state',
'_blank',
);
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
Learn more
</Button>
<ArrowUpRight size={16} className="learn-more-arrow" />
</section>
)}
New Dashboard
</GuardButton>
</Dropdown>
<Button
type="text"
className="learn-more"
data-testid="learn-more"
onClick={(): void => {
window.open(
'https://signoz.io/docs/userguide/manage-dashboards?utm_source=product&utm_medium=dashboard-list-empty-state',
'_blank',
);
}}
>
Learn more
</Button>
<ArrowUpRight size={16} className="learn-more-arrow" />
</section>
</div>
) : (
<>
@@ -764,25 +765,25 @@ function DashboardsList(): JSX.Element {
value={searchString}
onChange={handleSearch}
/>
{createNewDashboard && (
<Dropdown
overlayClassName="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
placement="bottomRight"
trigger={['click']}
<Dropdown
overlayClassName="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
placement="bottomRight"
trigger={['click']}
>
<GuardButton
relation="create"
object="dashboards"
variant="solid"
color="primary"
prefixIcon={<Plus />}
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
<Button
type="primary"
className="periscope-btn primary btn"
icon={<Plus size={14} />}
onClick={(): void => {
logEvent('Dashboard List: New dashboard clicked', {});
}}
>
New dashboard
</Button>
</Dropdown>
)}
New dashboard
</GuardButton>
</Dropdown>
</div>
{dashboards.length === 0 ? (

View File

@@ -1,8 +1,9 @@
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from 'react-query';
import { DeleteOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { Modal, Tooltip, Typography } from 'antd';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { Trash } from '@signozhq/icons';
import { Modal, Typography } from 'antd';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { useDeleteDashboard } from 'hooks/dashboard/useDeleteDashboard';
@@ -11,8 +12,10 @@ import history from 'lib/history';
import { useAppContext } from 'providers/App/App';
import { USER_ROLES } from 'types/roles';
import { ButtonWithTooltip } from '../../../components/ButtonWithTooltip/ButtonWithTooltip';
import { useAuthZ } from '../../../hooks/useAuthZ/useAuthZ';
import { buildPermission } from '../../../hooks/useAuthZ/utils';
import { Data } from '../DashboardsList';
import { TableLinkText } from './styles';
import './DeleteButton.styles.scss';
@@ -88,7 +91,20 @@ export function DeleteButton({
routeToListPage,
]);
const getDeleteTooltipContent = (): string => {
const canDeletePermission = buildPermission('assignee', `role:signoz-admin`);
const canDeleteProps = useAuthZ([canDeletePermission]);
const canDelete =
!isLocked && canDeleteProps.permissions?.[canDeletePermission]?.isGranted;
const tooltipMessage = useMemo(() => {
if (canDeleteProps.error) {
return canDeleteProps.error.message;
}
if (canDelete) {
return undefined;
}
if (isLocked) {
if (user.role === USER_ROLES.ADMIN || isAuthor) {
return t('dashboard:locked_dashboard_delete_tooltip_admin_author');
@@ -97,27 +113,30 @@ export function DeleteButton({
return t('dashboard:locked_dashboard_delete_tooltip_editor');
}
return '';
};
return `You don't have delete:dashboard:${id} permission.`;
}, [canDeleteProps.error, canDelete, isLocked, user, isAuthor, id, t]);
return (
<>
<Tooltip placement="left" title={getDeleteTooltipContent()}>
<TableLinkText
type="danger"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isLocked) {
openConfirmationDialog();
}
}}
className="delete-btn"
disabled={isLocked || (user.role === USER_ROLES.VIEWER && !isAuthor)}
>
<DeleteOutlined /> Delete dashboard
</TableLinkText>
</Tooltip>
<ButtonWithTooltip
variant="ghost"
color="destructive"
onClick={(e): void => {
e.preventDefault();
e.stopPropagation();
if (!isLocked) {
openConfirmationDialog();
}
}}
loading={canDeleteProps.isLoading}
className="w-full gap-2 px-2 justify-start"
disabled={!canDelete}
prefixIcon={<Trash className="mr-2" />}
tooltipTitle={tooltipMessage}
tooltipDisabled={canDeleteProps.isLoading || canDelete}
>
Delete dashboard
</ButtonWithTooltip>
{contextHolder}
</>

View File

@@ -11,6 +11,10 @@ export default {
name: 'dashboards',
type: 'metaresources',
},
{
name: 'role',
type: 'role',
},
],
relations: {
create: ['metaresources'],
@@ -18,6 +22,7 @@ export default {
list: ['metaresources'],
read: ['user', 'role', 'organization', 'metaresource'],
update: ['user', 'role', 'organization', 'metaresource'],
assignee: ['role'],
},
},
} as const;

View File

@@ -14,7 +14,7 @@ type ResourceTypeMap = {
type RelationName = keyof RelationsByType;
type ResourcesForRelation<R extends RelationName> = Extract<
export type ResourcesForRelation<R extends RelationName> = Extract<
Resource,
{ type: RelationsByType[R][number] }
>['name'];

View File

@@ -3,9 +3,9 @@ import permissionsType from './permissions.type';
import {
AuthZObject,
AuthZRelation,
AuthZResource,
BrandedPermission,
ResourceName,
ResourcesForRelation,
ResourceType,
} from './types';
@@ -19,11 +19,11 @@ export function buildPermission<R extends AuthZRelation>(
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
}
export function buildObjectString(
resource: AuthZResource,
export function buildObjectString<R extends 'delete' | 'read' | 'update'>(
resource: ResourcesForRelation<R>,
objectId: string,
): `${AuthZResource}${typeof ObjectSeparator}${string}` {
return `${resource}${ObjectSeparator}${objectId}` as const;
): AuthZObject<R> {
return `${resource}${ObjectSeparator}${objectId}` as AuthZObject<R>;
}
export function parsePermission(

6
frontend/src/lib/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

View File

@@ -764,3 +764,7 @@ notifications - 2050
.cursor-pointer {
cursor: pointer;
}
div[data-radix-popper-content-wrapper] {
z-index: 5000 !important;
}

View File

@@ -8426,16 +8426,16 @@ clone@^1.0.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==
clsx@2.1.1, clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
clsx@^1.1.1:
version "1.2.1"
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
cmdk@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-0.2.1.tgz#aa8e1332bb0b8d8484e793017c82537351188d9a"
@@ -18922,6 +18922,11 @@ table@^6.0.9:
string-width "^4.2.3"
strip-ansi "^6.0.1"
tailwind-merge@3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
tailwind-merge@^2.5.2:
version "2.6.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"