Compare commits

..

1 Commits

Author SHA1 Message Date
swapnil-signoz
1b7f1cb654 feat: adding metrics in definition and dashboard JSON 2026-07-10 16:23:30 +05:30
48 changed files with 3740 additions and 1173 deletions

View File

@@ -2,7 +2,7 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
@@ -134,17 +134,18 @@ function CreateServiceAccountModal(): JSX.Element {
Cancel
</Button>
<AuthZButton
checks={[SACreatePermission]}
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</AuthZButton>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
type="submit"
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</AuthZTooltip>
</DialogFooter>
</DialogWrapper>
);

View File

@@ -20,7 +20,6 @@ function OtherFiltersSkeleton(): JSX.Element {
<Skeleton.Input
active
size="small"
className="qf-other-filters-skeleton"
// eslint-disable-next-line react/no-array-index-key
key={index}
/>

View File

@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
@@ -109,21 +109,24 @@ function KeyFormPhase({
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<AuthZButton
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
authZEnabled={!!accountId}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
enabled={!!accountId}
>
Create Key
</AuthZButton>
<Button
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</AuthZTooltip>
</div>
</div>
</>

View File

@@ -1,7 +1,7 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
@@ -84,17 +84,20 @@ function DeleteAccountModal(): JSX.Element {
<X size={12} />
Cancel
</Button>
<AuthZButton
<AuthZTooltip
checks={[buildSADeletePermission(accountId ?? '')]}
authZEnabled={!!accountId}
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
enabled={!!accountId}
>
<Trash2 size={12} />
Delete
</AuthZButton>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Trash2 size={12} />
Delete
</Button>
</AuthZTooltip>
</div>
);

View File

@@ -7,7 +7,6 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
@@ -159,36 +158,38 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<AuthZButton
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
authZEnabled={!!accountId && !!keyItem?.id}
variant="link"
color="destructive"
onClick={onRevokeClick}
enabled={!!accountId && !!keyItem?.id}
>
<Trash2 size={12} />
Revoke Key
</AuthZButton>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<AuthZButton
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
authZEnabled={!!accountId && !!keyItem?.id}
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
enabled={!!accountId && !!keyItem?.id}
>
Save Changes
</AuthZButton>
<Button
type="submit"
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</AuthZTooltip>
</div>
</div>
</>

View File

@@ -1,13 +1,12 @@
import React, { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Pagination, Skeleton, Table, Tooltip } from 'antd';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
@@ -25,10 +24,10 @@ interface KeysTabProps {
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
canUpdate?: boolean;
accountId?: string;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
}
interface BuildColumnsParams {
@@ -114,26 +113,29 @@ function buildColumns({
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled ? 'Service account disabled' : 'Revoke Key';
return (
<Tooltip title={tooltipTitle}>
<AuthZButton
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
authZEnabled={!isDisabled && !!accountId}
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</AuthZButton>
</Tooltip>
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Tooltip title={tooltipTitle}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
</AuthZTooltip>
);
},
},
@@ -147,7 +149,6 @@ function KeysTab({
accountId = '',
currentPage,
pageSize,
onPageChange,
}: KeysTabProps): JSX.Element {
const [, setIsAddKeyOpen] = useQueryState(
'add-key',
@@ -211,18 +212,21 @@ function KeysTab({
Learn more
</a>
</p>
<AuthZButton
<AuthZTooltip
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
authZEnabled={!isDisabled && !!accountId}
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
enabled={!isDisabled && !!accountId}
>
+ Add your first key
</AuthZButton>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</AuthZTooltip>
</div>
);
}
@@ -274,24 +278,6 @@ function KeysTab({
})}
/>
<Pagination
current={currentPage}
pageSize={pageSize}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={onPageChange}
className="sa-drawer__keys-pagination"
/>
<EditKeyModal keyItem={editKey} />
<RevokeKeyModal />
@@ -299,7 +285,4 @@ function KeysTab({
);
}
export default withAuthZContent(KeysTab, {
checks: [APIKeyListPermission],
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
});
export default KeysTab;

View File

@@ -6,20 +6,15 @@ import { Input } from '@signozhq/ui/input';
import { useCopyToClipboard } from 'react-use';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { withAuthZContent } from 'lib/authz/components/withAuthZ/withAuthZContent';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import {
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { buildSAUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import SaveErrorItem from './SaveErrorItem';
import type { SaveError } from './utils';
import { Skeleton } from 'antd';
interface OverviewTabProps {
account: ServiceAccountRow;
@@ -28,6 +23,7 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
canUpdate?: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
@@ -43,6 +39,7 @@ function OverviewTab({
localRoles,
onRolesChange,
isDisabled,
canUpdate = true,
availableRoles,
rolesLoading,
rolesError,
@@ -89,22 +86,23 @@ function OverviewTab({
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled ? (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
{isDisabled || !canUpdate ? (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
enabled={!isDisabled && !canUpdate}
>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
) : (
<AuthZTooltip checks={[buildSAUpdatePermission(account.id)]}>
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
placeholder="Enter name"
/>
</AuthZTooltip>
<Input
id="sa-name"
value={localName}
onChange={(e): void => onNameChange(e.target.value)}
placeholder="Enter name"
/>
)}
</div>
@@ -222,9 +220,4 @@ function OverviewTab({
);
}
export default withAuthZContent(OverviewTab, {
checks: (props): ReturnType<typeof buildSAReadPermission>[] => [
buildSAReadPermission(props.account.id),
],
fallbackOnLoading: <Skeleton active paragraph={{ rows: 6 }} />,
});
export default OverviewTab;

View File

@@ -1,7 +1,7 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
@@ -45,20 +45,23 @@ export function RevokeKeyFooter({
<X size={12} />
Cancel
</Button>
<AuthZButton
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyId ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
authZEnabled={!!accountId && !!keyId}
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
enabled={!!accountId && !!keyId}
>
<Trash2 size={12} />
Revoke Key
</AuthZButton>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
</>
);
}
@@ -108,7 +111,7 @@ function RevokeKeyModal(): JSX.Element {
}
function handleCancel(): void {
void setRevokeKeyId(null);
setRevokeKeyId(null);
}
return (

View File

@@ -1,17 +1,11 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Key, LayoutGrid, Plus, Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { toast } from '@signozhq/ui/sonner';
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
import { Skeleton } from 'antd';
import { Pagination, Skeleton } from 'antd';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import {
getListServiceAccountsQueryKey,
@@ -22,6 +16,7 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -33,13 +28,15 @@ import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -50,6 +47,7 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -72,12 +70,14 @@ function toSaveApiError(err: unknown): APIError {
);
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function ServiceAccountDrawer({
onSuccess,
}: ServiceAccountDrawerProps): JSX.Element {
const [selectedAccountId, setSelectedAccountId] = useQueryState(
SA_QUERY_PARAMS.ACCOUNT,
);
const open = !!selectedAccountId;
const [activeTab, setActiveTab] = useQueryState(
SA_QUERY_PARAMS.TAB,
parseAsStringEnum<ServiceAccountDrawerTab>(
@@ -100,14 +100,28 @@ function ServiceAccountDrawer({
SA_QUERY_PARAMS.DELETE_SA,
parseAsBoolean.withDefault(false),
);
const [localName, setLocalName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const queryClient = useQueryClient();
const open = !!selectedAccountId;
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
selectedAccountId
? [
buildSAReadPermission(selectedAccountId),
buildSAUpdatePermission(selectedAccountId),
buildSADeletePermission(selectedAccountId),
APIKeyListPermission,
]
: [],
{ enabled: !!selectedAccountId },
);
const canRead =
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
?.isGranted ?? false;
const {
data: accountData,
@@ -117,7 +131,7 @@ function ServiceAccountDrawer({
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
{ query: { enabled: canRead && !!selectedAccountId } },
);
const account = useMemo(
@@ -131,7 +145,7 @@ function ServiceAccountDrawer({
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
enabled: !!selectedAccountId,
enabled: canRead && !!selectedAccountId,
});
const roleSessionRef = useRef<string | null>(null);
@@ -180,9 +194,16 @@ function ServiceAccountDrawer({
refetch: refetchRoles,
} = useRoles();
const canListKeys =
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
const canUpdate =
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
?.isGranted ?? true;
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId } },
{ query: { enabled: !!selectedAccountId && canListKeys } },
);
const keys = keysData?.data ?? [];
@@ -196,6 +217,7 @@ function ServiceAccountDrawer({
}
}, [keysLoading, keys.length, keysPage, setKeysPage]);
// the retry for this mutation is safe due to the api being idempotent on backend
const { mutateAsync: updateMutateAsync } = useUpdateServiceAccount();
const retryNameUpdate = useCallback(async (): Promise<void> => {
@@ -353,71 +375,23 @@ function ServiceAccountDrawer({
]);
const handleClose = useCallback((): void => {
setSaveErrors([]);
void setIsDeleteOpen(null);
void setIsAddKeyOpen(null);
void setSelectedAccountId(null);
void setActiveTab(null);
void setKeysPage(null);
void setEditKeyId(null);
void setIsAddKeyOpen(null);
void setIsDeleteOpen(null);
void setSelectedAccountId(null);
setSaveErrors([]);
}, [
setSelectedAccountId,
setActiveTab,
setKeysPage,
setEditKeyId,
setIsAddKeyOpen,
setIsDeleteOpen,
setSelectedAccountId,
]);
const footer = useMemo(
() =>
activeTab === ServiceAccountDrawerTab.Overview && !isDeleted && open ? (
<div className="sa-drawer__footer">
<AuthZButton
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</AuthZButton>
<div className="sa-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<AuthZButton
checks={[buildSAUpdatePermission(selectedAccountId ?? '')]}
authZEnabled={!!selectedAccountId}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</AuthZButton>
</div>
</div>
) : null,
[
activeTab,
isDeleted,
open,
selectedAccountId,
isSaving,
isDirty,
handleClose,
handleSave,
setIsDeleteOpen,
],
);
const body = (
const drawerContent = (
<div className="sa-drawer__layout">
<div className="sa-drawer__tabs">
<ToggleGroupSimple
@@ -459,23 +433,26 @@ function ServiceAccountDrawer({
]}
/>
{activeTab === ServiceAccountDrawerTab.Keys && (
<AuthZButton
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(selectedAccountId ?? ''),
]}
authZEnabled={!isDeleted && !!selectedAccountId}
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
enabled={!isDeleted && !!selectedAccountId}
>
<Plus size={12} />
Add Key
</AuthZButton>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</AuthZTooltip>
)}
</div>
@@ -484,7 +461,9 @@ function ServiceAccountDrawer({
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{(isAuthZLoading || isAccountLoading) && (
<Skeleton active paragraph={{ rows: 6 }} />
)}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
@@ -493,73 +472,141 @@ function ServiceAccountDrawer({
)}
/>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(account ? (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
) : (
<Skeleton active />
))}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
accountId={selectedAccountId ?? ''}
currentPage={keysPage}
pageSize={PAGE_SIZE}
onPageChange={(page): void => {
void setKeysPage(page);
}}
/>
)}
</>
)}
{!isAuthZLoading &&
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<>
{activeTab === ServiceAccountDrawerTab.Overview &&
(canRead && account ? (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
canUpdate={canUpdate}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
) : (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
))}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
)}
</div>
</div>
);
return (
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
showCloseButton
showOverlay={false}
title="Service Account Details"
className="sa-drawer"
width="wide"
footer={footer}
>
{open && (
const footer = (
<div className="sa-drawer__footer">
{activeTab === ServiceAccountDrawerTab.Keys ? (
<Pagination
current={keysPage}
pageSize={PAGE_SIZE}
total={keys.length}
showTotal={(total: number, range: number[]): JSX.Element => (
<>
<span className="sa-drawer__pagination-range">
{range[0]} &#8212; {range[1]}
</span>
<span className="sa-drawer__pagination-total"> of {total}</span>
</>
)}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => {
void setKeysPage(page);
}}
className="sa-drawer__keys-pagination"
/>
) : (
<>
{body}
<DeleteAccountModal />
<AddKeyModal />
{!isDeleted && (
<AuthZTooltip
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</AuthZTooltip>
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">
<Button variant="outlined" color="secondary" onClick={handleClose}>
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</div>
)}
</>
)}
</DrawerWrapper>
</div>
);
return (
<>
<DrawerWrapper
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
handleClose();
}
}}
direction="right"
showCloseButton
showOverlay={false}
title="Service Account Details"
className="sa-drawer"
width="wide"
footer={footer}
>
{drawerContent}
</DrawerWrapper>
<DeleteAccountModal />
<AddKeyModal />
</>
);
}

View File

@@ -1,5 +1,4 @@
import { toast } from '@signozhq/ui/sonner';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import {
@@ -60,7 +59,6 @@ describe('AddKeyModal', () => {
rest.post(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(201), ctx.json(createdKeyResponse)),
),
setupAuthzAdmin(),
);
});

View File

@@ -1,6 +1,5 @@
import { toast } from '@signozhq/ui/sonner';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -62,7 +61,6 @@ describe('EditKeyModal (URL-controlled)', () => {
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});

View File

@@ -1,6 +1,5 @@
import { toast } from '@signozhq/ui/sonner';
import { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
@@ -36,7 +35,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
{
id: 'key-2',
name: 'Staging Key',
expiresAt: 1924948800, // 2030-12-31 12:00 UTC (noon to avoid timezone issues)
expiresAt: 1924905600, // 2030-12-31
lastObservedAt: '2026-03-10T10:00:00Z',
serviceAccountId: 'sa-1',
},
@@ -48,7 +47,6 @@ const defaultProps = {
isDisabled: false,
currentPage: 1,
pageSize: 10,
onPageChange: jest.fn(),
};
function renderKeysTab(
@@ -69,7 +67,6 @@ describe('KeysTab', () => {
rest.delete(SA_KEY_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -77,12 +74,9 @@ describe('KeysTab', () => {
server.resetHandlers();
});
it('renders loading state', async () => {
it('renders loading state', () => {
renderKeysTab({ isLoading: true });
// Wait for authz to complete, then check for skeleton
await waitFor(() => {
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('renders empty state when no keys and clicking add sets add-key param', async () => {
@@ -97,9 +91,9 @@ describe('KeysTab', () => {
</NuqsTestingAdapter>,
);
await expect(
screen.findByText(/No keys. Start by creating one./i),
).resolves.toBeInTheDocument();
expect(
screen.getByText(/No keys. Start by creating one./i),
).toBeInTheDocument();
const addBtn = screen.getByRole('button', { name: /\+ Add your first key/i });
await user.click(addBtn);
expect(onUrlUpdate).toHaveBeenCalledWith(
@@ -109,12 +103,10 @@ describe('KeysTab', () => {
);
});
it('renders table with keys', async () => {
it('renders table with keys', () => {
renderKeysTab();
await expect(
screen.findByText('Production Key'),
).resolves.toBeInTheDocument();
expect(screen.getByText('Production Key')).toBeInTheDocument();
expect(screen.getByText('Staging Key')).toBeInTheDocument();
expect(screen.getByText('Never')).toBeInTheDocument();
expect(screen.getByText('Dec 31, 2030')).toBeInTheDocument();
@@ -130,7 +122,7 @@ describe('KeysTab', () => {
</NuqsTestingAdapter>,
);
const row = (await screen.findByText('Production Key')).closest('tr');
const row = screen.getByText('Production Key').closest('tr');
if (!row) {
throw new Error('Row not found');
}
@@ -154,8 +146,6 @@ describe('KeysTab', () => {
</NuqsTestingAdapter>,
);
// Wait for authz to complete and table to render
await screen.findByText('Production Key');
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
@@ -173,8 +163,7 @@ describe('KeysTab', () => {
renderKeysTab();
// Wait for authz to complete and table to render
await screen.findByText('Production Key');
// Seed the keys cache so RevokeKeyModal can read the key name
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));
@@ -188,11 +177,9 @@ describe('KeysTab', () => {
});
});
it('disables actions when isDisabled is true', async () => {
it('disables actions when isDisabled is true', () => {
renderKeysTab({ isDisabled: true });
// Wait for authz to complete and table to render
await screen.findByText('Production Key');
const revokeBtns = screen
.getAllByRole('button')
.filter((btn) => btn.className.includes('keys-tab__revoke-btn'));

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
@@ -31,6 +32,30 @@ const activeAccountResponse = {
updatedAt: '2026-01-02T00:00:00Z',
};
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
@@ -93,7 +118,7 @@ describe('ServiceAccountDrawer — permissions', () => {
renderDrawer();
await waitFor(() => {
expect(screen.getByText(/read:serviceaccount/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
});
});
@@ -115,7 +140,7 @@ describe('ServiceAccountDrawer — permissions', () => {
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
await waitFor(() => {
expect(screen.getByText(/list:factor-api-key/)).toBeInTheDocument();
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
});
});

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
@@ -6,6 +7,30 @@ import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';

View File

@@ -33,8 +33,7 @@
color: var(--foreground);
letter-spacing: -0.07px;
line-height: var(--paragraph-base-400-line-height);
margin-bottom: var(--spacing-8);
margin-top: 0;
margin: 0;
}
&__controls {

View File

@@ -7,48 +7,19 @@ import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import APIError from 'types/api/error';
import PermissionEditor from './components/PermissionEditor';
import { useCreateEditRolePageActions } from './useCreateEditRolePageActions';
import { useNavigationBlocker } from 'hooks/useNavigationBlocker';
import styles from './CreateEditRolePage.module.scss';
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
function authzCheckFn(
_props: object,
router: RouterContext,
): BrandedPermission[] {
const match = router.matchPath<{ roleId: string }>(ROUTES.ROLE_DETAILS);
const roleId = match?.roleId ?? 'new';
const roleName = router.searchParams.get('name') ?? '';
const isCreateMode = roleId === 'new';
if (isCreateMode) {
return [RoleCreatePermission];
}
if (roleName) {
return [
buildRoleReadPermission(roleName),
buildRoleUpdatePermission(roleName),
];
}
return [];
}
function CreateEditRolePageContent(): JSX.Element {
function CreateEditRolePage(): JSX.Element {
const history = useHistory();
const { pathname } = useLocation();
const urlQuery = useUrlQuery();
@@ -76,6 +47,9 @@ function CreateEditRolePageContent(): JSX.Element {
saveError,
validationErrors,
isCreateMode,
hasRequiredPermission,
isAuthZLoading,
deniedPermission,
loadError,
} = useCreateEditRolePageActions(roleId, roleName);
@@ -107,6 +81,10 @@ function CreateEditRolePageContent(): JSX.Element {
roleName,
]);
if (!hasRequiredPermission && !isAuthZLoading) {
return <PermissionDeniedFullPage permissionName={deniedPermission} />;
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div
@@ -149,7 +127,7 @@ function CreateEditRolePageContent(): JSX.Element {
);
}
if ((isLoading && !isCreateMode) || isFeatureGateLoading) {
if (isAuthZLoading || (isLoading && !isCreateMode) || isFeatureGateLoading) {
return (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -217,12 +195,7 @@ function CreateEditRolePageContent(): JSX.Element {
</Typography>
</div>
)}
<AuthZButton
checks={
isCreateMode
? [RoleCreatePermission]
: [buildRoleUpdatePermission(roleName)]
}
<Button
variant="solid"
color="primary"
onClick={handleSaveAndNavigate}
@@ -231,7 +204,7 @@ function CreateEditRolePageContent(): JSX.Element {
data-testid="save-button"
>
{isCreateMode ? 'Create role' : 'Save changes'}
</AuthZButton>
</Button>
</div>
</div>
@@ -317,11 +290,4 @@ function CreateEditRolePageContent(): JSX.Element {
);
}
export default withAuthZPage(CreateEditRolePageContent, {
checks: authzCheckFn,
fallbackOnLoading: (
<div className={styles.createEditRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});
export default CreateEditRolePage;

View File

@@ -1,21 +1,24 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderCreatePage(
@@ -68,9 +71,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -79,9 +80,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
it('shows error when license is invalid', async () => {
renderCreatePage({ activeLicense: invalidLicense });
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -93,19 +92,16 @@ describe('CreateEditRolePage - Feature Gate', () => {
await expect(screen.findByText('Create Role')).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', async () => {
it('shows back button when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', async () => {
it('back button is enabled when feature disabled', () => {
renderCreatePage({ activeLicense: invalidLicense });
const cancelButton = await screen.findByTestId('cancel-button');
expect(cancelButton).not.toBeDisabled();
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
@@ -122,9 +118,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
),
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -133,9 +127,7 @@ describe('CreateEditRolePage - Feature Gate', () => {
it('shows error when license is invalid', async () => {
renderEditPage(ROLE_ID, ROLE_NAME, { activeLicense: invalidLicense });
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();

View File

@@ -1,17 +1,16 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
} from 'lib/authz/utils/authz-test-utils';
import { mockUseAuthZDenyAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderCreatePage(): ReturnType<typeof render> {
@@ -32,7 +31,7 @@ function renderCreatePage(): ReturnType<typeof render> {
describe('CreateRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when create permission denied', async () => {
server.use(setupAuthzDenyAll());
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderCreatePage();
@@ -44,31 +43,19 @@ describe('CreateRolePage - AuthZ', () => {
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
server.use(
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
),
),
);
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});
renderCreatePage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders create page when create permission granted', async () => {
server.use(setupAuthzAdmin());
renderCreatePage();
await expect(
screen.findByTestId('role-name-input'),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -3,22 +3,27 @@ import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
async function renderCreatePage(): Promise<ReturnType<typeof render>> {
const result = render(
function renderCreatePage(): ReturnType<typeof render> {
return render(
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
<div data-testid="roles-list-redirect" />
@@ -30,63 +35,61 @@ async function renderCreatePage(): Promise<ReturnType<typeof render>> {
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('create-edit-role-page');
return result;
}
describe('CreateRolePage', () => {
describe('initial render', () => {
it('renders create role page with testId', async () => {
await renderCreatePage();
it('renders create role page with testId', () => {
renderCreatePage();
expect(screen.getByTestId('create-edit-role-page')).toBeInTheDocument();
});
it('shows breadcrumb with "Create role" as current page', async () => {
await renderCreatePage();
it('shows breadcrumb with "Create role" as current page', () => {
renderCreatePage();
const page = screen.getByTestId('create-edit-role-page');
const breadcrumbs = within(page).getAllByText('Create role');
expect(breadcrumbs.length).toBeGreaterThanOrEqual(1);
});
it('renders empty name input', async () => {
await renderCreatePage();
it('renders empty name input', () => {
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).toHaveValue('');
});
it('renders empty description input', async () => {
await renderCreatePage();
it('renders empty description input', () => {
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
expect(descInput).toHaveValue('');
});
it('name input is enabled in create mode', async () => {
await renderCreatePage();
it('name input is enabled in create mode', () => {
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
expect(nameInput).not.toBeDisabled();
});
it('save button shows "Create role" text', async () => {
await renderCreatePage();
it('save button shows "Create role" text', () => {
renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toHaveTextContent('Create role');
});
it('save button is disabled when no changes', async () => {
await renderCreatePage();
it('save button is disabled when no changes', () => {
renderCreatePage();
const saveBtn = screen.getByTestId('save-button');
expect(saveBtn).toBeDisabled();
});
it('does not show unsaved indicator initially', async () => {
await renderCreatePage();
it('does not show unsaved indicator initially', () => {
renderCreatePage();
expect(screen.queryByText('Unsaved changes')).not.toBeInTheDocument();
});
@@ -95,7 +98,7 @@ describe('CreateRolePage', () => {
describe('form interactions', () => {
it('enables save button when name is entered', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'test-role');
@@ -106,7 +109,7 @@ describe('CreateRolePage', () => {
it('shows unsaved indicator when form modified', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-role');
@@ -118,7 +121,7 @@ describe('CreateRolePage', () => {
it('enables save button when description is entered', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Some description');
@@ -131,7 +134,7 @@ describe('CreateRolePage', () => {
describe('cancel action', () => {
it('navigates to roles list on cancel', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
@@ -160,7 +163,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'my-custom-role');
@@ -197,7 +200,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -215,7 +218,7 @@ describe('CreateRolePage', () => {
it('shows error banner with "Role name is required" when saving with empty name', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -234,7 +237,7 @@ describe('CreateRolePage', () => {
it('clears error banner when user starts typing in name field', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const descInput = screen.getByTestId('role-description-input');
await user.type(descInput, 'Description only');
@@ -267,7 +270,7 @@ describe('CreateRolePage', () => {
);
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'duplicate-role');
@@ -288,7 +291,7 @@ describe('CreateRolePage', () => {
describe('validation errors', () => {
it('shows validation error when Only Selected has no items', async () => {
const user = userEvent.setup();
await renderCreatePage();
renderCreatePage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -1,43 +1,22 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { render, screen } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzDeny,
mockUseAuthZDenyAll,
mockUseAuthZGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { buildRoleUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const EDIT_ROLE_ID = 'test-role-123';
const EDIT_ROLE_NAME = 'test-role';
const rolesApiBase = '*/api/v1/roles';
beforeEach(() => {
server.use(
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
id: EDIT_ROLE_ID,
name: EDIT_ROLE_NAME,
description: 'Test role description',
type: 'custom',
transactionGroups: [],
},
}),
),
),
);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderEditPage(): ReturnType<typeof render> {
@@ -58,7 +37,7 @@ function renderEditPage(): ReturnType<typeof render> {
describe('EditRolePage - AuthZ', () => {
describe('permission denied', () => {
it('shows PermissionDeniedFullPage when read permission denied', async () => {
server.use(setupAuthzDenyAll());
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
@@ -68,7 +47,7 @@ describe('EditRolePage - AuthZ', () => {
});
it('shows PermissionDeniedFullPage when update permission denied but read granted', async () => {
server.use(setupAuthzDeny(buildRoleUpdatePermission(EDIT_ROLE_NAME)));
mockUseAuthZ.mockImplementation(mockUseAuthZGrantByPrefix('read'));
renderEditPage();
@@ -76,35 +55,36 @@ describe('EditRolePage - AuthZ', () => {
screen.findByText(/You are not authorized/i),
).resolves.toBeInTheDocument();
});
it('checks both read and update permissions for edit mode', () => {
mockUseAuthZ.mockImplementation(mockUseAuthZDenyAll);
renderEditPage();
expect(mockUseAuthZ).toHaveBeenCalledWith(
expect.arrayContaining([
expect.stringContaining('read'),
expect.stringContaining('update'),
]),
);
});
});
describe('loading state', () => {
it('shows skeleton while checking permissions', async () => {
server.use(
rest.post('*/api/v1/authz/check', (_req, res, ctx) =>
res(
ctx.delay(200),
ctx.status(200),
ctx.json({ data: [], status: 'success' }),
),
),
);
it('shows skeleton while checking permissions', () => {
mockUseAuthZ.mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});
renderEditPage();
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
});
describe('permission granted', () => {
it('renders edit page when both read and update permissions granted', async () => {
server.use(setupAuthzAdmin());
renderEditPage();
await expect(
screen.findByText(`Role - ${EDIT_ROLE_NAME}`),
).resolves.toBeInTheDocument();
});
});
});

View File

@@ -4,10 +4,14 @@ import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
const rolesApiBase = '*/api/v1/roles';
@@ -28,8 +32,8 @@ const roleWithTransactionGroups = {
};
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
setupAuthzAdmin(),
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(roleWithTransactionGroups)),
),
@@ -37,6 +41,7 @@ beforeEach(() => {
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
@@ -111,9 +116,9 @@ describe('EditRolePage', () => {
renderEditPage();
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});
it('navigates to view page when cancel clicked in error state', async () => {
@@ -127,8 +132,10 @@ describe('EditRolePage', () => {
renderEditPage();
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
await waitFor(async () => {
const cancelButton = await screen.findByTestId('cancel-button');
await user.click(cancelButton);
});
await expect(
screen.findByTestId('role-details-redirect'),

View File

@@ -1,18 +1,21 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import CreateEditRolePage from '../CreateEditRolePage';
import { TooltipProvider } from '@signozhq/ui/tooltip';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
function renderPage(): ReturnType<typeof render> {
@@ -34,13 +37,13 @@ function renderPage(): ReturnType<typeof render> {
async function switchToJsonMode(): Promise<void> {
const user = userEvent.setup();
const jsonRadio = await screen.findByTestId('permission-editor-mode-json');
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
}
async function switchToInteractiveMode(): Promise<void> {
const user = userEvent.setup();
const interactiveRadio = await screen.findByTestId(
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
);
await user.click(interactiveRadio);

View File

@@ -1,28 +1,31 @@
import { Route, Switch } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CreateEditRolePage from '../CreateEditRolePage';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
const expandButton = await screen.findByTestId('expand-all-button');
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
beforeEach(() => {
server.use(setupAuthzAdmin());
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
async function renderPage(): Promise<ReturnType<typeof render>> {
const result = render(
function renderPage(): ReturnType<typeof render> {
return render(
<TooltipProvider>
<Switch>
<Route path={ROUTES.ROLES_SETTINGS} exact>
@@ -36,20 +39,18 @@ async function renderPage(): Promise<ReturnType<typeof render>> {
undefined,
{ initialRoute: '/settings/roles/new' },
);
await screen.findByTestId('permission-editor');
return result;
}
describe('PermissionEditor', () => {
describe('mode toggle', () => {
it('renders permission editor with testId', async () => {
await renderPage();
it('renders permission editor with testId', () => {
renderPage();
expect(screen.getByTestId('permission-editor')).toBeInTheDocument();
});
it('defaults to interactive mode', async () => {
await renderPage();
it('defaults to interactive mode', () => {
renderPage();
const interactiveRadio = screen.getByTestId(
'permission-editor-mode-interactive',
@@ -59,7 +60,7 @@ describe('PermissionEditor', () => {
it('switches to JSON mode when clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -70,7 +71,7 @@ describe('PermissionEditor', () => {
it('switches back to interactive mode', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const jsonRadio = screen.getByTestId('permission-editor-mode-json');
await user.click(jsonRadio);
@@ -86,8 +87,8 @@ describe('PermissionEditor', () => {
});
describe('resource cards', () => {
it('renders all resource cards', async () => {
await renderPage();
it('renders all resource cards', () => {
renderPage();
expect(
screen.getByTestId('resource-card-factor-api-key'),
@@ -98,8 +99,8 @@ describe('PermissionEditor', () => {
).toBeInTheDocument();
});
it('resource cards are collapsed by default', async () => {
await renderPage();
it('resource cards are collapsed by default', () => {
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -111,7 +112,7 @@ describe('PermissionEditor', () => {
it('expands resource card when header clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -125,7 +126,7 @@ describe('PermissionEditor', () => {
it('collapses expanded resource card when header clicked again', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
const header = within(apiKeyCard).getByTestId(
@@ -139,7 +140,7 @@ describe('PermissionEditor', () => {
});
it('shows granted count in resource card header', async () => {
await renderPage();
renderPage();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
await expect(
@@ -150,7 +151,7 @@ describe('PermissionEditor', () => {
describe('action toggles', () => {
it('renders action toggles for each available action', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -169,7 +170,7 @@ describe('PermissionEditor', () => {
});
it('defaults all actions to None scope', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -187,7 +188,7 @@ describe('PermissionEditor', () => {
it('changes scope to All when clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -208,7 +209,7 @@ describe('PermissionEditor', () => {
it('updates granted count when scope changed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -227,7 +228,7 @@ describe('PermissionEditor', () => {
describe('Only Selected scope', () => {
it('shows item input selector when Only Selected is chosen', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -244,7 +245,7 @@ describe('PermissionEditor', () => {
it('adds item when typed and Enter pressed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -262,7 +263,7 @@ describe('PermissionEditor', () => {
it('adds item when Add button clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -283,7 +284,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by comma', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -303,7 +304,7 @@ describe('PermissionEditor', () => {
it('adds multiple items separated by space', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -323,7 +324,7 @@ describe('PermissionEditor', () => {
it('does not add duplicate items', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -343,7 +344,7 @@ describe('PermissionEditor', () => {
it('removes item when X clicked', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -366,7 +367,7 @@ describe('PermissionEditor', () => {
it('shows Add button disabled when input is empty', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -384,7 +385,7 @@ describe('PermissionEditor', () => {
describe('scope change confirmation dialog', () => {
it('shows confirm dialog when leaving Only Selected with items', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -406,7 +407,7 @@ describe('PermissionEditor', () => {
it('clears items when confirmed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -433,7 +434,7 @@ describe('PermissionEditor', () => {
it('keeps items when cancelled', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -460,7 +461,7 @@ describe('PermissionEditor', () => {
it('does not show dialog when leaving Only Selected with no items', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -479,7 +480,7 @@ describe('PermissionEditor', () => {
describe('verbs without Only Selected option', () => {
it('does not show Only Selected for list verb', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -500,8 +501,8 @@ describe('PermissionEditor', () => {
});
describe('collapse/expand all resources', () => {
it('shows expand/collapse toggle group', async () => {
await renderPage();
it('shows expand/collapse toggle group', () => {
renderPage();
expect(screen.getByTestId('toggle-all-group')).toBeInTheDocument();
expect(screen.getByTestId('expand-all-button')).toBeInTheDocument();
@@ -509,7 +510,7 @@ describe('PermissionEditor', () => {
});
it('expands all cards when expand button clicked', async () => {
await renderPage();
renderPage();
await expandAllCards();
const apiKeyCard = screen.getByTestId('resource-card-factor-api-key');
@@ -523,7 +524,7 @@ describe('PermissionEditor', () => {
describe('resource card error states', () => {
it('shows error border on collapsed card with validation error', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -553,7 +554,7 @@ describe('PermissionEditor', () => {
it('hides error border when card is expanded', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');
@@ -590,7 +591,7 @@ describe('PermissionEditor', () => {
it('clears validation error when permission is changed', async () => {
const user = userEvent.setup();
await renderPage();
renderPage();
const nameInput = screen.getByTestId('role-name-input');
await user.type(nameInput, 'valid-role');

View File

@@ -16,6 +16,7 @@ import {
useRolePermissions,
useUpdateRolePermissions,
} from '../hooks/useRolePermissions';
import { useRoleAuthZ } from '../hooks/useRoleAuthZ';
import {
useRoleUnsavedChanges,
type RoleFormData,
@@ -42,6 +43,9 @@ interface UseCreateEditRolePageCallbacksResult {
saveError: APIError | null;
clearSaveError: () => void;
validationErrors: Set<string>;
hasRequiredPermission: boolean;
isAuthZLoading: boolean;
deniedPermission: string;
}
export function useCreateEditRolePageActions(
@@ -51,6 +55,23 @@ export function useCreateEditRolePageActions(
const history = useHistory();
const isCreateMode = roleId === 'new';
const {
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const deniedPermission = useMemo(() => {
if (isCreateMode) {
return 'role:create';
}
if (roleName) {
return `role:${roleName}:update`;
}
return `role:<missing-rule-name>:update`;
}, [isCreateMode, roleName]);
const [formData, setFormData] = useState<RoleFormData>({
name: '',
description: '',
@@ -240,5 +261,10 @@ export function useCreateEditRolePageActions(
saveError,
clearSaveError,
validationErrors,
hasRequiredPermission: isCreateMode
? hasCreatePermission
: hasReadPermission && hasUpdatePermission,
isAuthZLoading,
deniedPermission,
};
}

View File

@@ -13,6 +13,7 @@ import APIError from 'types/api/error';
interface UseDeleteRoleModalProps {
roleId?: string | null;
isManaged: boolean;
hasDeletePermission: boolean;
onDeleteSuccess?: () => void;
}
@@ -30,7 +31,7 @@ interface UseDeleteRoleModalResult {
export function useDeleteRoleModal(
props: UseDeleteRoleModalProps,
): UseDeleteRoleModalResult {
const { roleId, isManaged, onDeleteSuccess } = props;
const { roleId, isManaged, hasDeletePermission, onDeleteSuccess } = props;
const queryClient = useQueryClient();
const [deleteTargetRoleId, setDeleteTargetRoleId] = useState<string | null>(
@@ -78,8 +79,10 @@ export function useDeleteRoleModal(
const isDeleteModalOpen = deleteTargetRoleId !== null;
const isDeleteDisabled = isManaged;
const deleteDisabledReason = 'Managed roles cannot be deleted';
const isDeleteDisabled = isManaged || !hasDeletePermission;
const deleteDisabledReason = isManaged
? 'Managed roles cannot be deleted'
: 'You do not have permission to delete this role';
return {
isDeleteModalOpen,

View File

@@ -5,11 +5,12 @@ import { Pagination, Skeleton } from 'antd';
import { useListRoles } from 'api/generated/services/role';
import { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import ROUTES from 'constants/routes';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import useUrlQuery from 'hooks/useUrlQuery';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { RoleType } from 'types/roles';
@@ -23,14 +24,23 @@ type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: AuthtypesRoleDTO };
interface RolesListContentProps {
interface RolesListingTableProps {
searchQuery: string;
}
function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { isRolesEnabled } = useRolesFeatureGate();
const { data, isLoading, isError, error } = useListRoles();
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
RoleListPermission,
]);
const hasListPermission = listPerms?.[RoleListPermission]?.isGranted ?? false;
const { data, isLoading, isError, error } = useListRoles({
query: { enabled: hasListPermission },
});
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
@@ -145,7 +155,11 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
</>
);
if (isLoading) {
if (!hasListPermission && listPerms !== null) {
return <PermissionDeniedFullPage permissionName="role:list" />;
}
if (isAuthZLoading || isLoading) {
return (
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
@@ -267,11 +281,4 @@ function RolesListContent({ searchQuery }: RolesListContentProps): JSX.Element {
);
}
export default withAuthZPage<RolesListContentProps>(RolesListContent, {
checks: [RoleListPermission],
fallbackOnLoading: (
<div className={styles.rolesListingTable}>
<Skeleton active paragraph={{ rows: 5 }} />
</div>
),
});
export default RolesListingTable;

View File

@@ -1,14 +1,11 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import ROUTES from 'constants/routes';
import {
RoleCreatePermission,
RoleListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import RolesListingTable from './RolesComponents/RolesListingTable';
@@ -40,25 +37,24 @@ function RolesSettings(): JSX.Element {
</div>
<div className={styles.rolesSettingsContent}>
<div className={styles.rolesSettingsToolbar}>
<AuthZTooltip checks={[RoleListPermission]}>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
</AuthZTooltip>
<Input
type="search"
placeholder="Search for roles..."
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
/>
{isRolesEnabled && (
<AuthZButton
checks={[RoleCreatePermission]}
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
>
<Plus size={14} />
Custom role
</AuthZButton>
<AuthZTooltip checks={[RoleCreatePermission]}>
<Button
variant="solid"
color="primary"
className={styles.roleSettingsToolbarButton}
onClick={(): void => history.push(ROUTES.ROLE_CREATE)}
>
<Plus size={14} />
Custom role
</Button>
</AuthZTooltip>
)}
</div>
<RolesListingTable searchQuery={searchQuery} />

View File

@@ -10,17 +10,11 @@ import { Typography } from '@signozhq/ui/typography';
import { Skeleton } from 'antd';
import { useGetRole } from 'api/generated/services/role';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
import { RouterContext } from 'lib/authz/components/withAuthZ/withAuthZ';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
import { RoleType } from 'types/roles';
@@ -33,7 +27,7 @@ import { useViewRolePageActions } from './useViewRolePageActions';
import styles from './ViewRolePage.module.scss';
function ViewRolePageContent(): JSX.Element {
function ViewRolePage(): JSX.Element {
const { formatTimezoneAdjustedTimestampOptional } = useTimezone();
const { isRolesEnabled, isLoading: isFeatureGateLoading } =
useRolesFeatureGate();
@@ -51,15 +45,26 @@ function ViewRolePageContent(): JSX.Element {
handleTabChange,
} = useViewRolePageActions();
const {
hasReadPermission,
readRolePermission,
hasUpdatePermission,
updateRolePermission,
hasDeletePermission,
isAuthZLoading,
} = useRoleAuthZ(roleName);
const { data, isLoading, error } = useGetRole(
{ id: roleId ?? '' },
{ query: { enabled: !!roleId } },
{ query: { enabled: !!roleId && hasReadPermission } },
);
const role = data?.data;
const isManaged = role?.type === RoleType.MANAGED;
const {
isDeleteModalOpen,
isDeleteDisabled,
deleteDisabledReason,
deleteError,
handleOpenDeleteModal,
handleCloseDeleteModal,
@@ -67,6 +72,7 @@ function ViewRolePageContent(): JSX.Element {
} = useDeleteRoleModal({
roleId,
isManaged: isManaged ?? false,
hasDeletePermission,
onDeleteSuccess: handleCancel,
});
@@ -138,6 +144,12 @@ function ViewRolePageContent(): JSX.Element {
],
);
if (!hasReadPermission && !isAuthZLoading) {
return (
<PermissionDeniedFullPage permissionName={readRolePermission.object} />
);
}
if (!isRolesEnabled && !isFeatureGateLoading) {
return (
<div className={styles.viewRolePage} data-testid="view-role-page">
@@ -175,7 +187,7 @@ function ViewRolePageContent(): JSX.Element {
);
}
if (isLoading || isFeatureGateLoading) {
if (isAuthZLoading || isLoading || isFeatureGateLoading) {
return (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
@@ -232,55 +244,47 @@ function ViewRolePageContent(): JSX.Element {
</div>
<div className={styles.viewRolePageActions}>
{isManaged ? (
<TooltipSimple title="Managed roles cannot be deleted">
<Button
variant="link"
color="destructive"
disabled
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleDeletePermission(roleName)]}
<TooltipSimple
title={isDeleteDisabled ? deleteDisabledReason : 'Open delete modal'}
>
<Button
variant="link"
color="destructive"
onClick={handleOpenDeleteModal}
disabled={isDeleteDisabled}
data-testid="delete-button"
className={styles.deleteButton}
>
Delete
</AuthZButton>
)}
</Button>
</TooltipSimple>
<Divider type="vertical" />
{isManaged ? (
<TooltipSimple title="Managed roles cannot be updated">
<Button
variant="solid"
color="primary"
disabled
data-testid="save-button"
>
Update
</Button>
</TooltipSimple>
) : (
<AuthZButton
checks={[buildRoleUpdatePermission(roleName)]}
<TooltipSimple
title={
isManaged
? 'Managed roles cannot be updated'
: hasUpdatePermission
? 'Open update page'
: `You are not authorized to perform ${updateRolePermission.object}`
}
>
<Button
variant="solid"
color="primary"
data-testid="save-button"
disabled={isManaged || !hasUpdatePermission}
onClick={handleRedirectToUpdate}
style={
isManaged || !hasUpdatePermission
? { pointerEvents: 'auto' }
: undefined
}
>
Update
</AuthZButton>
)}
</Button>
</TooltipSimple>
</div>
</div>
@@ -332,14 +336,4 @@ function ViewRolePageContent(): JSX.Element {
);
}
export default withAuthZPage(ViewRolePageContent, {
checks: (_props: object, router: RouterContext) => {
const roleName = router.searchParams.get('name') ?? '';
return roleName ? [buildRoleReadPermission(roleName)] : [];
},
fallbackOnLoading: (
<div className={styles.viewRolePage}>
<Skeleton active paragraph={{ rows: 8 }} />
</div>
),
});
export default ViewRolePage;

View File

@@ -37,7 +37,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelBtn = await screen.findByTestId('cancel-button');
const cancelBtn = screen.getByTestId('cancel-button');
await user.click(cancelBtn);
await expect(
@@ -61,10 +61,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const updateBtn = await screen.findByTestId('save-button');
await waitFor(() => {
expect(updateBtn).not.toBeDisabled();
});
const updateBtn = screen.getByTestId('save-button');
await user.click(updateBtn);
await expect(
@@ -79,10 +76,7 @@ describe('ViewRolePage - Actions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
const deleteBtn = await screen.findByTestId('delete-button');
await waitFor(() => {
expect(deleteBtn).not.toBeDisabled();
});
const deleteBtn = screen.getByTestId('delete-button');
await user.click(deleteBtn);
await expect(
@@ -111,11 +105,7 @@ describe('ViewRolePage - Actions', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const deleteBtn = await screen.findByTestId('delete-button');
await waitFor(() => {
expect(deleteBtn).not.toBeDisabled();
});
await user.click(deleteBtn);
await user.click(screen.getByTestId('delete-button'));
await expect(
screen.findByText(/Are you sure you want to delete the role/),

View File

@@ -1,17 +1,15 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
AUTHZ_CHECK_URL,
setupAuthzAdmin,
setupAuthzDenyAll,
setupAuthzGrantByPrefix,
mockUseAuthZDenyAll,
mockUseAuthZGrantAll,
mockUseAuthZGrantByPrefix,
} from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
@@ -27,15 +25,25 @@ import {
mockPermissionsData,
} from './testUtils';
const mockUseAuthZGrantReadDeleteDenied = mockUseAuthZGrantByPrefix(
'read',
'update',
);
const mockUseAuthZGrantReadUpdateDenied = mockUseAuthZGrantByPrefix(
'read',
'delete',
);
describe('ViewRolePage - AuthZ', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('permission denied', () => {
it('shows permission denied page when read permission denied', async () => {
server.use(setupAuthzDenyAll());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZDenyAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
@@ -55,8 +63,10 @@ describe('ViewRolePage - AuthZ', () => {
});
describe('update button visibility', () => {
it('enables Update button when update permission granted', async () => {
server.use(setupAuthzAdmin());
it('enables Update button when update permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -82,13 +92,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await expect(
screen.findByTestId('save-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('disables Update button when update permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'delete'));
it('disables Update button when update permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -114,13 +124,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('disables Update button when role is managed', async () => {
server.use(setupAuthzAdmin());
it('disables Update button when role is managed', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -150,15 +160,15 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('shows managed role tooltip when update button hovered on managed role', async () => {
const user = userEvent.setup();
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -188,7 +198,7 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const updateButton = await screen.findByTestId('save-button');
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
@@ -198,8 +208,12 @@ describe('ViewRolePage - AuthZ', () => {
});
});
it('disables and shows denial attribute when update permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'delete'));
it('shows authorization tooltip when update permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadUpdateDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -225,17 +239,22 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const updateButton = screen.getByTestId('save-button');
await user.hover(updateButton);
await waitFor(() => {
const updateButton = screen.getByTestId('save-button');
expect(updateButton).toBeDisabled();
expect(updateButton).toHaveAttribute('data-denied-permissions');
expect(screen.getByRole('tooltip')).toHaveTextContent(
/You are not authorized to perform/,
);
});
});
});
describe('delete button visibility', () => {
it('disables Delete button when delete permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'update'));
it('disables Delete button when delete permission denied', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -261,13 +280,13 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('enables Delete button when delete permission granted', async () => {
server.use(setupAuthzAdmin());
it('enables Delete button when delete permission granted', () => {
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -293,13 +312,15 @@ describe('ViewRolePage - AuthZ', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
expect(screen.getByTestId('delete-button')).not.toBeDisabled();
});
it('disables and shows denial attribute when delete permission denied', async () => {
server.use(setupAuthzGrantByPrefix('read', 'update'));
it('shows permission denied tooltip when delete permission denied', async () => {
const user = userEvent.setup();
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantReadDeleteDenied);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -325,17 +346,22 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
const deleteButton = screen.getByTestId('delete-button');
expect(deleteButton).toBeDisabled();
expect(deleteButton).toHaveAttribute('data-denied-permissions');
expect(screen.getByRole('tooltip')).toHaveTextContent(
'You do not have permission to delete this role',
);
});
});
it('shows managed role tooltip when role is managed', async () => {
const user = userEvent.setup();
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,
@@ -365,7 +391,7 @@ describe('ViewRolePage - AuthZ', () => {
},
);
const deleteButton = await screen.findByTestId('delete-button');
const deleteButton = screen.getByTestId('delete-button');
await user.hover(deleteButton);
await waitFor(() => {
@@ -378,9 +404,15 @@ describe('ViewRolePage - AuthZ', () => {
describe('loading state', () => {
it('shows skeleton while checking permissions', () => {
server.use(
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.delay('infinite'))),
);
jest.spyOn(useAuthZModule, 'useAuthZ').mockReturnValue({
isLoading: true,
isFetching: true,
error: null,
permissions: null,
allowed: false,
deniedPermissions: [],
refetchPermissions: jest.fn(),
});
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,

View File

@@ -38,32 +38,28 @@ describe('ViewRolePage - Custom Role', () => {
).resolves.toBeInTheDocument();
});
it('shows Update button for custom roles', async () => {
it('shows Update button for custom roles', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(screen.findByTestId('save-button')).resolves.toBeInTheDocument();
expect(screen.getByTestId('save-button')).toBeInTheDocument();
});
it('shows Cancel button', async () => {
it('shows Cancel button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('shows Delete button', async () => {
it('shows Delete button', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('delete-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('delete-button')).toBeInTheDocument();
});
it('renders created/updated timestamps labels', async () => {

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
@@ -16,12 +16,13 @@ import {
describe('ViewRolePage - Edge Cases', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows fallback for missing description', async () => {
@@ -52,7 +53,7 @@ describe('ViewRolePage - Edge Cases', () => {
await expect(screen.findByText('Description')).resolves.toBeInTheDocument();
});
it('shows fallback for invalid timestamps', async () => {
it('shows fallback for invalid timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
@@ -78,14 +79,11 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});
it('shows fallback for undefined timestamps', async () => {
it('shows fallback for undefined timestamps', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: {
status: 'success',
@@ -111,9 +109,6 @@ describe('ViewRolePage - Edge Cases', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(2);
});

View File

@@ -1,10 +1,10 @@
import { Route, Switch } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render, screen } from 'tests/test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
import ViewRolePage from '../ViewRolePage';
@@ -18,15 +18,16 @@ import {
describe('ViewRolePage - Error State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('displays error component when API has error but role data exists', async () => {
it('displays error component when API has error but role data exists', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -45,9 +46,7 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('displays error state with title when API fails without role data', async () => {
@@ -65,12 +64,10 @@ describe('ViewRolePage - Error State', () => {
await expect(
screen.findByText('Failed to load role'),
).resolves.toBeInTheDocument();
await waitFor(() => {
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
expect(document.querySelector('.error-in-place')).toBeInTheDocument();
});
it('shows back button on error state', async () => {
it('shows back button on error state', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
isLoading: false,
@@ -82,9 +79,7 @@ describe('ViewRolePage - Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('navigates to roles list when back button clicked on error state', async () => {
@@ -110,7 +105,7 @@ describe('ViewRolePage - Error State', () => {
{ initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME) },
);
const cancelButton = await screen.findByTestId('cancel-button');
const cancelButton = screen.getByTestId('cancel-button');
await user.click(cancelButton);
await expect(

View File

@@ -1,10 +1,10 @@
import * as roleApi from 'api/generated/services/role';
import { FeatureKeys } from 'constants/features';
import { server } from 'mocks-server/server';
import { defaultFeatureFlags, render, screen, waitFor } from 'tests/test-utils';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
import {
invalidLicense,
setupAuthzAdmin,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -17,7 +17,9 @@ import {
describe('ViewRolePage - Feature Gate', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: undefined,
@@ -29,7 +31,6 @@ describe('ViewRolePage - Feature Gate', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
describe('feature disabled', () => {
@@ -45,9 +46,7 @@ describe('ViewRolePage - Feature Gate', () => {
},
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
@@ -59,34 +58,28 @@ describe('ViewRolePage - Feature Gate', () => {
appContextOverrides: { activeLicense: invalidLicense },
});
await expect(
screen.findByTestId('feature-gate-error-banner'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('feature-gate-error-banner')).toBeInTheDocument();
await expect(
screen.findByText(/Custom roles feature is not available/i),
).resolves.toBeInTheDocument();
});
it('shows back button when feature disabled', async () => {
it('shows back button when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
it('back button is enabled when feature disabled', async () => {
it('back button is enabled when feature disabled', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
appContextOverrides: { activeLicense: invalidLicense },
});
await waitFor(() => {
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
expect(screen.getByTestId('cancel-button')).not.toBeDisabled();
});
});
});

View File

@@ -1,6 +1,6 @@
import * as roleApi from 'api/generated/services/role';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import { render } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -13,12 +13,13 @@ import {
describe('ViewRolePage - Loading State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows skeleton while fetching role', () => {

View File

@@ -1,5 +1,5 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { render, screen, waitFor } from 'tests/test-utils';
import { render, screen } from 'tests/test-utils';
import ViewRolePage from '../ViewRolePage';
@@ -19,7 +19,7 @@ describe('ViewRolePage - Managed Role', () => {
jest.restoreAllMocks();
});
it('disables Delete button for managed roles', async () => {
it('disables Delete button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -30,12 +30,10 @@ describe('ViewRolePage - Managed Role', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
expect(screen.getByTestId('delete-button')).toBeDisabled();
});
it('disables Update button for managed roles', async () => {
it('disables Update button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -46,12 +44,10 @@ describe('ViewRolePage - Managed Role', () => {
},
);
await waitFor(() => {
expect(screen.getByTestId('save-button')).toBeDisabled();
});
expect(screen.getByTestId('save-button')).toBeDisabled();
});
it('still shows Cancel button for managed roles', async () => {
it('still shows Cancel button for managed roles', () => {
render(
<TooltipProvider>
<ViewRolePage />
@@ -62,8 +58,6 @@ describe('ViewRolePage - Managed Role', () => {
},
);
await expect(
screen.findByTestId('cancel-button'),
).resolves.toBeInTheDocument();
expect(screen.getByTestId('cancel-button')).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import userEvent from '@testing-library/user-event';
import { render, screen, within } from 'tests/test-utils';
@@ -17,15 +17,8 @@ import {
mockPermissionsData,
} from './testUtils';
async function waitForPageReady(): Promise<void> {
await expect(
screen.findByTestId('view-role-page'),
).resolves.toBeInTheDocument();
}
async function expandAllCards(): Promise<void> {
const user = userEvent.setup();
await waitForPageReady();
const expandButton = screen.getByTestId('expand-all-button');
await user.click(expandButton);
}
@@ -37,7 +30,6 @@ describe('ViewRolePage - Permission Overview', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders Transaction Groups section label', async () => {
@@ -50,21 +42,19 @@ describe('ViewRolePage - Permission Overview', () => {
).resolves.toBeInTheDocument();
});
it('renders permission overview container', async () => {
it('renders permission overview container', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
});
it('shows resource permission cards', async () => {
it('shows resource permission cards', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-factor-api-key'),
).toBeInTheDocument();
@@ -74,12 +64,11 @@ describe('ViewRolePage - Permission Overview', () => {
).toBeInTheDocument();
});
it('displays granted count for each resource', async () => {
it('displays granted count for each resource', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('granted-count-factor-api-key'),
).toBeInTheDocument();
@@ -88,15 +77,16 @@ describe('ViewRolePage - Permission Overview', () => {
describe('ViewRolePage - Permission Overview Loading State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows skeleton when permissions are loading', async () => {
it('shows skeleton when permissions are loading', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -115,22 +105,22 @@ describe('ViewRolePage - Permission Overview Loading State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview-loading')).toBeInTheDocument();
});
});
describe('ViewRolePage - Permission Overview Error State', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows error when permissions fail to load', async () => {
it('shows error when permissions fail to load', () => {
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
isLoading: false,
@@ -149,19 +139,19 @@ describe('ViewRolePage - Permission Overview Error State', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview-error')).toBeInTheDocument();
});
});
describe('ViewRolePage - Scope: ALL permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "All" badge for actions with ALL scope', async () => {
@@ -192,7 +182,7 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
expect(screen.getByTestId('scope-badge-create')).toHaveTextContent('All');
});
it('shows full granted count when all actions are ALL', async () => {
it('shows full granted count when all actions are ALL', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -215,7 +205,6 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-role')).toHaveTextContent(
'3 / 3 granted',
);
@@ -223,13 +212,8 @@ describe('ViewRolePage - Scope: ALL permissions', () => {
});
describe('ViewRolePage - Scope: NONE permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "None" badge for actions with NONE scope', async () => {
@@ -260,7 +244,7 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
expect(screen.getByTestId('scope-badge-delete')).toHaveTextContent('None');
});
it('shows zero granted count when all actions are NONE', async () => {
it('shows zero granted count when all actions are NONE', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -284,7 +268,6 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'0 / 4 granted',
);
@@ -292,13 +275,8 @@ describe('ViewRolePage - Scope: NONE permissions', () => {
});
describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('shows "Only selected" badge with count', async () => {
@@ -362,7 +340,7 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
await expect(screen.findByText('key-def-456')).resolves.toBeInTheDocument();
});
it('counts ONLY_SELECTED as granted in count', async () => {
it('counts ONLY_SELECTED as granted in count', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -384,7 +362,6 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-serviceaccount')).toHaveTextContent(
'1 / 2 granted',
);
@@ -431,13 +408,8 @@ describe('ViewRolePage - Scope: ONLY_SELECTED permissions', () => {
});
describe('ViewRolePage - Mixed permission scopes', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders all three scope types in single resource card', async () => {
@@ -486,7 +458,7 @@ describe('ViewRolePage - Mixed permission scopes', () => {
);
});
it('renders multiple resources with different scope combinations', async () => {
it('renders multiple resources with different scope combinations', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -530,7 +502,6 @@ describe('ViewRolePage - Mixed permission scopes', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('granted-count-factor-api-key')).toHaveTextContent(
'2 / 2 granted',
);
@@ -544,13 +515,8 @@ describe('ViewRolePage - Mixed permission scopes', () => {
});
describe('ViewRolePage - Unknown resources', () => {
beforeEach(() => {
server.use(setupAuthzAdmin());
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders unknown resource with fallback label', async () => {
@@ -574,7 +540,6 @@ describe('ViewRolePage - Unknown resources', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-future-resource'),
).toBeInTheDocument();
@@ -611,7 +576,7 @@ describe('ViewRolePage - Unknown resources', () => {
).resolves.toBeInTheDocument();
});
it('handles resource with empty actions', async () => {
it('handles resource with empty actions', () => {
mockHooksWithPermissions({
...mockPermissionsData,
resources: [
@@ -630,7 +595,6 @@ describe('ViewRolePage - Unknown resources', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(
screen.getByTestId('resource-section-empty-resource'),
).toBeInTheDocument();
@@ -647,15 +611,13 @@ describe('ViewRolePage - View mode toggle', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders Interactive/JSON toggle', async () => {
it('renders Interactive/JSON toggle', () => {
render(<ViewRolePage />, undefined, {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-view-mode-list')).toBeInTheDocument();
expect(screen.getByTestId('permission-view-mode-json')).toBeInTheDocument();
});
@@ -667,7 +629,6 @@ describe('ViewRolePage - View mode toggle', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
expect(screen.getByTestId('permission-overview')).toBeInTheDocument();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
@@ -684,7 +645,6 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it('renders copy button in JSON view', async () => {
@@ -694,7 +654,6 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);
@@ -710,7 +669,6 @@ describe('ViewRolePage - JSON Viewer Copy Button', () => {
initialRoute: buildViewRoleRoute(CUSTOM_ROLE_ID, CUSTOM_ROLE_NAME),
});
await waitForPageReady();
const jsonToggle = screen.getByTestId('permission-view-mode-json');
await user.click(jsonToggle);

View File

@@ -3,12 +3,12 @@ import {
CoretypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import * as roleApi from 'api/generated/services/role';
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
customRoleResponse,
managedRoleResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
@@ -79,7 +79,9 @@ export const mockPermissionsData = {
};
export function mockHooksForCustomRole(): void {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -97,7 +99,9 @@ export function mockHooksForCustomRole(): void {
}
export function mockHooksWithPermissions(permissions: unknown): void {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: customRoleResponse,
@@ -115,7 +119,9 @@ export function mockHooksWithPermissions(permissions: unknown): void {
}
export function mockHooksForManagedRole(): void {
server.use(setupAuthzAdmin());
jest
.spyOn(useAuthZModule, 'useAuthZ')
.mockImplementation(mockUseAuthZGrantAll);
jest.spyOn(roleApi, 'useGetRole').mockReturnValue({
data: managedRoleResponse,

View File

@@ -11,21 +11,23 @@ import {
userEvent,
} from 'tests/test-utils';
import { FeatureKeys } from 'constants/features';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
invalidLicense,
setupAuthzAdmin,
setupAuthzDeny,
mockUseAuthZGrantAll,
} from 'lib/authz/utils/authz-test-utils';
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import RolesSettings from '../RolesSettings';
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
beforeEach(() => {
mockUseAuthZ.mockImplementation(mockUseAuthZGrantAll);
server.use(
setupAuthzAdmin(),
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
@@ -33,6 +35,7 @@ describe('RolesSettings', () => {
});
afterEach(() => {
jest.clearAllMocks();
server.resetHandlers();
});
@@ -270,18 +273,4 @@ describe('RolesSettings', () => {
// Total dashes expected: 2 (for both dates)
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
});
it('disables search input when user lacks list permission', async () => {
server.use(
setupAuthzDeny(RoleListPermission),
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
const searchInput = await screen.findByPlaceholderText('Search for roles...');
expect(searchInput).toBeDisabled();
});
});

View File

@@ -0,0 +1,88 @@
import { useMemo } from 'react';
import {
buildRoleDeletePermission,
buildRoleReadPermission,
buildRoleUpdatePermission,
RoleCreatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
ParsedPermissionObject,
parsePermission,
} from 'lib/authz/hooks/useAuthZ/utils';
interface UseRoleAuthZResult {
readRolePermission: ParsedPermissionObject;
updateRolePermission: ParsedPermissionObject;
deleteRolePermission: ParsedPermissionObject;
hasCreatePermission: boolean;
hasReadPermission: boolean;
hasUpdatePermission: boolean;
hasDeletePermission: boolean;
isAuthZLoading: boolean;
}
export function useRoleAuthZ(roleName: string): UseRoleAuthZResult {
const readRolePermissionName = buildRoleReadPermission(roleName);
const updateRolePermissionName = buildRoleUpdatePermission(roleName);
const deleteRolePermissionName = buildRoleDeletePermission(roleName);
const permissionsToCheck = useMemo(() => {
const perms = [RoleCreatePermission];
if (roleName) {
perms.push(
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
);
}
return perms;
}, [
roleName,
readRolePermissionName,
updateRolePermissionName,
deleteRolePermissionName,
]);
const { permissions, isLoading: isAuthZLoading } =
useAuthZ(permissionsToCheck);
const hasCreatePermission = useMemo(() => {
if (permissions === null) {
return false;
}
return permissions[RoleCreatePermission]?.isGranted ?? false;
}, [permissions]);
const hasReadPermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleReadPermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasUpdatePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleUpdatePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
const hasDeletePermission = useMemo(() => {
if (!roleName || permissions === null) {
return true;
}
return permissions[buildRoleDeletePermission(roleName)]?.isGranted ?? true;
}, [permissions, roleName]);
return {
readRolePermission: parsePermission(readRolePermissionName),
updateRolePermission: parsePermission(updateRolePermissionName),
deleteRolePermission: parsePermission(deleteRolePermissionName),
hasCreatePermission,
hasReadPermission,
hasUpdatePermission,
hasDeletePermission,
isAuthZLoading,
};
}

View File

@@ -28,7 +28,7 @@ describe('ServiceAccountsSettings — FGA', () => {
);
});
it('shows denied callout when list permission is denied', async () => {
it('shows PermissionDeniedFullPage when list permission is denied', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
@@ -47,40 +47,14 @@ describe('ServiceAccountsSettings — FGA', () => {
renderPage();
await waitFor(() => {
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
expect(
screen.getByText('Uh-oh! You are not authorized'),
).toBeInTheDocument();
});
expect(screen.queryByRole('table')).not.toBeInTheDocument();
});
it('shows page header and disables search when list permission is denied', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = await req.json();
return res(
ctx.status(200),
ctx.json(
authzMockResponse(
payload,
payload.map(() => false),
),
),
);
}),
);
renderPage();
await waitFor(() => {
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
});
expect(screen.getByText('Service Accounts')).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search by name or email...'),
).toBeDisabled();
});
it('shows table when list permission is granted', async () => {
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
@@ -104,7 +78,7 @@ describe('ServiceAccountsSettings — FGA', () => {
});
expect(
screen.queryByText(/is not authorized to perform/),
screen.queryByText('Uh-oh! You are not authorized'),
).not.toBeInTheDocument();
});

View File

@@ -44,7 +44,6 @@
display: flex;
align-items: center;
gap: var(--spacing-4);
padding-bottom: var(--spacing-6);
}
&__search {

View File

@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import { Check, ChevronDown, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Input } from '@signozhq/ui/input';
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
import { invalidateListServiceAccounts } from 'api/generated/services/serviceaccount';
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
import Spinner from 'components/Spinner';
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
import ServiceAccountsTable, {
PAGE_SIZE,
@@ -20,6 +17,7 @@ import {
SACreatePermission,
SAListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -40,13 +38,6 @@ import {
import './ServiceAccountsSettings.styles.scss';
function ServiceAccountsSettings(): JSX.Element {
const queryClient = useQueryClient();
const { permissions: authzPermissions, isLoading: isAuthZLoading } = useAuthZ([
SAListPermission,
]);
const canListServiceAccounts =
authzPermissions?.[SAListPermission]?.isGranted ?? false;
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [currentPage, setPage] = useQueryState(
SA_QUERY_PARAMS.PAGE,
parseAsInteger.withDefault(1),
@@ -61,19 +52,25 @@ function ServiceAccountsSettings(): JSX.Element {
FilterMode.All,
),
);
const [, setSelectedAccountId] = useQueryState(SA_QUERY_PARAMS.ACCOUNT);
const [, setIsCreateModalOpen] = useQueryState(
SA_QUERY_PARAMS.CREATE_SA,
parseAsBoolean.withDefault(false),
);
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
SAListPermission,
]);
const hasListPermission = listPerms?.[SAListPermission]?.isGranted ?? false;
const {
data: serviceAccountsData,
isLoading,
isError,
error,
} = useListServiceAccounts({ query: { enabled: canListServiceAccounts } });
const controlsDisabled = isAuthZLoading || !canListServiceAccounts;
refetch: handleCreateSuccess,
} = useListServiceAccounts({ query: { enabled: hasListPermission } });
const allAccounts = useMemo(
(): ServiceAccountRow[] =>
@@ -202,9 +199,9 @@ function ServiceAccountsSettings(): JSX.Element {
if (options?.closeDrawer) {
void setSelectedAccountId(null);
}
void invalidateListServiceAccounts(queryClient);
void handleCreateSuccess();
},
[queryClient, setSelectedAccountId],
[handleCreateSuccess, setSelectedAccountId],
);
return (
@@ -226,32 +223,31 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
</div>
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<AuthZTooltip checks={[SAListPermission]}>
<span>
<DropdownMenuSimple
menu={{ items: filterMenuItems }}
className="sa-settings-filter-dropdown"
{isAuthZLoading || isLoading ? (
<Spinner height="50vh" />
) : !hasListPermission ? (
<PermissionDeniedFullPage permissionName="serviceaccount:list" />
) : (
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<DropdownMenuSimple
menu={{ items: filterMenuItems }}
className="sa-settings-filter-dropdown"
>
<Button
variant="solid"
color="secondary"
className="sa-settings-filter-trigger"
>
<Button
variant="solid"
color="secondary"
className="sa-settings-filter-trigger"
disabled={controlsDisabled}
>
<span>{filterLabel}</span>
<ChevronDown
size={12}
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</DropdownMenuSimple>
</span>
</AuthZTooltip>
<span>{filterLabel}</span>
<ChevronDown
size={12}
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</DropdownMenuSimple>
<div className="sa-settings__search">
<AuthZTooltip checks={[SAListPermission]}>
<div className="sa-settings__search">
<Input
type="search"
name="service-accounts-search"
@@ -262,25 +258,23 @@ function ServiceAccountsSettings(): JSX.Element {
void setPage(1);
}}
className="sa-settings-search-input"
disabled={controlsDisabled}
/>
</div>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</AuthZTooltip>
</div>
<AuthZButton
checks={[SACreatePermission]}
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</AuthZButton>
</div>
<AuthZGuardContent checks={[SAListPermission]}>
{isError ? (
<ErrorInPlace
error={toAPIError(
@@ -295,8 +289,8 @@ function ServiceAccountsSettings(): JSX.Element {
onRowClick={handleRowClick}
/>
)}
</AuthZGuardContent>
</div>
</div>
)}
<CreateServiceAccountModal />

View File

@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import userEvent from '@testing-library/user-event';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
@@ -13,6 +14,46 @@ const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const ROLES_ENDPOINT = '*/api/v1/roles';
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/dialog', () => ({
...jest.requireActual('@signozhq/ui/dialog'),
DialogWrapper: ({
children,
open,
title,
}: {
children?: ReactNode;
open: boolean;
title?: string;
}): JSX.Element | null =>
open ? (
<div role="dialog" aria-label={title}>
{children}
</div>
) : null,
DialogFooter: ({ children }: { children?: ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
const mockServiceAccountsAPI = [
{
id: 'sa-1',
@@ -132,11 +173,11 @@ describe('ServiceAccountsSettings (integration)', () => {
</NuqsTestingAdapter>,
);
const viewButton = await screen.findByRole('button', {
name: /View service account CI Bot/i,
});
fireEvent.click(viewButton);
fireEvent.click(
await screen.findByRole('button', {
name: /View service account CI Bot/i,
}),
);
await expect(
screen.findByRole('button', { name: /Delete Service Account/i }),

View File

@@ -8,7 +8,86 @@
"logs": true
},
"dataCollected": {
"metrics": [],
"metrics": [
{
"name": "cloudsql.googleapis.com/database/up",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/cpu/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/utilization",
"unit": "Percent",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/memory/usage",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/disk/bytes_used",
"unit": "Bytes",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/num_backends",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/transaction_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/deadlock_count",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
"unit": "Count",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"unit": "Microseconds",
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag",
"unit": "Bytes",
"type": "Gauge",
"description": ""
}
],
"logs": []
},
"telemetryCollectionStrategy": {

View File

@@ -1,172 +0,0 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { authToken } from './common';
export const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
const SETTINGS_PANEL_TITLE = 'Edit quick filters';
const SETTINGS_ICON_TEST_ID = 'settings-icon';
const ANNOUNCEMENT_DISMISSED_KEY = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT';
/**
* Conservative lower bound for the open panel width. The panel is 342px in
* QuickFiltersSettings.styles.scss; 300 leaves slack for sub-pixel rounding.
*/
const SETTINGS_PANEL_MIN_WIDTH_PX = 300;
// Pages that already had the announcement-dismissal init script registered.
// addInitScript re-runs on every navigation, so registering once per Page keeps
// the callback from stacking up across repeated helper calls.
const announcementDismissed = new WeakSet<Page>();
export type QuickFilterSignal = 'logs' | 'traces';
export interface QuickFilterDefinition {
key: string;
dataType: string;
type: string;
}
export const DEFAULT_CUSTOM_FILTERS: QuickFilterDefinition[] = [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
},
{
key: 'deployment.environment',
dataType: 'string',
type: 'resource',
},
];
function settingsPanel(page: Page): Locator {
return page
.locator('.quick-filters-settings')
.filter({ hasText: SETTINGS_PANEL_TITLE });
}
function settingsGearContainer(page: Page): Locator {
return page.getByTestId(SETTINGS_ICON_TEST_ID).locator('..');
}
/** Minimal filter set so `isDynamicFilters` is true and the settings gear renders. */
export async function seedCustomFiltersViaApi(
page: Page,
signal: QuickFilterSignal,
filters: QuickFilterDefinition[] = DEFAULT_CUSTOM_FILTERS,
): Promise<void> {
const token = await authToken(page);
const res = await page.request.put('/api/v1/orgs/me/filters', {
data: { signal, filters },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PUT /api/v1/orgs/me/filters ${res.status()}: ${await res.text()}`,
);
}
}
/**
* Hide the one-off announcement tooltip so it cannot intercept the gear click.
* Registered via addInitScript (not page.evaluate) so it runs in the app's own
* origin on every navigation — evaluate() before goto() would set localStorage
* on the pre-navigation about:blank origin, which the app never reads. Must be
* called before the first goto(). Guarded to once per Page to avoid stacking.
*/
export async function dismissQuickFiltersAnnouncement(
page: Page,
): Promise<void> {
if (announcementDismissed.has(page)) {
return;
}
await page.addInitScript((key) => {
// Runs in the browser; Node's tsc has no DOM globals on this callback.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).localStorage.setItem(key, 'false');
}, ANNOUNCEMENT_DISMISSED_KEY);
announcementDismissed.add(page);
}
export async function gotoExplorerWithQuickFilters(
page: Page,
path: string,
): Promise<void> {
await dismissQuickFiltersAnnouncement(page);
await page.goto(path);
await page.getByTestId(SETTINGS_ICON_TEST_ID).waitFor({ state: 'visible' });
}
/** Settings drawer is closed — content unmounted and gear not active. */
export async function assertQuickFiltersSettingsDrawerClosed(
page: Page,
): Promise<void> {
await expect(page.getByText(SETTINGS_PANEL_TITLE)).toBeHidden();
await expect(settingsGearContainer(page)).not.toHaveClass(/active/);
// Collapsed panel keeps the `hidden` class (width: 0). Assert the class, not
// toBeVisible() — a zero-width element is never "visible" to Playwright.
await expect(page.locator('.quick-filters-settings')).toHaveClass(/hidden/);
}
/**
* Click the settings gear. Pair with `assertQuickFiltersSettingsDrawerOpen` for
* visibility/layout checks that guard overflow clipping regressions.
*/
export async function openQuickFiltersSettingsPanel(page: Page): Promise<void> {
await assertQuickFiltersSettingsDrawerClosed(page);
await page.getByTestId(SETTINGS_ICON_TEST_ID).click();
}
/**
* Assert the customization drawer is open, painted on screen, and populated with
* the seeded filters — not merely present in the DOM.
*/
export async function assertQuickFiltersSettingsDrawerOpen(
page: Page,
seededFilters: QuickFilterDefinition[] = DEFAULT_CUSTOM_FILTERS,
): Promise<void> {
const panel = settingsPanel(page);
const filtersColumn = page.locator('.quick-filters');
await expect(settingsGearContainer(page)).toHaveClass(/active/);
await expect(panel).toBeVisible();
await expect(panel).not.toHaveClass(/hidden/);
await expect(page.getByText(SETTINGS_PANEL_TITLE)).toBeVisible();
await expect(page.getByPlaceholder('Search for a filter...')).toBeVisible();
await expect(panel.locator('.qf-header-icon')).toBeVisible();
const addedList = panel.locator('.qf-added-filters-list');
await expect(panel.getByText('ADDED FILTERS')).toBeVisible();
for (const filter of seededFilters) {
await expect(addedList.getByText(filter.key, { exact: true })).toBeVisible();
}
// Footer only mounts after edits — absent on first open.
await expect(panel.getByRole('button', { name: 'Discard' })).toBeHidden();
await expect(panel.getByRole('button', { name: 'Save changes' })).toBeHidden();
// OTHER FILTERS loads asynchronously from attribute/aggregate suggestions.
await expect(panel.locator('.qf-other-filters-skeleton')).toHaveCount(0);
await expect(panel.getByText('OTHER FILTERS')).toBeVisible();
await expect(
panel.locator('.qf-other-filters-list .other-filters-item').first(),
).toBeVisible();
const [panelBox, columnBox] = await Promise.all([
panel.boundingBox(),
filtersColumn.boundingBox(),
]);
expect(panelBox?.width ?? 0).toBeGreaterThanOrEqual(
SETTINGS_PANEL_MIN_WIDTH_PX,
);
expect(columnBox).not.toBeNull();
expect(panelBox!.x).toBeGreaterThanOrEqual(columnBox!.x);
}
/** Close via the drawer header X and confirm state resets. */
export async function closeQuickFiltersSettingsPanel(
page: Page,
): Promise<void> {
await settingsPanel(page).locator('.qf-header-icon').click();
await assertQuickFiltersSettingsDrawerClosed(page);
}

View File

@@ -1,24 +0,0 @@
import { test, expect } from '../../fixtures/auth';
import {
assertQuickFiltersSettingsDrawerOpen,
closeQuickFiltersSettingsPanel,
DEFAULT_CUSTOM_FILTERS,
gotoExplorerWithQuickFilters,
LOGS_EXPLORER_PATH,
openQuickFiltersSettingsPanel,
seedCustomFiltersViaApi,
} from '../../helpers/quick-filters';
// smoke tests for settings drawer in quick filters
test.describe('Quick Filters — settings drawer', () => {
test('TC-01 logs explorer — settings gear opens a visible, populated drawer', async ({
authedPage: page,
}) => {
await seedCustomFiltersViaApi(page, 'logs', DEFAULT_CUSTOM_FILTERS);
await gotoExplorerWithQuickFilters(page, LOGS_EXPLORER_PATH);
await openQuickFiltersSettingsPanel(page);
await assertQuickFiltersSettingsDrawerOpen(page, DEFAULT_CUSTOM_FILTERS);
await closeQuickFiltersSettingsPanel(page);
await expect(page.locator('.quick-filters')).toBeVisible();
});
});