Compare commits

...

7 Commits

37 changed files with 830 additions and 274 deletions

View File

@@ -165,6 +165,8 @@ function createMockAppContext(
orgPreferences: createMockOrgPreferences(),
userPreferences: [],
isLoggedIn: true,
isNoAuthMode: false,
isPreflightLoading: false,
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
isFetchingUser: false,
isFetchingActiveLicense: false,

View File

@@ -58,6 +58,7 @@ function App(): JSX.Element {
isLoggedIn: isLoggedInState,
featureFlags,
org,
isPreflightLoading,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
@@ -355,6 +356,10 @@ function App(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCloudUser, isEnterpriseSelfHostedUser]);
if (isPreflightLoading) {
return <Spinner tip="Loading..." />;
}
// if the user is in logged in state
if (isLoggedInState) {
// if the setup calls are loading then return a spinner

View File

@@ -0,0 +1,72 @@
import axios from 'axios';
import { getIsNoAuthMode } from 'utils/noAuthMode';
import { interceptorRejected } from '../index';
jest.mock('utils/noAuthMode', () => ({
getIsNoAuthMode: jest.fn(),
}));
jest.mock('api/v2/sessions/rotate/post', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('AppRoutes/utils', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../utils', () => ({
Logout: jest.fn(),
}));
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
const post = require('api/v2/sessions/rotate/post').default;
// oxlint-disable-next-line typescript/no-require-imports typescript/no-var-requires
const { Logout } = require('../utils');
describe('interceptorRejected — no-auth mode', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(axios, 'isAxiosError').mockReturnValue(true);
});
it('does NOT call rotate or Logout when no-auth mode is enabled on 401', async () => {
(getIsNoAuthMode as jest.Mock).mockReturnValue(true);
const error = {
isAxiosError: true,
response: {
status: 401,
config: { url: '/dashboards', method: 'get' },
},
config: { url: '/dashboards', headers: {} },
};
await interceptorRejected(error as any).catch(() => {});
expect(post).not.toHaveBeenCalled();
expect(Logout).not.toHaveBeenCalled();
});
it('DOES attempt rotate when no-auth mode is disabled on 401', async () => {
(getIsNoAuthMode as jest.Mock).mockReturnValue(false);
(post as jest.Mock).mockResolvedValue({
data: { accessToken: 'a', refreshToken: 'b' },
});
const error = {
isAxiosError: true,
response: {
status: 401,
config: { url: '/dashboards', method: 'get' },
},
config: { url: '/dashboards', headers: {} },
};
await interceptorRejected(error as any).catch(() => {});
expect(post).toHaveBeenCalled();
});
});

View File

