Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion static/app/components/commandPalette/types.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {ReactNode} from 'react';
import type {LocationDescriptor} from 'history';

export type CommandPaletteGroupKey = 'navigate' | 'add' | 'help';
export type CommandPaletteGroupKey = 'search-result' | 'navigate' | 'add' | 'help';

interface CommonCommandPaletteAction {
display: {
Expand Down
3 changes: 3 additions & 0 deletions static/app/components/commandPalette/ui/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ export const COMMAND_PALETTE_GROUP_KEY_CONFIG: Record<
label: string;
}
> = {
'search-result': {
label: t('Search Results'),
},
navigate: {
label: t('Go to…'),
},
Expand Down
18 changes: 14 additions & 4 deletions static/app/components/commandPalette/ui/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette
import {COMMAND_PALETTE_GROUP_KEY_CONFIG} from 'sentry/components/commandPalette/ui/constants';
import {CommandPaletteList} from 'sentry/components/commandPalette/ui/list';
import {useCommandPaletteState} from 'sentry/components/commandPalette/ui/useCommandPaletteState';
import {useDsnLookupActions} from 'sentry/components/commandPalette/useDsnLookupActions';
import {SvgIcon} from 'sentry/icons/svgIcon';
import {unreachable} from 'sentry/utils/unreachable';
import normalizeUrl from 'sentry/utils/url/normalizeUrl';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';

type CommandPaletteActionMenuItem = MenuListItemProps & {
children: CommandPaletteActionMenuItem[];
Expand Down Expand Up @@ -41,12 +43,20 @@ function actionToMenuItem(
export function CommandPaletteContent() {
const {actions, selectedAction, selectAction, clearSelection, query, setQuery} =
useCommandPaletteState();
const organization = useOrganization({allowNull: true});
const hasDsnLookup = organization?.features?.includes('cmd-k-dsn-lookup') ?? false;
const dsnLookupActions = useDsnLookupActions(hasDsnLookup ? query : '');
const navigate = useNavigate();
const inputRef = useRef<HTMLInputElement>(null);

const mergedActions = useMemo(
() => [...dsnLookupActions, ...actions],
[dsnLookupActions, actions]
);

const groupedMenuItems = useMemo<CommandPaletteActionMenuItem[]>(() => {
const itemsBySection = new Map<string, CommandPaletteActionMenuItem[]>();
for (const action of actions) {
for (const action of mergedActions) {
const sectionLabel = action.groupingKey
? (COMMAND_PALETTE_GROUP_KEY_CONFIG[action.groupingKey]?.label ?? '')
: '';
Expand All @@ -65,7 +75,7 @@ export function CommandPaletteContent() {
};
})
.filter(section => section.children.length > 0);
}, [actions]);
}, [mergedActions]);

const handleSelect = useCallback(
(action: CommandPaletteActionWithKey) => {
Expand Down Expand Up @@ -94,12 +104,12 @@ export function CommandPaletteContent() {
if (selectionKey === null || selectionKey === undefined) {
return;
}
const action = actions.find(a => a.key === selectionKey);
const action = mergedActions.find(a => a.key === selectionKey);
if (action) {
handleSelect(action);
}
},
[actions, handleSelect]
[mergedActions, handleSelect]
);

// When an action has been selected, clear the query and focus the input
Expand Down
78 changes: 78 additions & 0 deletions static/app/components/commandPalette/useDsnLookupActions.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary';

import {useDsnLookupActions} from 'sentry/components/commandPalette/useDsnLookupActions';

describe('useDsnLookupActions', () => {
beforeEach(() => {
MockApiClient.clearMockResponses();
});

it('returns actions for a valid DSN', async () => {
const dsn = 'https://abc123def456abc123def456abc123de@o1.ingest.sentry.io/123';
MockApiClient.addMockResponse({
url: '/organizations/org-slug/dsn-lookup/',
body: {
organizationSlug: 'test-org',
projectSlug: 'test-project',
projectId: '42',
projectName: 'Test Project',
projectPlatform: 'javascript',
keyLabel: 'Default',
keyId: '1',
},
match: [MockApiClient.matchQuery({dsn})],
});

const {result} = renderHookWithProviders(() => useDsnLookupActions(dsn));

await waitFor(() => {
expect(result.current.length).toBeGreaterThan(0);
});

expect(result.current).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'dsn-lookup-issues',
type: 'navigate',
to: '/organizations/test-org/issues/?project=42',
}),
expect.objectContaining({
key: 'dsn-lookup-project-settings',
type: 'navigate',
to: '/settings/test-org/projects/test-project/',
}),
expect.objectContaining({
key: 'dsn-lookup-client-keys',
type: 'navigate',
to: '/settings/test-org/projects/test-project/keys/',
}),
])
);
});

it('returns empty array for non-DSN query', () => {
const mockApi = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dsn-lookup/',
body: {},
});

const {result} = renderHookWithProviders(() =>
useDsnLookupActions('some random text')
);

expect(result.current).toEqual([]);
expect(mockApi).not.toHaveBeenCalled();
});

