mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-03 21:30:34 +01:00
Compare commits
14 Commits
nv/dashboa
...
feat/new-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6577d22746 | ||
|
|
f3c42594fa | ||
|
|
143cef8e6d | ||
|
|
9f4fe5c7cf | ||
|
|
a6246bf32b | ||
|
|
8159d6d148 | ||
|
|
9b84f75de0 | ||
|
|
8e526263c1 | ||
|
|
ef9b8eec8a | ||
|
|
94a5e568fd | ||
|
|
25cd3fc705 | ||
|
|
5c3fdb64ed | ||
|
|
bac68a6707 | ||
|
|
766a05e24b |
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -109,10 +109,7 @@ go.mod @therealpandey
|
||||
/pkg/modules/role/ @therealpandey
|
||||
/pkg/types/coretypes/ @therealpandey @vikrantgupta25
|
||||
|
||||
/frontend/src/hooks/useAuthZ/ @H4ad
|
||||
/frontend/src/components/GuardAuthZ/ @H4ad
|
||||
/frontend/src/components/AuthZTooltip/ @H4ad
|
||||
/frontend/src/components/createGuardedRoute/ @H4ad
|
||||
/frontend/src/lib/authz/ @H4ad
|
||||
/frontend/src/container/RolesSettings/ @H4ad
|
||||
/frontend/src/components/RolesSelect/ @H4ad
|
||||
/frontend/src/pages/MembersSettings/ @H4ad
|
||||
|
||||
7
.github/workflows/build-enterprise.yaml
vendored
7
.github/workflows/build-enterprise.yaml
vendored
@@ -61,13 +61,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
6
.github/workflows/build-staging.yaml
vendored
6
.github/workflows/build-staging.yaml
vendored
@@ -67,12 +67,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> frontend/.env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
|
||||
echo 'VITE_DOCS_BASE_URL="https://staging.signoz.io"' >> frontend/.env
|
||||
echo 'VITE_ENVIRONMENT="staging"' >> frontend/.env
|
||||
echo 'VITE_VERSION="${{ steps.build-info.outputs.version }}"' >> frontend/.env
|
||||
|
||||
7
.github/workflows/gor-signoz.yaml
vendored
7
.github/workflows/gor-signoz.yaml
vendored
@@ -27,13 +27,6 @@ jobs:
|
||||
echo 'VITE_SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}"' >> .env
|
||||
echo 'VITE_SENTRY_ORG="${{ secrets.SENTRY_ORG }}"' >> .env
|
||||
echo 'VITE_SENTRY_PROJECT_ID="${{ secrets.SENTRY_PROJECT_ID }}"' >> .env
|
||||
echo 'VITE_SENTRY_DSN="${{ secrets.SENTRY_DSN }}"' >> .env
|
||||
echo 'VITE_TUNNEL_URL="${{ secrets.TUNNEL_URL }}"' >> .env
|
||||
echo 'VITE_TUNNEL_DOMAIN="${{ secrets.TUNNEL_DOMAIN }}"' >> .env
|
||||
echo 'VITE_POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
|
||||
echo 'VITE_PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
|
||||
echo 'VITE_APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
|
||||
echo 'VITE_PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
|
||||
echo 'VITE_DOCS_BASE_URL="https://signoz.io"' >> .env
|
||||
echo 'VITE_ENVIRONMENT="production"' >> .env
|
||||
echo 'VITE_VERSION="${{ github.ref_name }}"' >> .env
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const permissionsTypePath = "frontend/src/hooks/useAuthZ/permissions.type.ts"
|
||||
const permissionsTypePath = "frontend/src/lib/authz/hooks/useAuthZ/permissions.type.ts"
|
||||
|
||||
var permissionsTypeTemplate = template.Must(template.New("permissions").Parse(
|
||||
`// AUTO GENERATED FILE - DO NOT EDIT - GENERATED BY cmd/enterprise/*.go generate authz
|
||||
|
||||
@@ -3071,6 +3071,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesListedDashboardForUserV2'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
@@ -3082,6 +3086,7 @@ components:
|
||||
- dashboards
|
||||
- total
|
||||
- tags
|
||||
- reservedKeywords
|
||||
type: object
|
||||
DashboardtypesListableDashboardV2:
|
||||
properties:
|
||||
@@ -3089,6 +3094,10 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
|
||||
type: array
|
||||
reservedKeywords:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
@@ -3100,6 +3109,7 @@ components:
|
||||
- dashboards
|
||||
- total
|
||||
- tags
|
||||
- reservedKeywords
|
||||
type: object
|
||||
DashboardtypesListableDashboardView:
|
||||
properties:
|
||||
|
||||
@@ -25,6 +25,7 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
NODE_ENV="development"
|
||||
BUNDLE_ANALYSER="true"
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_PYLON_APP_ID="pylon-app-id"
|
||||
VITE_APPCUES_APP_ID="appcess-app-id"
|
||||
VITE_PYLON_IDENTITY_SECRET="pylon-identity-secret"
|
||||
|
||||
CI="1"
|
||||
|
||||
# API
|
||||
VITE_BASE_PATH=""
|
||||
VITE_FRONTEND_API_ENDPOINT="http://localhost:8080"
|
||||
VITE_WEBSOCKET_API_ENDPOINT=""
|
||||
|
||||
# Pylon
|
||||
VITE_PYLON_ENABLED="false"
|
||||
VITE_PYLON_APP_ID=""
|
||||
VITE_PYLON_IDENTITY_SECRET=""
|
||||
|
||||
# Appcues
|
||||
VITE_APPCUES_ENABLED="false"
|
||||
VITE_APPCUES_APP_ID=""
|
||||
|
||||
# PostHog
|
||||
VITE_POSTHOG_ENABLED="false"
|
||||
VITE_POSTHOG_API_HOST=""
|
||||
VITE_POSTHOG_KEY=""
|
||||
VITE_POSTHOG_UI_HOST=""
|
||||
|
||||
# Sentry
|
||||
VITE_SENTRY_ENABLED="false"
|
||||
VITE_SENTRY_AUTH_TOKEN=""
|
||||
VITE_SENTRY_ORG=""
|
||||
VITE_SENTRY_PROJECT_ID=""
|
||||
VITE_SENTRY_TUNNEL=""
|
||||
VITE_SENTRY_DSN=""
|
||||
|
||||
# Docs
|
||||
VITE_DOCS_BASE_URL="https://signoz.io"
|
||||
|
||||
# Build info
|
||||
VITE_ENVIRONMENT="development"
|
||||
VITE_VERSION=""
|
||||
|
||||
@@ -111,11 +111,10 @@
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
var PYLON_APP_ID = '<%- PYLON_APP_ID %>';
|
||||
var pylonSettings =
|
||||
((window.signozBootData || {}).settings || {}).pylon || {};
|
||||
var pylonEnabled = pylonSettings.enabled !== false;
|
||||
if (PYLON_APP_ID && pylonEnabled) {
|
||||
var pylonEnabled = pylonSettings.enabled === true;
|
||||
if (pylonSettings.appId && pylonEnabled) {
|
||||
(function () {
|
||||
var e = window;
|
||||
var t = document;
|
||||
@@ -133,7 +132,7 @@
|
||||
e.setAttribute('async', 'true');
|
||||
e.setAttribute(
|
||||
'src',
|
||||
'https://widget.usepylon.com/widget/' + PYLON_APP_ID,
|
||||
'https://widget.usepylon.com/widget/' + pylonSettings.appId,
|
||||
);
|
||||
var n = t.getElementsByTagName('script')[0];
|
||||
n.parentNode.insertBefore(e, n);
|
||||
@@ -150,15 +149,14 @@
|
||||
window.AppcuesSettings = { enableURLDetection: true };
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
var appcuesEnabled = appcuesSettings.enabled === true;
|
||||
if (appcuesSettings.appId && appcuesEnabled) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
a.src = '//fast.appcues.com/' + APPCUES_APP_ID + '.js';
|
||||
a.src = '//fast.appcues.com/' + appcuesSettings.appId + '.js';
|
||||
var s = d.getElementsByTagName(t)[0];
|
||||
s.parentNode.insertBefore(a, s);
|
||||
})(document, 'script');
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"project": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignore": ["src/api/generated/**/*.ts", "src/typings/*.ts"],
|
||||
"ignoreDependencies": [
|
||||
"http-proxy-middleware",
|
||||
"@typescript/native-preview"
|
||||
]
|
||||
}
|
||||
@@ -79,7 +79,6 @@
|
||||
"event-source-polyfill": "1.0.31",
|
||||
"eventemitter3": "5.0.1",
|
||||
"history": "4.10.1",
|
||||
"http-proxy-middleware": "4.1.1",
|
||||
"http-status-codes": "2.3.0",
|
||||
"i18next": "^21.6.12",
|
||||
"i18next-browser-languagedetector": "^6.1.3",
|
||||
|
||||
22
frontend/pnpm-lock.yaml
generated
22
frontend/pnpm-lock.yaml
generated
@@ -164,9 +164,6 @@ importers:
|
||||
history:
|
||||
specifier: 4.10.1
|
||||
version: 4.10.1
|
||||
http-proxy-middleware:
|
||||
specifier: 4.1.1
|
||||
version: 4.1.1
|
||||
http-status-codes:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
@@ -5461,10 +5458,6 @@ packages:
|
||||
resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
resolution: {integrity: sha512-KX5ZofGXLFXqFAkQoOWZ+rTtaLTut7m0gyL+QzJrdejtIZ+F4bPPDoe7reISg2+v0CAz5OfVwEJEhty7X+e57g==}
|
||||
engines: {node: ^22.15.0 || ^24.0.0 || >=26.0.0}
|
||||
|
||||
http-status-codes@2.3.0:
|
||||
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
|
||||
|
||||
@@ -5472,9 +5465,6 @@ packages:
|
||||
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
httpxy@0.5.3:
|
||||
resolution: {integrity: sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw==}
|
||||
|
||||
human-signals@2.1.0:
|
||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||
engines: {node: '>=10.17.0'}
|
||||
@@ -14515,16 +14505,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-proxy-middleware@4.1.1:
|
||||
dependencies:
|
||||
debug: 4.3.4(supports-color@5.5.0)
|
||||
httpxy: 0.5.3
|
||||
is-glob: 4.0.3
|
||||
is-plain-obj: 4.1.0
|
||||
micromatch: 4.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
http-status-codes@2.3.0: {}
|
||||
|
||||
https-proxy-agent@5.0.1:
|
||||
@@ -14534,8 +14514,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
httpxy@0.5.3: {}
|
||||
|
||||
human-signals@2.1.0: {}
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
@@ -292,10 +292,10 @@ function App(): JSX.Element {
|
||||
isChatSupportEnabled &&
|
||||
!showAddCreditCardModal &&
|
||||
(isCloudUser || isEnterpriseSelfHostedUser) &&
|
||||
(window.signozBootData?.settings?.pylon.enabled ?? true)
|
||||
window.signozBootData?.settings?.pylon?.enabled
|
||||
) {
|
||||
const email = user.email || '';
|
||||
const secret = process.env.PYLON_IDENTITY_SECRET || '';
|
||||
const secret = window.signozBootData?.settings?.pylon?.identitySecret || '';
|
||||
let emailHash = '';
|
||||
|
||||
if (email && secret) {
|
||||
@@ -304,7 +304,7 @@ function App(): JSX.Element {
|
||||
|
||||
window.pylon = {
|
||||
chat_settings: {
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
app_id: window.signozBootData?.settings?.pylon?.appId,
|
||||
email: user.email,
|
||||
name: user.displayName || user.email,
|
||||
email_hash: emailHash,
|
||||
@@ -335,22 +335,23 @@ function App(): JSX.Element {
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (
|
||||
(window.signozBootData?.settings?.posthog.enabled ?? true) &&
|
||||
process.env.POSTHOG_KEY
|
||||
window.signozBootData?.settings?.posthog?.enabled &&
|
||||
window.signozBootData?.settings?.posthog?.key
|
||||
) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
posthog.init(window.signozBootData.settings.posthog.key, {
|
||||
api_host: window.signozBootData.settings.posthog.apiHost,
|
||||
ui_host: window.signozBootData.settings.posthog.uiHost,
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!isSentryInitialized &&
|
||||
(window.signozBootData?.settings?.sentry.enabled ?? true)
|
||||
window.signozBootData?.settings?.sentry?.enabled
|
||||
) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
tunnel: process.env.TUNNEL_URL,
|
||||
dsn: window.signozBootData.settings.sentry.dsn,
|
||||
tunnel: window.signozBootData.settings.sentry.tunnel,
|
||||
environment: process.env.ENVIRONMENT,
|
||||
release: process.env.VERSION,
|
||||
integrations: [
|
||||
|
||||
@@ -5031,6 +5031,10 @@ export interface DashboardtypesListableDashboardForUserV2DTO {
|
||||
* @type array
|
||||
*/
|
||||
dashboards: DashboardtypesListedDashboardForUserV2DTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -5098,6 +5102,10 @@ export interface DashboardtypesListableDashboardV2DTO {
|
||||
* @type array
|
||||
*/
|
||||
dashboards: DashboardtypesListedDashboardV2DTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
reservedKeywords: string[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Controller, useForm } from 'react-hook-form';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
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';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
|
||||
import CreateServiceAccountModal from '../CreateServiceAccountModal';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { Chart, ChartConfiguration, ChartData, Color } from 'chart.js';
|
||||
import {
|
||||
ActiveElement,
|
||||
Chart,
|
||||
ChartConfiguration,
|
||||
ChartData,
|
||||
ChartEvent,
|
||||
ChartType,
|
||||
Color,
|
||||
TooltipItem,
|
||||
} from 'chart.js';
|
||||
import * as chartjsAdapter from 'chartjs-adapter-date-fns';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
@@ -60,184 +69,189 @@ export const getGraphOptions = (
|
||||
minTime?: number,
|
||||
maxTime?: number,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): CustomChartOptions => ({
|
||||
animation: {
|
||||
duration: animate ? 200 : 0,
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: true,
|
||||
},
|
||||
plugins: {
|
||||
...(staticLine
|
||||
? {
|
||||
annotation: {
|
||||
annotations: [
|
||||
{
|
||||
type: 'line',
|
||||
yMin: staticLine.yMin,
|
||||
yMax: staticLine.yMax,
|
||||
borderColor: staticLine.borderColor,
|
||||
borderWidth: staticLine.borderWidth,
|
||||
label: {
|
||||
content: staticLine.lineText,
|
||||
enabled: true,
|
||||
font: {
|
||||
size: 10,
|
||||
): CustomChartOptions =>
|
||||
({
|
||||
animation: {
|
||||
duration: animate ? 200 : 0,
|
||||
},
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: true,
|
||||
},
|
||||
plugins: {
|
||||
...(staticLine
|
||||
? {
|
||||
annotation: {
|
||||
annotations: [
|
||||
{
|
||||
type: 'line',
|
||||
yMin: staticLine.yMin,
|
||||
yMax: staticLine.yMax,
|
||||
borderColor: staticLine.borderColor,
|
||||
borderWidth: staticLine.borderWidth,
|
||||
label: {
|
||||
content: staticLine.lineText,
|
||||
enabled: true,
|
||||
font: {
|
||||
size: 10,
|
||||
},
|
||||
borderWidth: 0,
|
||||
position: 'start',
|
||||
backgroundColor: 'transparent',
|
||||
color: staticLine.textColor,
|
||||
},
|
||||
borderWidth: 0,
|
||||
position: 'start',
|
||||
backgroundColor: 'transparent',
|
||||
color: staticLine.textColor,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
title: {
|
||||
display: title !== undefined,
|
||||
text: title,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(context: TooltipItem<'line'>[]): string | string[] {
|
||||
const date = dayjs(context[0].parsed.x);
|
||||
return date
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
|
||||
},
|
||||
label(context: TooltipItem<'line'>): string | string[] {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
|
||||
}
|
||||
|
||||
return label;
|
||||
},
|
||||
labelTextColor(labelData: TooltipItem<'line'>): Color {
|
||||
if (labelData.datasetIndex === nearestDatasetIndex.current) {
|
||||
return 'rgba(255, 255, 255, 1)';
|
||||
}
|
||||
|
||||
return 'rgba(255, 255, 255, 0.75)';
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
title: {
|
||||
display: title !== undefined,
|
||||
text: title,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(context): string | string[] {
|
||||
const date = dayjs(context[0].parsed.x);
|
||||
return date
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.MONTH_DATETIME_FULL_SECONDS);
|
||||
},
|
||||
label(context): string | string[] {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
label += getToolTipValue(context.parsed.y.toString(), yAxisUnit);
|
||||
}
|
||||
|
||||
return label;
|
||||
},
|
||||
labelTextColor(labelData): Color {
|
||||
if (labelData.datasetIndex === nearestDatasetIndex.current) {
|
||||
return 'rgba(255, 255, 255, 1)';
|
||||
}
|
||||
|
||||
return 'rgba(255, 255, 255, 0.75)';
|
||||
position: 'custom',
|
||||
itemSort(item1: TooltipItem<'line'>, item2: TooltipItem<'line'>): number {
|
||||
return item2.parsed.y - item1.parsed.y;
|
||||
},
|
||||
},
|
||||
position: 'custom',
|
||||
itemSort(item1, item2): number {
|
||||
return item2.parsed.y - item1.parsed.y;
|
||||
},
|
||||
[dragSelectPluginId]: createDragSelectPluginOptions(
|
||||
!!onDragSelect,
|
||||
onDragSelect,
|
||||
dragSelectColor,
|
||||
),
|
||||
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
|
||||
!!onDragSelect,
|
||||
currentTheme === 'dark' ? 'white' : 'black',
|
||||
),
|
||||
},
|
||||
[dragSelectPluginId]: createDragSelectPluginOptions(
|
||||
!!onDragSelect,
|
||||
onDragSelect,
|
||||
dragSelectColor,
|
||||
),
|
||||
[intersectionCursorPluginId]: createIntersectionCursorPluginOptions(
|
||||
!!onDragSelect,
|
||||
currentTheme === 'dark' ? 'white' : 'black',
|
||||
),
|
||||
},
|
||||
layout: {
|
||||
padding: 0,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: isStacked,
|
||||
offset: false,
|
||||
grid: {
|
||||
layout: {
|
||||
padding: 0,
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: isStacked,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
drawTicks: true,
|
||||
},
|
||||
adapters: {
|
||||
date: chartjsAdapter,
|
||||
},
|
||||
time: {
|
||||
unit: xAxisTimeUnit?.unitName || 'minute',
|
||||
stepSize: xAxisTimeUnit?.stepSize || 1,
|
||||
displayFormats: {
|
||||
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
second: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
minute: DATE_TIME_FORMATS.TIME,
|
||||
hour: DATE_TIME_FORMATS.SLASH_SHORT,
|
||||
day: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
week: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
month: DATE_TIME_FORMATS.YEAR_MONTH,
|
||||
year: DATE_TIME_FORMATS.YEAR_SHORT,
|
||||
},
|
||||
},
|
||||
type: 'time',
|
||||
ticks: { color: getAxisLabelColor(currentTheme) },
|
||||
...(minTime && {
|
||||
min: dayjs(minTime).tz(timezone.value).format(),
|
||||
}),
|
||||
...(maxTime && {
|
||||
max: dayjs(maxTime).tz(timezone.value).format(),
|
||||
}),
|
||||
},
|
||||
y: {
|
||||
stacked: isStacked,
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
drawTicks: true,
|
||||
},
|
||||
adapters: {
|
||||
date: chartjsAdapter,
|
||||
},
|
||||
time: {
|
||||
unit: xAxisTimeUnit?.unitName || 'minute',
|
||||
stepSize: xAxisTimeUnit?.stepSize || 1,
|
||||
displayFormats: {
|
||||
millisecond: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
second: DATE_TIME_FORMATS.TIME_SECONDS,
|
||||
minute: DATE_TIME_FORMATS.TIME,
|
||||
hour: DATE_TIME_FORMATS.SLASH_SHORT,
|
||||
day: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
week: DATE_TIME_FORMATS.DATE_SHORT,
|
||||
month: DATE_TIME_FORMATS.YEAR_MONTH,
|
||||
year: DATE_TIME_FORMATS.YEAR_SHORT,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
},
|
||||
},
|
||||
type: 'time',
|
||||
ticks: { color: getAxisLabelColor(currentTheme) },
|
||||
...(minTime && {
|
||||
min: dayjs(minTime).tz(timezone.value).format(),
|
||||
}),
|
||||
...(maxTime && {
|
||||
max: dayjs(maxTime).tz(timezone.value).format(),
|
||||
}),
|
||||
},
|
||||
y: {
|
||||
stacked: isStacked,
|
||||
display: true,
|
||||
grid: {
|
||||
display: true,
|
||||
color: getGridColor(),
|
||||
},
|
||||
ticks: {
|
||||
color: getAxisLabelColor(currentTheme),
|
||||
// Include a dollar sign in the ticks
|
||||
callback(value): string {
|
||||
return getYAxisFormattedValue(value.toString(), yAxisUnit);
|
||||
ticks: {
|
||||
color: getAxisLabelColor(currentTheme),
|
||||
// Include a dollar sign in the ticks
|
||||
callback(value: number | string): string {
|
||||
return getYAxisFormattedValue(value.toString(), yAxisUnit);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
},
|
||||
point: {
|
||||
hoverBackgroundColor: (ctx: any): string => {
|
||||
if (ctx?.element?.options?.borderColor) {
|
||||
return ctx.element.options.borderColor;
|
||||
}
|
||||
return 'rgba(0,0,0,0.1)';
|
||||
elements: {
|
||||
line: {
|
||||
tension: 0,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
},
|
||||
hoverRadius: 5,
|
||||
},
|
||||
},
|
||||
onClick: (event, element, chart): void => {
|
||||
if (onClickHandler) {
|
||||
onClickHandler(event, element, chart, data);
|
||||
}
|
||||
},
|
||||
onHover: (event, _, chart): void => {
|
||||
if (event.native) {
|
||||
const interactions = chart.getElementsAtEventForMode(
|
||||
event.native,
|
||||
'nearest',
|
||||
{
|
||||
intersect: false,
|
||||
point: {
|
||||
hoverBackgroundColor: (ctx: any): string => {
|
||||
if (ctx?.element?.options?.borderColor) {
|
||||
return ctx.element.options.borderColor;
|
||||
}
|
||||
return 'rgba(0,0,0,0.1)';
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (interactions[0]) {
|
||||
nearestDatasetIndex.current = interactions[0].datasetIndex;
|
||||
hoverRadius: 5,
|
||||
},
|
||||
},
|
||||
onClick: (
|
||||
event: ChartEvent,
|
||||
element: ActiveElement[],
|
||||
chart: Chart,
|
||||
): void => {
|
||||
if (onClickHandler) {
|
||||
onClickHandler(event, element, chart, data);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
onHover: (event: ChartEvent, _: ActiveElement[], chart: Chart): void => {
|
||||
if (event.native) {
|
||||
const interactions = chart.getElementsAtEventForMode(
|
||||
event.native,
|
||||
'nearest',
|
||||
{
|
||||
intersect: false,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
if (interactions[0]) {
|
||||
nearestDatasetIndex.current = interactions[0].datasetIndex;
|
||||
}
|
||||
}
|
||||
},
|
||||
}) as CustomChartOptions;
|
||||
|
||||
declare module 'chart.js' {
|
||||
interface TooltipPositionerMap {
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
|
||||
import { GuardAuthZ } from './GuardAuthZ';
|
||||
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
const NoPermissionFallback = (_response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => <div>Access denied</div>;
|
||||
const NoPermissionFallbackWithSuggestions = (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
Access denied. Required permission: {response.requiredPermissionName}
|
||||
</div>
|
||||
);
|
||||
|
||||
it('should render children when permission is granted', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnLoading when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnLoading={<LoadingFallback />}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when loading and no fallbackOnLoading provided', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnError={<div>Custom error fallback</div>}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallbackOnNoPermissions when 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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallback}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permission is denied and no fallbackOnNoPermissions provided', 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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="update" object="role:123">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permissions object is null', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json({ data: [], status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
|
||||
const permission = buildPermission('update', 'role:123');
|
||||
|
||||
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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Access denied. Required permission:/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
new RegExp(permission.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle different relation and object combinations', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<GuardAuthZ relation="delete" object="role:456">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
|
||||
export type GuardAuthZProps<R extends AuthZRelation> = {
|
||||
children: ReactElement;
|
||||
relation: R;
|
||||
object: AuthZObject<R>;
|
||||
fallbackOnLoading?: JSX.Element;
|
||||
fallbackOnError?: JSX.Element;
|
||||
fallbackOnNoPermissions?: (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}) => JSX.Element;
|
||||
};
|
||||
|
||||
export function GuardAuthZ<R extends AuthZRelation>({
|
||||
children,
|
||||
relation,
|
||||
object,
|
||||
fallbackOnLoading,
|
||||
fallbackOnError,
|
||||
fallbackOnNoPermissions,
|
||||
}: GuardAuthZProps<R>): JSX.Element | null {
|
||||
const permission = buildPermission<R>(relation, object);
|
||||
|
||||
const { permissions, isLoading, error } = useAuthZ([permission]);
|
||||
|
||||
if (isLoading) {
|
||||
return fallbackOnLoading ?? null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fallbackOnError ?? children;
|
||||
}
|
||||
|
||||
if (!permissions?.[permission]?.isGranted) {
|
||||
return (
|
||||
fallbackOnNoPermissions?.({
|
||||
requiredPermissionName: permission,
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
permissionName="serviceaccount:read"
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
size="small"
|
||||
className={cx(styles.callout, className)}
|
||||
>
|
||||
<Typography.Text className={styles.permission}>
|
||||
<code className={styles.permissionCode}>user/{user.id}</code> is not
|
||||
authorized to perform{' '}
|
||||
<code className={styles.permissionCode}>{permissionName}</code>
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedCallout;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with a different permissionName', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="role:read" />);
|
||||
expect(screen.getByText(/role:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedFullPage;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
route: string;
|
||||
Component: () => JSX.Element;
|
||||
Component: ComponentType;
|
||||
key: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
import { DatePicker } from 'antd';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildSAAttachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate } from '../utils';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
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';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
|
||||
@@ -7,12 +7,12 @@ 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 AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildAPIKeyUpdatePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { disabledDate, formatLastObservedAt } from '../utils';
|
||||
|
||||
@@ -16,8 +16,8 @@ import type {
|
||||
import { AxiosError } from 'axios';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildAPIKeyUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
@@ -4,13 +4,13 @@ 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 AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
APIKeyCreatePermission,
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSAAttachPermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import RolesSelect from 'components/RolesSelect';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
|
||||
import { buildSAUpdatePermission } from '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';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import {
|
||||
buildAPIKeyDeletePermission,
|
||||
buildSADetachPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
|
||||
@@ -16,7 +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 'components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
import { useRoles } from 'components/RolesSelect';
|
||||
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
|
||||
import {
|
||||
@@ -35,8 +35,8 @@ import {
|
||||
buildSADeletePermission,
|
||||
buildSAReadPermission,
|
||||
buildSAUpdatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import APIError from 'types/api/error';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import AddKeyModal from './AddKeyModal';
|
||||
import DeleteAccountModal from './DeleteAccountModal';
|
||||
import KeysTab from './KeysTab';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import EditKeyModal from '../EditKeyModal';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import KeysTab from '../KeysTab';
|
||||
|
||||
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
setupAuthzDenyAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
APIKeyListPermission,
|
||||
buildSADeletePermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'tests/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ServiceAccountDrawer from '../ServiceAccountDrawer';
|
||||
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
|
||||
import { createGuardedRoute } from './createGuardedRoute';
|
||||
|
||||
describe('createGuardedRoute', () => {
|
||||
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
|
||||
<div>Test Component: {testProp}</div>
|
||||
);
|
||||
|
||||
it('should render component when permission is granted', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should substitute route parameters in object string', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple route parameters', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const txn = payload[0];
|
||||
const responseData: AuthtypesGettableTransactionDTO[] = [
|
||||
{
|
||||
relation: txn.relation,
|
||||
object: {
|
||||
resource: {
|
||||
kind: txn.object.resource.kind,
|
||||
type: txn.object.resource.type,
|
||||
},
|
||||
selector: '123:456',
|
||||
},
|
||||
authorized: true,
|
||||
},
|
||||
];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: responseData, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}:{version}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123', version: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id/:version',
|
||||
url: '/dashboard/123/456',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep placeholder when route parameter is missing', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render loading fallback when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
expect(screen.getByText('SigNoz')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component when API error occurs (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render no permissions fallback when 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, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const heading = document.querySelector('h3');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading?.textContent).toMatch(/not authorized/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass all props to wrapped component', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const ComponentWithMultipleProps = ({
|
||||
prop1,
|
||||
prop2,
|
||||
prop3,
|
||||
}: {
|
||||
prop1: string;
|
||||
prop2: number;
|
||||
prop3: boolean;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
{prop1} - {prop2} - {prop3.toString()}
|
||||
</div>
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
ComponentWithMultipleProps,
|
||||
'read',
|
||||
'role:*',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
prop1: 'value1',
|
||||
prop2: 42,
|
||||
prop3: true,
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should memoize resolved object based on route params', async () => {
|
||||
let requestCount = 0;
|
||||
const requestedObjects: string[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const obj = payload[0]?.object;
|
||||
const kind = obj?.resource?.kind;
|
||||
const selector = obj?.selector ?? '*';
|
||||
const objectStr = `${kind}:${selector}`;
|
||||
requestedObjects.push(objectStr ?? '');
|
||||
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch1 = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props1 = {
|
||||
testProp: 'test-value-1',
|
||||
match: mockMatch1,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
const { unmount } = render(<GuardedComponent {...props1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(requestedObjects).toContain('role:123');
|
||||
|
||||
unmount();
|
||||
|
||||
const mockMatch2 = {
|
||||
params: { id: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/456',
|
||||
};
|
||||
|
||||
const props2 = {
|
||||
testProp: 'test-value-2',
|
||||
match: mockMatch2,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props2} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(requestedObjects).toContain('role:456');
|
||||
});
|
||||
|
||||
it('should handle different relation types', 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, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'delete',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '789' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/789',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
.guard-authz-error-no-authz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
.guard-authz-error-no-authz-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--l3-foreground);
|
||||
line-height: 18px;
|
||||
|
||||
span {
|
||||
background-color: var(--l3-background);
|
||||
white-space: nowrap;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ComponentType, ReactElement, useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import noDataUrl from '@/assets/Icons/no-data.svg';
|
||||
|
||||
import AppLoading from '../AppLoading/AppLoading';
|
||||
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
import './createGuardedRoute.styles.scss';
|
||||
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="guard-authz-error-no-authz">
|
||||
<div className="guard-authz-error-no-authz-content">
|
||||
<img src={noDataUrl} alt="No permission" />
|
||||
<h3>Uh-oh! You are not authorized</h3>
|
||||
<p>
|
||||
<code>user/{user.id}</code> is not authorized to perform{' '}
|
||||
<code>{formatPermission(response.requiredPermissionName)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
|
||||
Component: ComponentType<P>,
|
||||
relation: R,
|
||||
object: AuthZObject<R>,
|
||||
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
|
||||
return function GuardedRouteComponent(
|
||||
props: P & RouteComponentProps<Record<string, string>>,
|
||||
): ReactElement {
|
||||
const resolvedObject = useMemo(() => {
|
||||
const paramPattern = /\{([^}]+)\}/g;
|
||||
return object.replace(paramPattern, (match, paramName) => {
|
||||
const paramValue = props.match?.params?.[paramName];
|
||||
return paramValue !== undefined ? paramValue : match;
|
||||
}) as AuthZObject<R>;
|
||||
}, [props.match?.params]);
|
||||
|
||||
return (
|
||||
<GuardAuthZ
|
||||
relation={relation}
|
||||
object={resolvedObject}
|
||||
fallbackOnLoading={<AppLoading />}
|
||||
fallbackOnNoPermissions={(response): ReactElement => (
|
||||
<OnNoPermissionsFallback {...response} />
|
||||
)}
|
||||
>
|
||||
<Component {...props} />
|
||||
</GuardAuthZ>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import { useCallback, useMemo, useRef } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Skeleton } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import TimeSeries from 'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import {
|
||||
@@ -13,13 +14,13 @@ import {
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { AlignedData, Options } from 'uplot';
|
||||
|
||||
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
|
||||
import { buildEntityMetricsChartConfig } from './configBuilder';
|
||||
|
||||
import { useEntityMetrics } from './hooks';
|
||||
import { isKeyNotFoundError } from '../utils';
|
||||
@@ -70,7 +71,7 @@ function EntityMetrics<T>({
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
const { queries, chartData, queryPayloads } = useEntityMetrics({
|
||||
const { queries, chartData, tableData, queryPayloads } = useEntityMetrics({
|
||||
queryKey,
|
||||
timeRange,
|
||||
entity,
|
||||
@@ -80,16 +81,10 @@ function EntityMetrics<T>({
|
||||
});
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
@@ -101,43 +96,39 @@ function EntityMetrics<T>({
|
||||
[handleTimeChange],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
const configs = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, idx) => {
|
||||
const panelType = queryPayloads[idx]?.graphType;
|
||||
if (panelType === PANEL_TYPES.TABLE) {
|
||||
return null;
|
||||
}
|
||||
return getUPlotChartOptions({
|
||||
apiResponse: data?.payload,
|
||||
const widgetTitle = entityWidgetInfo[idx].title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-');
|
||||
return buildEntityMetricsChartConfig({
|
||||
id: `${category}-${widgetTitle}`,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse: data?.payload,
|
||||
timezone,
|
||||
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: timeRange.startTime,
|
||||
maxTimeScale: timeRange.endTime,
|
||||
onDragSelect,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}): void => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
});
|
||||
}),
|
||||
[
|
||||
queries,
|
||||
queryPayloads,
|
||||
category,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
entityWidgetInfo,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
onDragSelect,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -170,14 +161,22 @@ function EntityMetrics<T>({
|
||||
>
|
||||
{panelType === PANEL_TYPES.TABLE ? (
|
||||
<MetricsTable
|
||||
rows={chartData[idx]?.[0]?.rows ?? []}
|
||||
columns={chartData[idx]?.[0]?.columns ?? []}
|
||||
rows={tableData[idx]?.[0]?.rows ?? []}
|
||||
columns={tableData[idx]?.[0]?.columns ?? []}
|
||||
/>
|
||||
) : (
|
||||
<Uplot
|
||||
options={options[idx] as Options}
|
||||
data={chartData[idx] as AlignedData}
|
||||
/>
|
||||
configs[idx] &&
|
||||
chartData[idx] && (
|
||||
<TimeSeries
|
||||
config={configs[idx]}
|
||||
data={chartData[idx]}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
yAxisUnit={entityWidgetInfo[idx].yAxisUnit}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
|
||||
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import * as appContextHooks from 'providers/App/App';
|
||||
import { LicenseEvent } from 'types/api/licensesV3/getActive';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import EntityMetrics from '../EntityMetrics';
|
||||
import { useEntityMetrics } from '../hooks';
|
||||
@@ -15,12 +16,15 @@ const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
|
||||
typeof useEntityMetrics
|
||||
>;
|
||||
|
||||
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
|
||||
getUPlotChartOptions: jest.fn().mockReturnValue({}),
|
||||
jest.mock('../configBuilder', () => ({
|
||||
buildEntityMetricsChartConfig: jest.fn().mockReturnValue({
|
||||
getId: jest.fn().mockReturnValue('mock-id'),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
|
||||
getUPlotChartData: jest.fn().mockReturnValue([]),
|
||||
jest.mock('lib/uPlotV2/utils/dataUtils', () => ({
|
||||
prepareChartData: jest.fn().mockReturnValue([]),
|
||||
hasSingleVisiblePoint: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
@@ -30,9 +34,20 @@ jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('components/Uplot', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="uplot-chart">Uplot Chart</div>,
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/TimeSeries/TimeSeries',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => (
|
||||
<div data-testid="uplot-chart">TimeSeries Chart</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): { timezone: { value: string } } => ({
|
||||
timezone: { value: 'UTC' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../MetricsTable', () => ({
|
||||
@@ -294,20 +309,28 @@ const renderEntityMetrics = (overrides = {}): any => {
|
||||
);
|
||||
};
|
||||
|
||||
const mockChartData = [
|
||||
[], // time_series chart data (uplot handles empty array)
|
||||
const mockChartData: (uPlot.AlignedData | null)[] = [
|
||||
[
|
||||
[1705315200, 1705318800],
|
||||
[42.5, 43.2],
|
||||
], // time_series chart data (AlignedData)
|
||||
null, // table uses tableData
|
||||
];
|
||||
|
||||
const mockTableData: (import('../utils').MetricsTableData[] | null)[] = [
|
||||
null, // time_series uses chartData
|
||||
[
|
||||
{
|
||||
rows: [
|
||||
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
|
||||
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
|
||||
{ timestamp: '2024-01-15T10:00:00Z', value: '1024' },
|
||||
{ timestamp: '2024-01-15T10:01:00Z', value: '1028' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
|
||||
{ key: 'value', label: 'Value', isValueColumn: true },
|
||||
],
|
||||
},
|
||||
], // table chart data
|
||||
], // table data
|
||||
];
|
||||
|
||||
const mockQueryPayloads = [
|
||||
@@ -321,6 +344,7 @@ describe('EntityMetrics', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockQueries as any,
|
||||
chartData: mockChartData,
|
||||
tableData: mockTableData,
|
||||
queryPayloads: mockQueryPayloads as any,
|
||||
});
|
||||
mockUseQuery.mockReturnValue({
|
||||
@@ -351,7 +375,8 @@ describe('EntityMetrics', () => {
|
||||
it('renders loading state when fetching metrics', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockLoadingQueries as any,
|
||||
chartData: [[], []],
|
||||
chartData: [null, null],
|
||||
tableData: [null, null],
|
||||
queryPayloads: mockQueryPayloads as any,
|
||||
});
|
||||
renderEntityMetrics();
|
||||
@@ -362,7 +387,8 @@ describe('EntityMetrics', () => {
|
||||
it('renders error state when query fails', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockErrorQueries as any,
|
||||
chartData: [[], []],
|
||||
chartData: [null, null],
|
||||
tableData: [null, null],
|
||||
queryPayloads: mockQueryPayloads as any,
|
||||
});
|
||||
renderEntityMetrics();
|
||||
@@ -373,8 +399,9 @@ describe('EntityMetrics', () => {
|
||||
it('renders empty state when no metrics data', () => {
|
||||
mockUseEntityMetrics.mockReturnValue({
|
||||
queries: mockEmptyQueries as any,
|
||||
chartData: [
|
||||
[],
|
||||
chartData: [[[]], null],
|
||||
tableData: [
|
||||
null,
|
||||
[
|
||||
{
|
||||
rows: [],
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
DrawStyle,
|
||||
FillMode,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
SelectionPreferencesSource,
|
||||
} from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { hasSingleVisiblePoint } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { get } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export interface EntityMetricsChartConfigProps {
|
||||
id: string;
|
||||
isDarkMode: boolean;
|
||||
currentQuery: Query;
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
apiResponse?: MetricRangePayloadProps;
|
||||
timezone: Timezone;
|
||||
yAxisUnit: string;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
}
|
||||
|
||||
export function buildEntityMetricsChartConfig({
|
||||
id,
|
||||
isDarkMode,
|
||||
currentQuery,
|
||||
onDragSelect,
|
||||
apiResponse,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
}: EntityMetricsChartConfigProps): UPlotConfigBuilder {
|
||||
const stepIntervals = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
) as Record<string, number>;
|
||||
const minStepInterval = Object.keys(stepIntervals).length
|
||||
? Math.min(...Object.values(stepIntervals))
|
||||
: undefined;
|
||||
|
||||
const tzDate = (timestamp: number): Date =>
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
id,
|
||||
onDragSelect,
|
||||
tzDate,
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
stepInterval: minStepInterval,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: minTimeScale,
|
||||
max: maxTimeScale,
|
||||
});
|
||||
|
||||
builder.addScale({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
show: true,
|
||||
side: 2,
|
||||
isDarkMode,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
builder.addAxis({
|
||||
scaleKey: 'y',
|
||||
show: true,
|
||||
side: 3,
|
||||
isDarkMode,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
if (!apiResponse?.data?.result) {
|
||||
return builder;
|
||||
}
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const hasSingleValidPoint = hasSingleVisiblePoint(series.values);
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = getLegend(series, currentQuery, baseLabelName);
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
|
||||
label,
|
||||
colorMapping: {},
|
||||
spanGaps: true,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: hasSingleValidPoint,
|
||||
pointSize: 5,
|
||||
fillMode: FillMode.None,
|
||||
isDarkMode,
|
||||
metric: series.metric,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import {
|
||||
GetMetricQueryRange,
|
||||
GetQueryResultsProps,
|
||||
} from 'lib/dashboard/getQueryResults';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { FeatureKeys } from '../../../../constants/features';
|
||||
import { useAppContext } from '../../../../providers/App/App';
|
||||
import { getMetricsTableData } from './utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { getMetricsTableData, MetricsTableData } from './utils';
|
||||
|
||||
export interface UseEntityMetricsParams<T> {
|
||||
queryKey: string;
|
||||
@@ -32,10 +33,8 @@ export interface UseEntityMetricsParams<T> {
|
||||
|
||||
export interface UseEntityMetricsResult {
|
||||
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
|
||||
chartData: (
|
||||
| ReturnType<typeof getUPlotChartData>
|
||||
| ReturnType<typeof getMetricsTableData>
|
||||
)[];
|
||||
chartData: (uPlot.AlignedData | null)[];
|
||||
tableData: (MetricsTableData[] | null)[];
|
||||
queryPayloads: GetQueryResultsProps[];
|
||||
}
|
||||
|
||||
@@ -93,9 +92,22 @@ export function useEntityMetrics<T>({
|
||||
() =>
|
||||
queries.map(({ data }, index) => {
|
||||
const panelType = queryPayloads[index]?.graphType;
|
||||
return panelType === PANEL_TYPES.TABLE
|
||||
? getMetricsTableData(data)
|
||||
: getUPlotChartData(data?.payload);
|
||||
if (panelType === PANEL_TYPES.TABLE) {
|
||||
return null;
|
||||
}
|
||||
return data?.payload ? prepareChartData(data.payload) : null;
|
||||
}),
|
||||
[queries, queryPayloads],
|
||||
);
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
queries.map(({ data }, index) => {
|
||||
const panelType = queryPayloads[index]?.graphType;
|
||||
if (panelType !== PANEL_TYPES.TABLE) {
|
||||
return null;
|
||||
}
|
||||
return getMetricsTableData(data);
|
||||
}),
|
||||
[queries, queryPayloads],
|
||||
);
|
||||
@@ -103,6 +115,7 @@ export function useEntityMetrics<T>({
|
||||
return {
|
||||
queries,
|
||||
chartData,
|
||||
tableData,
|
||||
queryPayloads,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useRolesFeatureGate } from 'hooks/useRolesFeatureGate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { mockUseAuthZDenyAll } from 'tests/authz-test-utils';
|
||||
import { mockUseAuthZDenyAll } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -3,12 +3,12 @@ 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 { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const rolesApiBase = '*/api/v1/roles';
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantByPrefix,
|
||||
} from 'tests/authz-test-utils';
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import CreateEditRolePage from '../CreateEditRolePage';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const EDIT_ROLE_ID = 'test-role-123';
|
||||
|
||||
@@ -4,12 +4,12 @@ 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 { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
async function expandAllCards(): Promise<void> {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getResourcePanel } from '../../permissions.config';
|
||||
import ItemInputSelector from './ItemInputSelector';
|
||||
|
||||
import styles from './ActionToggle.module.scss';
|
||||
import { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { getActionLabel } from 'container/RolesSettings/ViewRolePage/components/permissionDisplay.utils';
|
||||
|
||||
const SCOPE_LABELS: Record<PermissionScope, string> = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ConfirmDialog } from '@signozhq/ui/dialog';
|
||||
import { RadioGroup, RadioGroupItem } from '@signozhq/ui/radio-group';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Skeleton } from 'antd';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import { PermissionScope, ResourcePermissions } from '../../types';
|
||||
import type { EditorMode, JsonEditorRef } from './JsonEditor.types';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight } from '@signozhq/icons';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Monaco } from '@monaco-editor/react';
|
||||
import permissionsType from 'hooks/useAuthZ/permissions.type';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import transactionGroupSchema from 'schemas/generated/transactionGroups.schema.json';
|
||||
|
||||
export const TRANSACTION_GROUP_SCHEMA = transactionGroupSchema;
|
||||
|
||||
@@ -5,10 +5,10 @@ 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 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
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 LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useHistory } from 'react-router-dom';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { RoleCreatePermission } from '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';
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import { useDeleteRoleModal } from 'container/RolesSettings/DeleteRoleModal/useDeleteRoleModal';
|
||||
import { useRoleAuthZ } from 'container/RolesSettings/hooks/useRoleAuthZ';
|
||||
import { transformApiToRolePermissions } from 'container/RolesSettings/hooks/useRolePermissions';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 'hooks/useAuthZ/useAuthZ';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
mockUseAuthZGrantByPrefix,
|
||||
} from 'tests/authz-test-utils';
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 'hooks/useAuthZ/useAuthZ';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { defaultFeatureFlags, render, screen } from 'tests/test-utils';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ViewRolePage from '../ViewRolePage';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { customRoleResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/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';
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import {
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import * as roleApi from 'api/generated/services/role';
|
||||
import * as useAuthZModule from 'hooks/useAuthZ/useAuthZ';
|
||||
import * as useAuthZModule from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { mockUseAuthZGrantAll } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import * as useRolePermissionsModule from '../../hooks/useRolePermissions';
|
||||
|
||||
|
||||
@@ -11,12 +11,15 @@ import {
|
||||
userEvent,
|
||||
} from 'tests/test-utils';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const rolesApiURL = 'http://localhost/api/v1/roles';
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
CoretypesKindDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import {
|
||||
ActionConfig,
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
buildRoleReadPermission,
|
||||
buildRoleUpdatePermission,
|
||||
RoleCreatePermission,
|
||||
} from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { ParsedPermissionObject, parsePermission } from 'hooks/useAuthZ/utils';
|
||||
} 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;
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
useGetRole,
|
||||
useUpdateRole,
|
||||
} from 'api/generated/services/role';
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
import {
|
||||
getResourcePanel,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Bot, Key, Shield } from '@signozhq/icons';
|
||||
|
||||
import permissionsType from 'hooks/useAuthZ/permissions.type';
|
||||
import permissionsType from 'lib/authz/hooks/useAuthZ/permissions.type';
|
||||
import {
|
||||
AuthZResource,
|
||||
AuthZVerb,
|
||||
OBJECT_SCOPED_VERBS,
|
||||
ObjectScopedVerb,
|
||||
} from 'hooks/useAuthZ/types';
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { CoretypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/** Shared shape of the icon components exported by `@signozhq/icons`. */
|
||||
@@ -84,7 +84,7 @@ export function getResourceVerbs(
|
||||
}
|
||||
|
||||
// Role resource cannot have assignee verb
|
||||
// TODO(H4ad): Remove this once we get rid of frontend/src/hooks/useAuthZ/legacy.ts
|
||||
// TODO(H4ad): Remove this once we get rid of frontend/lib/authz/hooks/useAuthZ/legacy.ts
|
||||
if (resource === 'role') {
|
||||
return match.allowedVerbs.filter((verb) => verb !== 'assignee');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthZResource, AuthZVerb } from 'hooks/useAuthZ/types';
|
||||
import type { AuthZResource, AuthZVerb } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { CoretypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export enum PermissionScope {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import ServiceAccountsSettings from './ServiceAccountsSettings';
|
||||
|
||||
const SA_LIST_URL = 'http://localhost/api/v1/service_accounts';
|
||||
|
||||
@@ -4,10 +4,10 @@ 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 AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import PermissionDeniedFullPage from 'components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ServiceAccountDrawer from 'components/ServiceAccountDrawer/ServiceAccountDrawer';
|
||||
import ServiceAccountsTable, {
|
||||
@@ -16,8 +16,8 @@ import ServiceAccountsTable, {
|
||||
import {
|
||||
SACreatePermission,
|
||||
SAListPermission,
|
||||
} from 'hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { setupAuthzAdmin } from 'tests/authz-test-utils';
|
||||
import { setupAuthzAdmin } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import ServiceAccountsSettings from '../ServiceAccountsSettings';
|
||||
|
||||
|
||||
21
frontend/src/lib/authz/README.md
Normal file
21
frontend/src/lib/authz/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# AuthZ
|
||||
|
||||
Permission-based authorization system for SigNoz frontend.
|
||||
|
||||
## Supported Resources
|
||||
|
||||
See [hooks/useAuthZ/permissions.type.ts](./hooks/useAuthZ/permissions.type.ts) for available resources and verbs.
|
||||
|
||||
If your page/content represents a resource not listed there, skip authz implementation — the backend doesn't enforce it yet.
|
||||
|
||||
## UI Gating
|
||||
|
||||
Need to gate UI based on permissions? See [components/README.md](./components/README.md).
|
||||
|
||||
Covers: AuthZButton, AuthZTooltip, withAuthZ*, AuthZGuard*, when to use each.
|
||||
|
||||
## Testing
|
||||
|
||||
Need to test authz behavior? See [utils/README.md](./utils/README.md).
|
||||
|
||||
Covers: MSW handlers, mock hooks, test patterns.
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import AuthZButton from './AuthZButton';
|
||||
|
||||
// AuthZButton is a thin composition over AuthZTooltip + Button. The denial
|
||||
// tooltip / disabled-on-deny UX is owned and tested by AuthZTooltip; here we
|
||||
// assert AuthZButton forwards the right props and renders a Button child.
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip');
|
||||
const mockTooltip = AuthZTooltip as unknown as jest.Mock;
|
||||
|
||||
const createPerm = buildPermission(
|
||||
'create',
|
||||
'serviceaccount:*' as AuthZObject<'create'>,
|
||||
);
|
||||
|
||||
describe('AuthZButton', () => {
|
||||
beforeEach(() => {
|
||||
mockTooltip.mockImplementation(
|
||||
({ children }: { children: ReactElement }) => children,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockTooltip.mockReset();
|
||||
});
|
||||
|
||||
it('renders a Button child with forwarded props', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('create-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('create-btn').tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('forwards checks and enables the check by default', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip).toHaveBeenCalledTimes(1);
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
checks: [createPerm],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a custom tooltipMessage', () => {
|
||||
render(
|
||||
<AuthZButton
|
||||
checks={[createPerm]}
|
||||
tooltipMessage="Ask an admin"
|
||||
testId="create-btn"
|
||||
>
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
tooltipMessage: 'Ask an admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes authZEnabled through as the tooltip enabled flag', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({ enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button, ButtonProps } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type AuthZButtonProps = ButtonProps & {
|
||||
/**
|
||||
* Permissions required to enable the button (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/**
|
||||
* Override the default denial tooltip message.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* Gate the permission check itself. When false, renders a plain button.
|
||||
*/
|
||||
authZEnabled?: boolean;
|
||||
};
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
</AuthZTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthZButton;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { AuthZGuard } from './AuthZGuard';
|
||||
import { AuthZGuardContent } from './AuthZGuardContent';
|
||||
import { AuthZGuardPage } from './AuthZGuardPage';
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
const Protected = (): JSX.Element => <div>Protected content</div>;
|
||||
|
||||
describe('AuthZGuard', () => {
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes denied permissions to a function fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<div>denied: {deniedPermissions.length}</div>
|
||||
)}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied: 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing for a denied check with no fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
const { container } = render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the loading fallback while checking', () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallbackOnLoading={<div>Loading…</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fails open on error by default (renders children)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback on error when failOpenOnError is false', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
onFailRenderContent={false}
|
||||
fallback={<div>No access</div>}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the app loader while checking', () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
82
frontend/src/lib/authz/components/AuthZGuard/AuthZGuard.tsx
Normal file
82
frontend/src/lib/authz/components/AuthZGuard/AuthZGuard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ReactElement, ReactNode, useMemo } from 'react';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export type AuthZGuardFallback =
|
||||
| ReactNode
|
||||
| ((info: { deniedPermissions: BrandedPermission[] }) => ReactNode);
|
||||
|
||||
export type AuthZGuardProps = {
|
||||
/**
|
||||
* Permissions required to render `children` (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
/**
|
||||
* Rendered when denied. A function receives the denied permissions.
|
||||
*/
|
||||
fallback?: AuthZGuardFallback;
|
||||
fallbackOnLoading?: ReactNode;
|
||||
/**
|
||||
* By default, we don't expect the check API request to fail, in those cases, we prefer to show the content and then let the API fail (during list/create).
|
||||
*
|
||||
* In case you want to have a different behavior when request fail, set to false.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
onFailRenderContent?: boolean;
|
||||
};
|
||||
|
||||
function resolveFallback(
|
||||
fallback: AuthZGuardFallback | undefined,
|
||||
deniedPermissions: BrandedPermission[],
|
||||
): ReactNode {
|
||||
if (typeof fallback === 'function') {
|
||||
return fallback({ deniedPermissions });
|
||||
}
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
export function AuthZGuard({
|
||||
checks,
|
||||
children,
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
onFailRenderContent = true,
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
const { isLoading, error, permissions } = useAuthZ(checks);
|
||||
|
||||
// TODO(authz): Use allowed/deniedPermissions from useAuthZ after devtools PR merges
|
||||
const { allowed, deniedPermissions } = useMemo(() => {
|
||||
if (!permissions) {
|
||||
return { allowed: false, deniedPermissions: [] as BrandedPermission[] };
|
||||
}
|
||||
|
||||
const denied = Object.entries(permissions)
|
||||
.filter(([, { isGranted }]) => !isGranted)
|
||||
.map(([perm]) => perm as BrandedPermission);
|
||||
|
||||
return {
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
if (isLoading) {
|
||||
return <>{fallbackOnLoading ?? null}</>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onFailRenderContent ? (
|
||||
children
|
||||
) : (
|
||||
<>{resolveFallback(fallback, deniedPermissions)}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <>{resolveFallback(fallback, deniedPermissions)}</>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ReactElement } from 'react';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardContent({
|
||||
fallback,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ReactElement } from 'react';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardPage({
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallbackOnLoading={fallbackOnLoading ?? <AppLoading />}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { buildPermission } from 'hooks/useAuthZ/utils';
|
||||
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import type {
|
||||
AuthZObject,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import AuthZTooltip from './AuthZTooltip';
|
||||
|
||||
jest.mock('hooks/useAuthZ/useAuthZ');
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const noPermissions = {
|
||||
@@ -13,6 +16,8 @@ const noPermissions = {
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -157,11 +162,11 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
const button = screen.getByRole('button', { name: 'Action' });
|
||||
expect(button).toBeDisabled();
|
||||
|
||||
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(
|
||||
attachRolePerm,
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
.wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
@@ -1,16 +1,23 @@
|
||||
import { ReactElement, cloneElement, useMemo } from 'react';
|
||||
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import type { BrandedPermission } from 'hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { formatPermission } from 'hooks/useAuthZ/utils';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
cursor: 'not-allowed',
|
||||
};
|
||||
|
||||
const noOp = (): void => {};
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
@@ -49,11 +56,13 @@ function AuthZTooltip({
|
||||
}, [checks, permissions]);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return (
|
||||
<span className={styles.wrapper}>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
);
|
||||
return cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
@@ -64,12 +73,14 @@ function AuthZTooltip({
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={styles.wrapper}
|
||||
data-denied-permissions={deniedPermissions.join(',')}
|
||||
>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
})}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export interface PermissionDeniedCalloutProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
showIcon
|
||||
size="small"
|
||||
className={cx(styles.callout, className)}
|
||||
>
|
||||
<Typography.Text className={styles.permission}>
|
||||
<code className={styles.permissionCode}>user/{user.id}</code> is not
|
||||
authorized to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permissionCode}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedCallout;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { CircleSlash2 } from '@signozhq/icons';
|
||||
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export interface PermissionDeniedFullPageProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
<span className={styles.icon}>
|
||||
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
|
||||
</span>
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permission}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionDeniedFullPage;
|
||||
185
frontend/src/lib/authz/components/README.md
Normal file
185
frontend/src/lib/authz/components/README.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# AuthZ Components
|
||||
|
||||
Quick reference for permission-gating UI. All components use AND semantics: user needs ALL permissions in `checks` array.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to gate...
|
||||
├── A button? → AuthZButton
|
||||
├── Any element with tooltip on deny? → AuthZTooltip
|
||||
├── A section inside a page? → withAuthZContent (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardContent
|
||||
├── An entire page/route? → withAuthZPage (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardPage
|
||||
├── Need full control over fallback? → withAuthZ / AuthZGuard
|
||||
└── None of above fit?
|
||||
├── Can create wrapper component? → Create it (like AuthZButton)
|
||||
└── Last resort → useAuthZ hook directly
|
||||
```
|
||||
|
||||
## Building Permissions
|
||||
|
||||
Use `buildPermission`, `buildObjectString` or pre-built constants. Never cast with `as BrandedPermission`.
|
||||
|
||||
```tsx
|
||||
import { buildPermission, buildObjectString } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import {
|
||||
RoleCreatePermission,
|
||||
buildRoleReadPermission
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
// Static permission (pre-built)
|
||||
const checks = [RoleCreatePermission];
|
||||
|
||||
// Dynamic permission (builder fn)
|
||||
const checks = [buildRoleReadPermission(roleId)];
|
||||
|
||||
// Custom permission (buildPermission + buildObjectString)
|
||||
const checks = [buildPermission('read', buildObjectString('dashboard', dashboardId))];
|
||||
```
|
||||
|
||||
## Creating Permission Helpers
|
||||
|
||||
When adding authz to a new resource, create a permissions file under `lib/authz/hooks/useAuthZ/permissions/`.
|
||||
|
||||
```tsx
|
||||
// lib/authz/hooks/useAuthZ/permissions/dashboard.permissions.ts
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard, no specific id needed
|
||||
export const DashboardCreatePermission = buildPermission('create', 'dashboard:*');
|
||||
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
|
||||
|
||||
// Resource-level — require specific id
|
||||
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `dashboard:${id}`);
|
||||
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `dashboard:${id}`);
|
||||
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `dashboard:${id}`);
|
||||
```
|
||||
|
||||
Pattern:
|
||||
- `<Resource><Action>Permission` → collection-level const (wildcard `*`)
|
||||
- `build<Resource><Action>Permission(id)` → resource-level fn (specific id)
|
||||
|
||||
## Components
|
||||
|
||||
### AuthZButton
|
||||
|
||||
Button that disables + shows tooltip when denied.
|
||||
|
||||
```tsx
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZButton checks={[SACreatePermission]} onClick={handleCreate}>
|
||||
Create
|
||||
</AuthZButton>
|
||||
```
|
||||
|
||||
### AuthZTooltip
|
||||
|
||||
Wraps any element. Disables child + shows denial tooltip.
|
||||
|
||||
```tsx
|
||||
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZTooltip checks={[buildSADeletePermission(accountId)]}>
|
||||
<IconButton icon={<Trash />} onClick={handleDelete} />
|
||||
</AuthZTooltip>
|
||||
```
|
||||
|
||||
### withAuthZPage (preferred for pages)
|
||||
|
||||
HOC for route-level gating. Wrap at export. Shows `PermissionDeniedFullPage` + `AppLoading`.
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RolesPage(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
export default withAuthZPage(RolesPage, {
|
||||
checks: [RoleListPermission],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZContent (preferred for sections)
|
||||
|
||||
HOC for inline sections. Shows `PermissionDeniedCallout` on deny.
|
||||
|
||||
```tsx
|
||||
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RoleEditor(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// Dynamic checks from route params
|
||||
export default withAuthZContent(RoleEditor, {
|
||||
checks: (_props, ctx) => [buildRoleReadPermission(ctx.params.roleId)],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZ
|
||||
|
||||
HOC base. No default fallback. Use when you need custom fallback.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export default withAuthZ(SecretPanel, {
|
||||
checks: [buildPermission('write', 'settings:org')],
|
||||
fallback: <p>No access</p>,
|
||||
});
|
||||
```
|
||||
|
||||
### AuthZGuardPage
|
||||
|
||||
JSX variant of `withAuthZPage`. Use when HOC not possible (conditional rendering).
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardPage checks={[RoleListPermission]}>
|
||||
<RolesPage />
|
||||
</AuthZGuardPage>
|
||||
```
|
||||
|
||||
### AuthZGuardContent
|
||||
|
||||
JSX variant of `withAuthZContent`. Use when HOC not possible.
|
||||
|
||||
```tsx
|
||||
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardContent checks={[RoleCreatePermission]}>
|
||||
<RoleEditor />
|
||||
</AuthZGuardContent>
|
||||
```
|
||||
|
||||
### AuthZGuard
|
||||
|
||||
JSX base guard. No default fallback. Use when you need custom fallback in JSX.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
<AuthZGuard
|
||||
checks={[buildPermission('write', 'settings:org')]}
|
||||
fallback={<p>No access</p>}
|
||||
fallbackOnLoading={<Spinner />}
|
||||
>
|
||||
<SecretContent />
|
||||
</AuthZGuard>
|
||||
```
|
||||
|
||||
## Fallback Components
|
||||
|
||||
Don't use these components directly, always prefer using via `withAuthZ` and their variants.
|
||||
|
||||
- PermissionDeniedCallout: inline error callout. Shows `user/{id} is not authorized to perform {permissions}`.
|
||||
- PermissionDeniedFullPage: full-page centered error. Same message, bigger presentation.
|
||||
@@ -0,0 +1,578 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from 'tests/test-utils';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
buildObjectString,
|
||||
buildPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { withAuthZ, RouterContext } from './withAuthZ';
|
||||
import { withAuthZContent } from './withAuthZContent';
|
||||
import { withAuthZPage } from './withAuthZPage';
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
const mockUseLocation = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): Record<string, string> => mockUseParams(),
|
||||
useLocation: (): { pathname: string; search: string } => mockUseLocation(),
|
||||
}));
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
function Base(): JSX.Element {
|
||||
return <div>Base component</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
});
|
||||
|
||||
describe('withAuthZ', () => {
|
||||
it('renders the wrapped component when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Base component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when denied without a fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the provided fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, {
|
||||
checks: [readPerm],
|
||||
fallback: <div>No access</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves checks from props via the selector form', async () => {
|
||||
type Props = { roleId: string };
|
||||
const RoleView = ({ roleId }: Props): JSX.Element => <div>role {roleId}</div>;
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-1'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const Guarded = withAuthZ<Props>(RoleView, {
|
||||
checks: ({ roleId }) => [
|
||||
buildPermission('read', buildObjectString<'read'>('role', roleId)),
|
||||
],
|
||||
fallback: <div>denied selector</div>,
|
||||
});
|
||||
|
||||
render(<Guarded roleId="r-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied selector')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role r-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets a descriptive displayName', () => {
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
expect(Guarded.displayName).toBe('withAuthZ(Base)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZPage(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZContent(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context', () => {
|
||||
it('extracts checks from route params via router.params', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-123' });
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-123'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from query params via router.searchParams', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/roles',
|
||||
search: '?roleId=r-456',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-456'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleListView = (): JSX.Element => <div>role list view</div>;
|
||||
const Guarded = withAuthZ(RoleListView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.searchParams.get('roleId') ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role list view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from pathname via router.matchPath', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/settings/roles/r-789/edit',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', 'r-789'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const EditRoleView = (): JSX.Element => <div>edit role</div>;
|
||||
const Guarded = withAuthZ(EditRoleView, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ roleId: string }>(
|
||||
'/settings/roles/:roleId/edit',
|
||||
);
|
||||
return match
|
||||
? [
|
||||
buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', match.roleId),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
},
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('denies when router-derived permission is not allowed', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-denied' });
|
||||
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-denied'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
fallback: <div>access denied</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('access denied')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role view')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context stability', () => {
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter(): JSX.Element {
|
||||
renderCount += 1;
|
||||
return <div data-testid="render-count">{renderCount}</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderCount = 0;
|
||||
server.use(setupAuthzGrantByPrefix('read||__||role'));
|
||||
});
|
||||
|
||||
it('does not re-render when useParams returns new object with same values', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME values — should not cause re-render
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
// Allow any pending effects to flush
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same pathname', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ id: string }>('/roles/:id');
|
||||
return match
|
||||
? [buildPermission('read', buildObjectString<'read'>('role', match.id))]
|
||||
: [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME pathname — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same search params', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
// Access searchParams to ensure it's part of the dependency chain
|
||||
void router.searchParams.get('tab');
|
||||
return [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME search — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when params values actually change', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// Return DIFFERENT values — re-mount with new mock values
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-2' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when pathname actually changes', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT pathname — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/users', search: '' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when search params actually change', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT search — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=details' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent cache invalidation', () => {
|
||||
const testPerm = buildPermission(
|
||||
'read',
|
||||
'role:test-invalidation' as AuthZObject<'read'>,
|
||||
);
|
||||
// Callout displays permission as "relation:object" format
|
||||
const displayedPerm = 'read:role:test-invalidation';
|
||||
|
||||
function ContentComponent(): JSX.Element {
|
||||
return <div data-testid="protected-content">Protected Content</div>;
|
||||
}
|
||||
|
||||
function InvalidationTrigger({
|
||||
permission,
|
||||
onReady,
|
||||
}: {
|
||||
permission: string;
|
||||
onReady: (invalidate: () => Promise<void>) => void;
|
||||
}): null {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
onReady(async () => {
|
||||
// Reset query to initial state and trigger refetch (matches devtools behavior)
|
||||
await queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
}, [queryClient, permission, onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('re-renders from allowed to denied when cache is invalidated', async () => {
|
||||
let shouldGrant = true;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
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, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially allowed - should show content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change server response to deny
|
||||
shouldGrant = false;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show denied callout
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Callout should show the denied permission
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText(displayedPerm)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-renders from denied to allowed when cache is invalidated', async () => {
|
||||
let shouldGrant = false;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
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, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially denied - should show callout
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
|
||||
// Change server response to allow
|
||||
shouldGrant = true;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show protected content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
13
frontend/src/lib/authz/components/withAuthZ/withAuthZ.tsx
Normal file
13
frontend/src/lib/authz/components/withAuthZ/withAuthZ.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuard } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export type { RouterContext, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZ<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuard, 'withAuthZ', Component, opts);
|
||||
}
|
||||
108
frontend/src/lib/authz/components/withAuthZ/withAuthZ.utils.tsx
Normal file
108
frontend/src/lib/authz/components/withAuthZ/withAuthZ.utils.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ComponentType, ReactElement, createElement, useMemo } from 'react';
|
||||
import {
|
||||
matchPath as reactRouterMatchPath,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type RouterContext = {
|
||||
/**
|
||||
* Route params from useParams (e.g. `/roles/:roleId` → `{ roleId: "r-1" }`)
|
||||
*/
|
||||
params: Record<string, string | undefined>;
|
||||
pathname: string;
|
||||
/**
|
||||
* Query params as URLSearchParams (use `.get('key')` to read)
|
||||
*/
|
||||
searchParams: URLSearchParams;
|
||||
/**
|
||||
* Extract params from pathname using a route pattern.
|
||||
* Returns null if pattern doesn't match.
|
||||
* @example router.matchPath<{ id: string }>('/edit/:id')?.id
|
||||
*/
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
) => Params | null;
|
||||
};
|
||||
|
||||
export type WithAuthZOptions<P> = {
|
||||
/**
|
||||
* Static checks, or a selector deriving them from props and router context.
|
||||
* Use router context to extract dynamic values from route params, pathname, or query params.
|
||||
* @example
|
||||
* // From route params
|
||||
* checks: (props, router) => [buildPermission('read', `role:${router.params.roleId}`)]
|
||||
* // From query params
|
||||
* checks: (props, router) => [buildPermission('read', `dashboard:${router.searchParams.get('id')}`)]
|
||||
* // From pathname matching
|
||||
* checks: (props, router) => {
|
||||
* const match = router.matchPath<{ id: string }>('/edit/:id');
|
||||
* return match ? [buildPermission('update', `role:${match.id}`)] : [];
|
||||
* }
|
||||
*/
|
||||
checks:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
fallback?: AuthZGuardProps['fallback'];
|
||||
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
|
||||
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
|
||||
};
|
||||
|
||||
function useStableParams(): Record<string, string | undefined> {
|
||||
const params = useParams();
|
||||
const paramsJson = JSON.stringify(params);
|
||||
return useMemo(() => JSON.parse(paramsJson), [paramsJson]);
|
||||
}
|
||||
|
||||
function useRouterContext(): RouterContext {
|
||||
const params = useStableParams();
|
||||
const { pathname, search } = useLocation();
|
||||
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
return useMemo(
|
||||
(): RouterContext => ({
|
||||
params,
|
||||
pathname,
|
||||
searchParams,
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
): Params | null => {
|
||||
const match = reactRouterMatchPath<Params>(pathname, {
|
||||
path: pattern,
|
||||
exact: false,
|
||||
});
|
||||
return match?.params ?? null;
|
||||
},
|
||||
}),
|
||||
[params, pathname, searchParams],
|
||||
);
|
||||
}
|
||||
|
||||
export function createAuthZHOC<P extends object>(
|
||||
Guard: ComponentType<AuthZGuardProps>,
|
||||
hocName: string,
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, ...guardProps } = opts;
|
||||
|
||||
function Wrapped(props: P): ReactElement | null {
|
||||
const router = useRouterContext();
|
||||
const resolvedChecks =
|
||||
typeof checks === 'function' ? checks(props, router) : checks;
|
||||
|
||||
return (
|
||||
<Guard checks={resolvedChecks} {...guardProps}>
|
||||
{createElement(Component, props)}
|
||||
</Guard>
|
||||
);
|
||||
}
|
||||
|
||||
Wrapped.displayName = `${hocName}(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZContent<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardContent, 'withAuthZContent', Component, opts);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardPage } from 'lib/authz/components/AuthZGuard/AuthZGuardPage';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZPage<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardPage, 'withAuthZPage', Component, opts);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user