@@ -13,6 +13,7 @@ import { Events } from 'constants/events';
import { LOCALSTORAGE } from 'constants/localStorage';
import { getBasePath } from 'utils/basePath';
import { eventEmitter } from 'utils/getEventEmitter';
import { getIsNoAuthMode } from 'utils/noAuthMode';
import apiV1, { apiAlertManager, apiV2, apiV3, apiV4, apiV5 } from './apiV1';
import { Logout } from './utils';
@@ -108,7 +109,10 @@ export const interceptorRejected = async (
if (axios.isAxiosError(value) && value.response) {
const { response } = value;
const isNoAuthMode = getIsNoAuthMode();
if (
!isNoAuthMode &&
response.status === 401 &&
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry!
response.config.url !== '/sessions/rotate' &&
@@ -140,16 +144,20 @@ export const interceptorRejected = async (
return await Promise.resolve(reResponse);
} catch (error) {
if ((error as AxiosError)?.response?.status === 401) {
Logout();
void Logout();
}
}
} catch (error) {
Logout();
void Logout();
}
}
if (response.status === 401 && response.config.url === '/sessions/rotate') {
Logout();
if (
!isNoAuthMode &&
response.status === 401 &&
response.config.url === '/sessions/rotate'
) {
void Logout();
}
}
return await Promise.reject(value);

View File

@@ -15,6 +15,7 @@ import {
} from 'api/generated/services/users';
import { AxiosError } from 'axios';
import { MemberRow } from 'components/MembersTable/MembersTable';
import { NoAuthGuard } from 'components/NoAuthGuard';
import RolesSelect, { useRoles } from 'components/RolesSelect';
import SaveErrorItem from 'components/ServiceAccountDrawer/SaveErrorItem';
import type { SaveError } from 'components/ServiceAccountDrawer/utils';
@@ -592,39 +593,43 @@ function EditMemberDrawer({
<div className="edit-member-drawer__footer-left">
<Tooltip title={getDeleteTooltip(isRootUser, isSelf)}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
<NoAuthGuard>
<Button
onClick={(): void => setShowDeleteConfirm(true)}
disabled={isRootUser || isSelf}
variant="link"
color="destructive"
>
<Trash2 size={12} />
{isInvited ? 'Revoke Invite' : 'Delete Member'}
</Button>
</NoAuthGuard>
</span>
</Tooltip>
<div className="edit-member-drawer__footer-divider" />
<Tooltip title={isRootUser ? ROOT_USER_TOOLTIP : undefined}>
<span className="edit-member-drawer__tooltip-wrapper">
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
<NoAuthGuard>
<Button
onClick={handleGenerateResetLink}
disabled={isGeneratingLink || isRootUser || isLoadingTokenStatus}
variant="link"
color="warning"
>
<RefreshCw size={12} />
{isGeneratingLink
? 'Generating...'
: isInvited
? getInviteButtonLabel(
isLoadingTokenStatus,
existingToken,
isTokenExpired,
tokenNotFound,
)
: 'Generate Password Reset Link'}
</Button>
</NoAuthGuard>
</span>
</Tooltip>
</div>
@@ -635,15 +640,17 @@ function EditMemberDrawer({
Cancel
</Button>
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
disabled={!isDirty || isSaving || isRootUser}
onClick={handleSave}
loading={isSaving}
>
{isSaving ? 'Saving...' : 'Save Member Details'}
</Button>
</NoAuthGuard>
</div>
</>
)}

View File

@@ -0,0 +1,3 @@
.banner {
height: var(--spacing-20);
}

View File

@@ -0,0 +1,18 @@
import { PersistedAnnouncementBanner } from '@signozhq/ui';
import styles from './NoAuthBanner.module.scss';
export function NoAuthBanner(): JSX.Element {
return (
<PersistedAnnouncementBanner
type="warning"
storageKey="no-auth-banner-v1"
testId="no-auth-banner"
className={styles.banner}
>
No-auth mode: authentication is disabled, network is the trust boundary.
</PersistedAnnouncementBanner>
);
}
export default NoAuthBanner;

View File

@@ -0,0 +1,17 @@
import { render, screen } from 'tests/test-utils';
import { NoAuthBanner } from '../NoAuthBanner';
describe('NoAuthBanner', () => {
it('renders the no-auth message', () => {
render(<NoAuthBanner />);
expect(
screen.getByText(/No-auth mode: authentication is disabled/i),
).toBeInTheDocument();
});
it('renders with the warning test id', () => {
render(<NoAuthBanner />);
expect(screen.getByTestId('no-auth-banner')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { Tooltip, TooltipProvider } from '@signozhq/ui';
import { useAppContext } from 'providers/App/App';
export const DEFAULT_MESSAGE = 'Not available in no-auth mode';
interface NoAuthGuardProps {
children: React.ReactElement;
message?: string;
}
export function NoAuthGuard({
children,
message = DEFAULT_MESSAGE,
}: NoAuthGuardProps): JSX.Element {
const { isNoAuthMode } = useAppContext();
if (!isNoAuthMode) {
return children;
}
const disabledChild = React.cloneElement(children, { disabled: true });
return (
<TooltipProvider>
<Tooltip title={message} arrow>
<span
data-no-auth-trigger
style={{ display: 'inline-flex', cursor: 'not-allowed' }}
>
{disabledChild}
</span>
</Tooltip>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,75 @@
import React from 'react';
import { render } from 'tests/test-utils';
import { DEFAULT_MESSAGE, NoAuthGuard } from '..';
describe('NoAuthGuard', () => {
it('renders children unchanged when isNoAuthMode is false', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: false } },
);
expect(getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('does not intercept onClick when isNoAuthMode is false', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<NoAuthGuard>
<button type="button" onClick={handleClick}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: false } },
);
getByRole('button', { name: 'Action' }).click();
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('disables children when isNoAuthMode is true', () => {
const { getByRole } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('renders a tooltip trigger wrapper when isNoAuthMode is true', () => {
const { container } = render(
<NoAuthGuard>
<button type="button">Action</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(
container.querySelector('span[data-no-auth-trigger]'),
).toBeInTheDocument();
});
it('overrides existing disabled prop — no-auth always wins', () => {
const { getByRole } = render(
// eslint-disable-next-line react/button-has-type
<NoAuthGuard>
<button type="button" disabled={false}>
Action
</button>
</NoAuthGuard>,
undefined,
{ appContextOverrides: { isNoAuthMode: true } },
);
expect(getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('exports DEFAULT_MESSAGE as a non-empty string', () => {
expect(typeof DEFAULT_MESSAGE).toBe('string');
expect(DEFAULT_MESSAGE.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1 @@
export { DEFAULT_MESSAGE, NoAuthGuard } from './NoAuthGuard';

View File

@@ -2,6 +2,8 @@ import { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui';
import { Skeleton, Table, Tooltip } from 'antd';
import { DEFAULT_MESSAGE, NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -23,6 +25,7 @@ interface KeysTabProps {
interface BuildColumnsParams {
isDisabled: boolean;
isNoAuthMode: boolean;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
@@ -42,6 +45,7 @@ function formatExpiry(expiresAt: number): JSX.Element {
function buildColumns({
isDisabled,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
@@ -92,23 +96,30 @@ function buildColumns({
key: 'action',
width: 48,
align: 'right' as const,
render: (_, record): JSX.Element => (
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
),
render: (_, record): JSX.Element => {
const tooltipTitle = isDisabled
? 'Service account disabled'
: isNoAuthMode
? DEFAULT_MESSAGE
: 'Revoke Key';
return (
<Tooltip title={tooltipTitle}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled || isNoAuthMode}
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</Tooltip>
);
},
},
];
}
@@ -134,6 +145,7 @@ function KeysTab({
parseAsString.withDefault(''),
);
const editKey = keys.find((k) => k.id === editKeyId) ?? null;
const { isNoAuthMode } = useAppContext();
const handleformatLastObservedAt = useCallback(
(lastObservedAt: Date | null | undefined): string =>
@@ -143,14 +155,20 @@ function KeysTab({
const onRevokeClick = useCallback(
(keyId: string): void => {
setRevokeKeyId(keyId);
void setRevokeKeyId(keyId);
},
[setRevokeKeyId],
);
const columns = useMemo(
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
[isDisabled, onRevokeClick, handleformatLastObservedAt],
() =>
buildColumns({
isDisabled,
isNoAuthMode,
onRevokeClick,
handleformatLastObservedAt,
}),
[isDisabled, isNoAuthMode, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
@@ -176,16 +194,18 @@ function KeysTab({
Learn more
</a>
</p>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
<NoAuthGuard>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</NoAuthGuard>
</div>
);
}

View File

@@ -42,6 +42,7 @@ import {
import APIError from 'types/api/error';
import { retryOn429, toAPIError } from 'utils/errorUtils';
import { NoAuthGuard } from 'components/NoAuthGuard';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -410,18 +411,20 @@ function ServiceAccountDrawer({
</ToggleGroupItem>
</ToggleGroup>
{activeTab === ServiceAccountDrawerTab.Keys && (
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
<NoAuthGuard>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</NoAuthGuard>
)}
</div>
@@ -500,16 +503,18 @@ function ServiceAccountDrawer({
) : (
<>
{!isDeleted && (
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
<NoAuthGuard>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</NoAuthGuard>
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">
@@ -517,15 +522,17 @@ function ServiceAccountDrawer({
<X size={14} />
Cancel
</Button>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
onClick={handleSave}
>
Save Changes
</Button>
</NoAuthGuard>
</div>
)}
</>

View File

@@ -4,6 +4,7 @@ import { useQueryClient } from 'react-query';
import { Button } from 'antd';
import type { NotificationInstance } from 'antd/es/notification/interface';
import deleteChannel from 'api/channels/delete';
import { NoAuthGuard } from 'components/NoAuthGuard';
import APIError from 'types/api/error';
function Delete({ notifications, id }: DeleteProps): JSX.Element {
@@ -35,14 +36,16 @@ function Delete({ notifications, id }: DeleteProps): JSX.Element {
};
return (
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
<NoAuthGuard>
<Button
loading={loading}
disabled={loading}
type="link"
onClick={onClickHandler}
>
Delete
</Button>
</NoAuthGuard>
);
}

View File

@@ -10,6 +10,7 @@ import { useGetMetricsOnboardingStatus } from 'api/generated/services/metrics';
import listUserPreferences from 'api/v1/user/preferences/list';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import Header from 'components/Header/Header';
import NoAuthBanner from 'components/NoAuthBanner/NoAuthBanner';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
@@ -56,7 +57,7 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element {
const { user } = useAppContext();
const { user, isNoAuthMode } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const isDarkMode = useIsDarkMode();
@@ -270,6 +271,7 @@ export default function Home(): JSX.Element {
return (
<div className="home-container">
{isNoAuthMode && <NoAuthBanner />}
<div className="sticky-header">
<Header
leftComponent={

View File

@@ -70,6 +70,7 @@ import {
TriangleAlert,
X,
} from 'lucide-react';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import {
@@ -1006,21 +1007,25 @@ function MultiIngestionSettings(): JSX.Element {
</div>
</div>
<div className="action-btn">
<Button
variant="link"
size="icon"
color="secondary"
suffix={<PenLine size={14} />}
aria-label="Edit ingestion key"
onClick={onEditKey}
/>
<Button
variant="link"
size="icon"
color="destructive"
suffix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
/>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="secondary"
suffix={<PenLine size={14} />}
aria-label="Edit ingestion key"
onClick={onEditKey}
/>
</NoAuthGuard>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="destructive"
suffix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
onClick={onDeleteKey}
/>
</NoAuthGuard>
</div>
</div>
),
@@ -1123,28 +1128,32 @@ function MultiIngestionSettings(): JSX.Element {
<div className="actions">
{hasLimits(signalName) ? (
<>
<Button
variant="link"
size="icon"
color="secondary"
prefix={<PenLine size={14} />}
aria-label={`Edit ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
/>
<Button
variant="link"
size="icon"
color="destructive"
prefix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
aria-label={`Delete ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onDeleteSignalLimit}
/>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="secondary"
prefix={<PenLine size={14} />}
aria-label={`Edit ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onEditSignalLimit}
/>
</NoAuthGuard>
<NoAuthGuard>
<Button
variant="link"
size="icon"
color="destructive"
prefix={<Trash2 color={Color.BG_CHERRY_500} size={14} />}
aria-label={`Delete ${signalName} limit`}
disabled={
!!(activeAPIKey?.id === APIKey?.id && activeSignal)
}
onClick={onDeleteSignalLimit}
/>
</NoAuthGuard>
</>
) : (
<Button
@@ -1679,14 +1688,16 @@ function MultiIngestionSettings(): JSX.Element {
onChange={handleSearch}
/>
<Button
variant="solid"
className="add-new-ingestion-key-btn"
prefix={<Plus size={14} />}
onClick={showAddModal}
>
New Ingestion key
</Button>
<NoAuthGuard>
<Button
variant="solid"
className="add-new-ingestion-key-btn"
prefix={<Plus size={14} />}
onClick={showAddModal}
>
New Ingestion key
</Button>
</NoAuthGuard>
</div>
<Table

View File

@@ -8,6 +8,7 @@ import { useListUsers } from 'api/generated/services/users';
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
import MembersTable, { MemberRow } from 'components/MembersTable/MembersTable';
import { NoAuthGuard } from 'components/NoAuthGuard';
import useUrlQuery from 'hooks/useUrlQuery';
import { toISOString } from 'utils/app';
@@ -20,7 +21,6 @@ const PAGE_SIZE = 20;
function MembersSettings(): JSX.Element {
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
@@ -145,7 +145,7 @@ function MembersSettings(): JSX.Element {
: `Deleted ⎯ ${deletedCount}`;
const handleInviteComplete = useCallback((): void => {
refetchUsers();
void refetchUsers();
}, [refetchUsers]);
const handleRowClick = useCallback((member: MemberRow): void => {
@@ -157,7 +157,7 @@ function MembersSettings(): JSX.Element {
}, []);
const handleMemberEditComplete = useCallback((): void => {
refetchUsers();
void refetchUsers();
}, [refetchUsers]);
return (
@@ -200,14 +200,16 @@ function MembersSettings(): JSX.Element {
/>
</div>
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
onClick={(): void => setIsInviteModalOpen(true)}
>
<Plus size={12} />
Invite member
</Button>
</NoAuthGuard>
</div>
</div>
<MembersTable

View File

@@ -7,6 +7,7 @@ import {
updateMyPassword,
useUpdateMyUserV2,
} from 'api/generated/services/users';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useNotifications } from 'hooks/useNotifications';
import { Check, FileTerminal, MailIcon, UserIcon } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
@@ -80,10 +81,10 @@ function UserInfo(): JSX.Element {
currentPassword === updatePassword;
const onSaveHandler = async (): Promise<void> => {
logEvent('Account Settings: Name Updated', {
void logEvent('Account Settings: Name Updated', {
name: changedName,
});
logEvent(
void logEvent(
'Account Settings: Name Updated',
{
name: changedName,
@@ -144,14 +145,16 @@ function UserInfo(): JSX.Element {
Update name
</Button>
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
<NoAuthGuard>
<Button
type="default"
className="periscope-btn secondary"
icon={<FileTerminal size={16} />}
onClick={(): void => setIsResetPasswordModalOpen(true)}
>
Reset password
</Button>
</NoAuthGuard>
</div>
<Modal

View File

@@ -16,6 +16,7 @@ import {
import { AxiosError } from 'axios';
import { FeatureKeys } from 'constants/features';
import { defaultTo } from 'lodash-es';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useAppContext } from 'providers/App/App';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ErrorV2Resp } from 'types/api';
@@ -256,14 +257,16 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
Cancel
</Button>
)}
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
<NoAuthGuard>
<Button
onClick={onSubmitHandler}
variant="solid"
color="primary"
loading={isCreating || isUpdating}
>
Save Changes
</Button>
</NoAuthGuard>
</section>
</div>
)}

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/ui';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
import {
@@ -65,7 +66,9 @@ function SSOEnforcementToggle({
};
return (
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
<NoAuthGuard>
<Switch disabled={isLoading} value={isChecked} onChange={onChangeHandler} />
</NoAuthGuard>
);
}

View File

@@ -14,6 +14,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import { NoAuthGuard } from 'components/NoAuthGuard';
import CopyToClipboard from 'periscope/components/CopyToClipboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -153,20 +154,24 @@ function AuthDomain(): JSX.Element {
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
<NoAuthGuard>
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.config?.ssoType || '')}
</Button>
</NoAuthGuard>
<NoAuthGuard>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</NoAuthGuard>
</section>
),
},
@@ -178,17 +183,19 @@ function AuthDomain(): JSX.Element {
<div className="auth-domain">
<section className="auth-domain-header">
<h3 className="auth-domain-title">Authenticated Domains</h3>
<Button
prefix={<PlusOutlined />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
<NoAuthGuard>
<Button
prefix={<PlusOutlined />}
onClick={(): void => {
setAddDomain(true);
}}
variant="solid"
size="sm"
color="primary"
>
Add Domain
</Button>
</NoAuthGuard>
</section>
{formattedError && <ErrorContent error={formattedError} />}
{!errorFetchingAuthDomainListResponse && (
@@ -231,15 +238,16 @@ function AuthDomain(): JSX.Element {
>
Cancel
</Button>,
<Button
key="submit"
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>,
<NoAuthGuard key="submit">
<Button
prefix={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>
</NoAuthGuard>,
]}
>
<p className="delete-text">

View File

@@ -16,6 +16,7 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { ErrorType } from 'api/generatedAPIInstance';
import ROUTES from 'constants/routes';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { handleApiError } from 'utils/errorUtils';
@@ -146,16 +147,17 @@ function CreateRoleModal({
<X size={14} />
Cancel
</Button>,
<Button
key="submit"
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>,
<NoAuthGuard key="submit">
<Button
variant="solid"
color="primary"
onClick={onSubmit}
loading={isLoading}
size="sm"
>
{isEditMode ? 'Save Changes' : 'Create Role'}
</Button>
</NoAuthGuard>,
]}
destroyOnClose
className="create-role-modal"

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button, Input } from '@signozhq/ui';
import { NoAuthGuard } from 'components/NoAuthGuard';
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from './config';
import CreateRoleModal from './RolesComponents/CreateRoleModal';
@@ -29,15 +30,17 @@ function RolesSettings(): JSX.Element {
onChange={(e): void => setSearchQuery(e.target.value)}
/>
{IS_ROLE_DETAILS_AND_CRUD_ENABLED && (
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
className="role-settings-toolbar-button"
onClick={(): void => setIsCreateModalOpen(true)}
>
<Plus size={14} />
Custom role
</Button>
</NoAuthGuard>
)}
</div>
<RolesListingTable searchQuery={searchQuery} />

View File

@@ -100,6 +100,8 @@ export function getAppContextMockState(
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
isNoAuthMode: false,
isPreflightLoading: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,

View File

@@ -28,6 +28,7 @@ import {
} from './utils';
import './ServiceAccountsSettings.styles.scss';
import { NoAuthGuard } from 'components/NoAuthGuard';
function ServiceAccountsSettings(): JSX.Element {
const [currentPage, setPage] = useQueryState(
@@ -111,9 +112,9 @@ function ServiceAccountsSettings(): JSX.Element {
const maxPage = Math.max(1, Math.ceil(filteredAccounts.length / PAGE_SIZE));
if (currentPage > maxPage) {
setPage(maxPage);
void setPage(maxPage);
} else if (currentPage < 1) {
setPage(1);
void setPage(1);
}
}, [filteredAccounts.length, currentPage, setPage]);
@@ -129,8 +130,8 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.All);
setPage(1);
void setFilterMode(FilterMode.All);
void setPage(1);
},
},
{
@@ -142,8 +143,8 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Active);
setPage(1);
void setFilterMode(FilterMode.Active);
void setPage(1);
},
},
{
@@ -155,8 +156,8 @@ function ServiceAccountsSettings(): JSX.Element {
</div>
),
onClick: (): void => {
setFilterMode(FilterMode.Deleted);
setPage(1);
void setFilterMode(FilterMode.Deleted);
void setPage(1);
},
},
];
@@ -175,7 +176,7 @@ function ServiceAccountsSettings(): JSX.Element {
const handleRowClick = useCallback(
(row: ServiceAccountRow): void => {
setSelectedAccountId(row.id);
void setSelectedAccountId(row.id);
},
[setSelectedAccountId],
);
@@ -183,9 +184,9 @@ function ServiceAccountsSettings(): JSX.Element {
const handleDrawerSuccess = useCallback(
(options?: { closeDrawer?: boolean }): void => {
if (options?.closeDrawer) {
setSelectedAccountId(null);
void setSelectedAccountId(null);
}
handleCreateSuccess();
void handleCreateSuccess();
},
[handleCreateSuccess, setSelectedAccountId],
);
@@ -231,23 +232,25 @@ function ServiceAccountsSettings(): JSX.Element {
placeholder="Search by name or email..."
value={searchQuery}
onChange={(e): void => {
setSearchQuery(e.target.value);
setPage(1);
void setSearchQuery(e.target.value);
void setPage(1);
}}
className="sa-settings-search-input"
/>
</div>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
<NoAuthGuard>
<Button
variant="solid"
color="primary"
onClick={async (): Promise<void> => {
await setIsCreateModalOpen(true);
}}
>
<Plus size={12} />
New Service Account
</Button>
</NoAuthGuard>
</div>
</div>

View File

@@ -1116,6 +1116,7 @@
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}
}

View File

@@ -131,6 +131,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
featureFlags,
trialInfo,
isLoggedIn,
isNoAuthMode,
userPreferences,
changelog,
toggleChangelogModal,
@@ -401,7 +402,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
);
const handleReorderShortcutNavItems = (): void => {
logEvent('Sidebar V2: Save shortcuts clicked', {
void logEvent('Sidebar V2: Save shortcuts clicked', {
shortcuts: tempPinnedMenuItems.map((item) => item.key),
});
setPinnedMenuItems(tempPinnedMenuItems);
@@ -429,7 +430,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const isWorkspaceBlocked = trialInfo?.workSpaceBlock || false;
const onClickGetStarted = (event: MouseEvent): void => {
logEvent('Sidebar: Menu clicked', {
void logEvent('Sidebar: Menu clicked', {
menuRoute: '/get-started',
menuLabel: 'Get Started',
});
@@ -482,12 +483,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
isNoAuthMode,
}),
[
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
user.email,
isWorkspaceBlocked,
isNoAuthMode,
],
);
@@ -628,7 +631,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else if (item) {
onClickHandler(item?.key as string, event);
}
logEvent('Sidebar V2: Menu clicked', {
void logEvent('Sidebar V2: Menu clicked', {
menuRoute: item?.key,
menuLabel: item?.label,
});
@@ -771,7 +774,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
onTogglePin={
allowPin
? (item): void => {
logEvent(
void logEvent(
`Sidebar V2: Menu item ${item.isPinned ? 'unpinned' : 'pinned'}`,
{
menuRoute: item.key,
@@ -818,7 +821,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const event = (info as SidebarItem & { domEvent?: MouseEvent }).domEvent;
if (item && !('type' in item)) {
logEvent('Help Popover: Item clicked', {
void logEvent('Help Popover: Item clicked', {
menuRoute: item.key,
menuLabel: String(item.label),
});
@@ -867,7 +870,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
menuLabel = item.label;
}
logEvent('Settings Popover: Item clicked', {
void logEvent('Settings Popover: Item clicked', {
menuRoute: item?.key,
menuLabel,
});
@@ -904,7 +907,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
}
break;
case 'logout':
Logout();
void Logout();
break;
default:
}
@@ -1058,7 +1061,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
<div
className="nav-section-title-icon reorder"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts clicked', {});
void logEvent('Sidebar V2: Manage shortcuts clicked', {});
setIsReorderShortcutNavItemsModalOpen(true);
}}
>
@@ -1105,7 +1108,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
return;
}
const newCollapsedState = !isMoreMenuCollapsed;
logEvent('Sidebar V2: More menu clicked', {
void logEvent('Sidebar V2: More menu clicked', {
action: isMoreMenuCollapsed ? 'expand' : 'collapse',
});
setIsMoreMenuCollapsed(newCollapsedState);
@@ -1209,14 +1212,14 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
open={isReorderShortcutNavItemsModalOpen}
closable
onCancel={(): void => {
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
hideReorderShortcutNavItemsModal();
}}
footer={[
<Button
key="cancel"
onClick={(): void => {
logEvent('Sidebar V2: Manage shortcuts dismissed', {});
void logEvent('Sidebar V2: Manage shortcuts dismissed', {});
hideReorderShortcutNavItemsModal();
}}
className="periscope-btn cancel-btn secondary-btn"

View File

@@ -5,6 +5,7 @@ const BASE_PARAMS = {
isWorkspaceBlocked: false,
isEnterpriseSelfHostedUser: false,
isCommunityEnterpriseUser: false,
isNoAuthMode: false,
};
describe('getUserSettingsDropdownMenuItems', () => {
@@ -71,4 +72,15 @@ describe('getUserSettingsDropdownMenuItems', () => {
expect(keys[3]).toBe('account');
expect(keys[keys.length - 1]).toBe('logout');
});
it('omits sign out and its preceding divider when isNoAuthMode=true', () => {
const items =
getUserSettingsDropdownMenuItems({ ...BASE_PARAMS, isNoAuthMode: true }) ??
[];
const keys = items.map((item: any) => item.key ?? item.type);
expect(keys).not.toContain('logout');
// the trailing divider before logout should also be gone
expect(keys[keys.length - 1]).toBe('keyboard-shortcuts');
});
});

View File

@@ -1,6 +1,13 @@
import { RocketOutlined } from '@ant-design/icons';
import { Style } from '@signozhq/design-tokens';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui';
import { MenuProps } from 'antd';
import { DEFAULT_MESSAGE } from 'components/NoAuthGuard';
import ROUTES from 'constants/routes';
import {
ArrowUpRight,
@@ -470,6 +477,7 @@ export interface UserSettingsMenuItemsParams {
isWorkspaceBlocked: boolean;
isEnterpriseSelfHostedUser: boolean;
isCommunityEnterpriseUser: boolean;
isNoAuthMode: boolean;
}
export const getUserSettingsDropdownMenuItems = ({
@@ -477,6 +485,7 @@ export const getUserSettingsDropdownMenuItems = ({
isWorkspaceBlocked,
isEnterpriseSelfHostedUser,
isCommunityEnterpriseUser,
isNoAuthMode,
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
[
{
@@ -523,7 +532,19 @@ export const getUserSettingsDropdownMenuItems = ({
{ type: 'divider' as const },
{
key: 'logout',
label: (
disabled: isNoAuthMode,
label: isNoAuthMode ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="user-settings-dropdown-logout-section">Sign out</span>
</TooltipTrigger>
<TooltipContent style={{ zIndex: 1100 }}>
{DEFAULT_MESSAGE}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="user-settings-dropdown-logout-section">Sign out</span>
),
icon: (

View File

@@ -298,7 +298,7 @@ function SettingsPage(): JSX.Element {
isDisabled={false}
showIcon={false}
onClick={(event): void => {
logEvent('Settings V2: Menu clicked', {
void logEvent('Settings V2: Menu clicked', {
menuLabel: item.label,
menuRoute: item.key,
});

View File

@@ -12,8 +12,11 @@ import {
import { useQuery } from 'react-query';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { useGetGlobalConfig } from 'api/generated/services/global';
import { useGetMyUser } from 'api/generated/services/users';
import listOrgPreferences from 'api/v1/org/preferences/list';
import { clearAuthStorage } from 'utils/clearAuthStorage';
import { setNoAuthMode } from 'utils/noAuthMode';
import listUserPreferences from 'api/v1/user/preferences/list';
import getUserVersion from 'api/v1/version/get';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -68,11 +71,50 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(
(): boolean => getLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN) === 'true',
);
const [isNoAuthMode, setIsNoAuthMode] = useState<boolean>(false);
const [isPreflightLoading, setIsPreflightLoading] = useState<boolean>(true);
const [org, setOrg] = useState<Organization[] | null>(null);
const [changelog, setChangelog] = useState<ChangelogSchema | null>(null);
const [showChangelogModal, setShowChangelogModal] = useState<boolean>(false);
// Pre-flight: discover auth mode from public global config.
// On success: in impersonation mode → clear stale tokens, force isLoggedIn=true,
// set noAuthMode singleton so the axios interceptor (outside React)
// can skip the rotate-logout chain.
// On failure: fail-safe to normal auth flow (treat as not no-auth).
const { data: globalConfigData, isLoading: isFetchingGlobalConfig } =
useGetGlobalConfig({
query: {
retry: 2,
retryDelay: 1000,
refetchOnWindowFocus: false,
staleTime: Infinity,
},
});
useEffect(() => {
if (isFetchingGlobalConfig) {
return;
}
const impersonationEnabled =
globalConfigData?.data?.identN?.impersonation?.enabled === true;
if (impersonationEnabled) {
clearAuthStorage();
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
setNoAuthMode(true);
setIsNoAuthMode(true);
setIsLoggedIn(true);
} else {
setNoAuthMode(false);
setIsNoAuthMode(false);
}
setIsPreflightLoading(false);
}, [globalConfigData, isFetchingGlobalConfig]);
// fetcher for current user
// user will only be fetched if the user id and token is present
// if logged out and trying to hit any route none of these calls will trigger
@@ -342,6 +384,9 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
// global event listener for LOGOUT event to clean the app context state
useGlobalEventListener('LOGOUT', () => {
if (isNoAuthMode) {
return;
} // logout is meaningless in no-auth; defensively no-op
setIsLoggedIn(false);
setDefaultUser(getUserDefaults());
setActiveLicense(null);
@@ -360,6 +405,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
trialInfo,
orgPreferences,
isLoggedIn,
isNoAuthMode,
isPreflightLoading,
org,
isFetchingUser,
isFetchingActiveLicense,
@@ -395,6 +442,8 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
isFetchingOrgPreferences,
isFetchingUser,
isLoggedIn,
isNoAuthMode,
isPreflightLoading,
org,
orgPreferences,
activeLicenseRefetch,

View File

@@ -2,6 +2,7 @@ import { ReactElement } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { renderHook, waitFor } from '@testing-library/react';
import setLocalStorageApi from 'api/browser/localstorage/set';
import { getIsNoAuthMode, setNoAuthMode } from 'utils/noAuthMode';
import type {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
@@ -17,6 +18,7 @@ import { AppProvider, useAppContext } from '../App';
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
const MY_USER_URL = 'http://localhost/api/v2/users/me';
const MY_ORG_URL = 'http://localhost/api/v2/orgs/me';
const GLOBAL_CONFIG_URL = 'http://localhost/api/v1/global/config';
jest.mock('constants/env', () => ({
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
@@ -357,3 +359,89 @@ describe('AppProvider when authz/check fails', () => {
);
});
});
describe('AppProvider no-auth preflight', () => {
beforeEach(() => {
queryClient.clear();
});
afterEach(() => {
setNoAuthMode(false);
});
it('sets isNoAuthMode=true and noAuthMode singleton when impersonation is enabled', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: true } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isNoAuthMode).toBe(true);
},
{ timeout: 3000 },
);
expect(getIsNoAuthMode()).toBe(true);
});
it('leaves isNoAuthMode=false and clears noAuthMode singleton when impersonation is disabled', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: false } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(
() => {
expect(result.current.isPreflightLoading).toBe(false);
},
{ timeout: 3000 },
);
expect(result.current.isNoAuthMode).toBe(false);
expect(getIsNoAuthMode()).toBe(false);
});
it('transitions isPreflightLoading from true to false once preflight resolves', async () => {
server.use(
rest.get(GLOBAL_CONFIG_URL, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: { identN: { impersonation: { enabled: false } } },
}),
),
),
);
const wrapper = createWrapper();
const { result } = renderHook(() => useAppContext(), { wrapper });
expect(result.current.isPreflightLoading).toBe(true);
await waitFor(
() => {
expect(result.current.isPreflightLoading).toBe(false);
},
{ timeout: 3000 },
);
});
});

View File

@@ -18,6 +18,8 @@ export interface IAppContext {
orgPreferences: OrgPreference[] | null;
userPreferences: UserPreference[] | null;
isLoggedIn: boolean;
isNoAuthMode: boolean;
isPreflightLoading: boolean;
org: Organization[] | null;
isFetchingUser: boolean;
isFetchingActiveLicense: boolean;

View File

@@ -237,6 +237,8 @@ export function getAppContextMock(
isFetchingOrgPreferences: false,
orgPreferencesFetchError: null,
isLoggedIn: true,
isNoAuthMode: false,
isPreflightLoading: false,
showChangelogModal: false,
updateUser: jest.fn(),
updateOrg: jest.fn(),

View File

@@ -0,0 +1,39 @@
import { LOCALSTORAGE } from 'constants/localStorage';
import { clearAuthStorage } from '../clearAuthStorage';
describe('clearAuthStorage', () => {
beforeEach(() => {
localStorage.clear();
});
it('removes all auth-related localStorage keys', () => {
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
localStorage.setItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN, 'refresh');
localStorage.setItem(LOCALSTORAGE.IS_LOGGED_IN, 'true');
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, 'old@example.com');
localStorage.setItem(LOCALSTORAGE.LOGGED_IN_USER_NAME, 'old');
localStorage.setItem(LOCALSTORAGE.IS_IDENTIFIED_USER, 'true');
localStorage.setItem(LOCALSTORAGE.USER_ID, 'abc');
clearAuthStorage();
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.REFRESH_AUTH_TOKEN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.IS_LOGGED_IN)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_EMAIL)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.LOGGED_IN_USER_NAME)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.IS_IDENTIFIED_USER)).toBeNull();
expect(localStorage.getItem(LOCALSTORAGE.USER_ID)).toBeNull();
});
it('preserves non-auth localStorage keys', () => {
localStorage.setItem(LOCALSTORAGE.THEME, 'dark');
localStorage.setItem(LOCALSTORAGE.AUTH_TOKEN, 'access');
clearAuthStorage();
expect(localStorage.getItem(LOCALSTORAGE.THEME)).toBe('dark');
expect(localStorage.getItem(LOCALSTORAGE.AUTH_TOKEN)).toBeNull();
});
});

View File

@@ -0,0 +1,16 @@
import deleteLocalStorageKey from 'api/browser/localstorage/remove';
import { LOCALSTORAGE } from 'constants/localStorage';
const AUTH_KEYS: LOCALSTORAGE[] = [
LOCALSTORAGE.AUTH_TOKEN,
LOCALSTORAGE.REFRESH_AUTH_TOKEN,
LOCALSTORAGE.IS_LOGGED_IN,
LOCALSTORAGE.LOGGED_IN_USER_EMAIL,
LOCALSTORAGE.LOGGED_IN_USER_NAME,
LOCALSTORAGE.IS_IDENTIFIED_USER,
LOCALSTORAGE.USER_ID,
];
export const clearAuthStorage = (): void => {
AUTH_KEYS.forEach((key) => deleteLocalStorageKey(key));
};

View File

@@ -0,0 +1,7 @@
let _isNoAuthMode = false;
export const setNoAuthMode = (value: boolean): void => {
_isNoAuthMode = value;
};
export const getIsNoAuthMode = (): boolean => _isNoAuthMode;