it('returns empty array for empty query', () => {
const mockApi = MockApiClient.addMockResponse({
url: '/organizations/org-slug/dsn-lookup/',
body: {},
});

const {result} = renderHookWithProviders(() => useDsnLookupActions(''));

expect(result.current).toEqual([]);
expect(mockApi).not.toHaveBeenCalled();
});
});
73 changes: 73 additions & 0 deletions static/app/components/commandPalette/useDsnLookupActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {useMemo} from 'react';

import type {CommandPaletteActionWithKey} from 'sentry/components/commandPalette/types';
import {DSN_PATTERN} from 'sentry/components/search/sources/dsnLookupUtils';
import type {DsnLookupResponse} from 'sentry/components/search/sources/dsnLookupUtils';
import {IconIssues, IconList, IconSettings} from 'sentry/icons';
import {t} from 'sentry/locale';
import {useApiQuery} from 'sentry/utils/queryClient';
import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
import useOrganization from 'sentry/utils/useOrganization';

export function useDsnLookupActions(query: string): CommandPaletteActionWithKey[] {
const organization = useOrganization();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useDsnLookupActions crashes when organization context is null

High Severity

content.tsx calls useOrganization({allowNull: true}) and defensively null-checks with optional chaining, indicating the organization context can be null. However, useDsnLookupActions is always invoked (hooks can't be conditional), and it internally calls useOrganization() without allowNull, which throws when the organization context is missing. The empty-string guard (hasDsnLookup ? query : '') doesn't prevent the crash because the hook body still runs useOrganization() before checking the query.

Additional Locations (1)

Fix in Cursor Fix in Web

const debouncedQuery = useDebouncedValue(query, 300);
const isDsn = DSN_PATTERN.test(debouncedQuery);

const {data} = useApiQuery<DsnLookupResponse>(
[`/organizations/${organization.slug}/dsn-lookup/`, {query: {dsn: debouncedQuery}}],
{
staleTime: 30_000,
enabled: isDsn,
}
);

return useMemo(() => {
if (!data) {
return [];
}

const orgSlug = data.organizationSlug;
const projectSlug = data.projectSlug;
const projectId = data.projectId;
const projectName = data.projectName;

const actions: CommandPaletteActionWithKey[] = [
{
key: 'dsn-lookup-issues',
type: 'navigate',
to: `/organizations/${orgSlug}/issues/?project=${projectId}`,
display: {
label: t('Issues for %s', projectName),
details: t('View issues'),
icon: <IconIssues />,
},
groupingKey: 'search-result',
},
{
key: 'dsn-lookup-project-settings',
type: 'navigate',
to: `/settings/${orgSlug}/projects/${projectSlug}/`,
display: {
label: t('%s Settings', projectName),
details: t('Project settings'),
icon: <IconSettings />,
},
groupingKey: 'search-result',
},
{
key: 'dsn-lookup-client-keys',
type: 'navigate',
to: `/settings/${orgSlug}/projects/${projectSlug}/keys/`,
display: {
label: t('Client Keys (DSN) for %s', projectName),
details: t('Manage DSN keys'),
icon: <IconList />,
},
groupingKey: 'search-result',
},
];

return actions;
}, [data]);
}
2 changes: 2 additions & 0 deletions static/app/components/search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import useRouter from 'sentry/utils/useRouter';

import ApiSource from './sources/apiSource';
import CommandSource from './sources/commandSource';
import DsnLookupSource from './sources/dsnLookupSource';
import FormSource from './sources/formSource';
import OrganizationsSource from './sources/organizationsSource';
import RouteSource from './sources/routeSource';
Expand Down Expand Up @@ -203,6 +204,7 @@ function Search({
RouteSource,
OrganizationsSource,
CommandSource,
DsnLookupSource,
]
}
>
Expand Down
98 changes: 98 additions & 0 deletions static/app/components/search/sources/dsnLookupSource.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {useEffect, useMemo, useState} from 'react';

import {Client} from 'sentry/api';
import {t} from 'sentry/locale';
import useOrganization from 'sentry/utils/useOrganization';

import {DSN_PATTERN} from './dsnLookupUtils';
import type {DsnLookupResponse} from './dsnLookupUtils';
import type {ChildProps, ResultItem} from './types';
import {makeResolvedTs} from './utils';

type Props = {
children: (props: ChildProps) => React.ReactElement;
query: string;
};

function DsnLookupSource({query, children}: Props) {
const organization = useOrganization();
const hasDsnLookup = organization.features.includes('cmd-k-dsn-lookup');
const isDsn = DSN_PATTERN.test(query);
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState<DsnLookupResponse | null>(null);

useEffect(() => {
if (!hasDsnLookup || !isDsn) {
setData(null);
setIsLoading(false);
return undefined;
}

setIsLoading(true);
const api = new Client();
let cancelled = false;

api
.requestPromise(`/organizations/${organization.slug}/dsn-lookup/`, {
query: {dsn: query},
})
.then((response: DsnLookupResponse) => {
if (!cancelled) {
setData(response);
setIsLoading(false);
}
})
.catch(() => {
if (!cancelled) {
setData(null);
setIsLoading(false);
}
});

return () => {
cancelled = true;
};
}, [hasDsnLookup, isDsn, query, organization.slug]);

const results = useMemo(() => {
if (!data) {
return [];
}

const resolvedTs = makeResolvedTs();
const {organizationSlug, projectSlug, projectId, projectName} = data;

const items: ResultItem[] = [
{
title: t('Issues for %s', projectName),
description: t('View issues'),
sourceType: 'dsn-lookup',
resultType: 'route',
resolvedTs,
to: `/organizations/${organizationSlug}/issues/?project=${projectId}`,
},
{
title: t('%s Settings', projectName),
description: t('Project settings'),
sourceType: 'dsn-lookup',
resultType: 'route',
resolvedTs,
to: `/settings/${organizationSlug}/projects/${projectSlug}/`,
},
{
title: t('Client Keys (DSN) for %s', projectName),
description: t('Manage DSN keys'),
sourceType: 'dsn-lookup',
resultType: 'route',
resolvedTs,
to: `/settings/${organizationSlug}/projects/${projectSlug}/keys/`,
},
];

return items.map((item, i) => ({item, score: 0, refIndex: i}));
}, [data]);

return children({isLoading, results});
}

export default DsnLookupSource;
11 changes: 11 additions & 0 deletions static/app/components/search/sources/dsnLookupUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const DSN_PATTERN = /^https?:\/\/([a-f0-9]{32})(:[a-f0-9]{32})?@[^/]+\/\d+$/;

export interface DsnLookupResponse {
keyId: string;
keyLabel: string;
organizationSlug: string;
projectId: string;
projectName: string;
projectPlatform: string | null;
projectSlug: string;
}
3 changes: 2 additions & 1 deletion static/app/components/search/sources/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export type ResultItem = {
| 'integration'
| 'sentryApp'
| 'docIntegration'
| 'help';
| 'help'
| 'dsn-lookup';
/**
* The title to display in result options.
*/
Expand Down
Loading