Skip to content
Draft
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
1 change: 1 addition & 0 deletions static/app/data/controlsiloUrlPatterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export const controlsiloUrlPatterns: RegExp[] = [
new RegExp('^api/0/organizations/[^/]+/service-accounts/$'),
new RegExp('^api/0/organizations/[^/]+/service-accounts/[^/]+/$'),
new RegExp('^api/0/organizations/[^/]+/service-accounts/[^/]+/tokens/$'),
new RegExp('^api/0/organizations/[^/]+/service-accounts/[^/]+/tokens/[^/]+/$'),
new RegExp('^api/0/organizations/[^/]+/monitoring-providers/$'),
new RegExp('^api/0/organizations/[^/]+/monitoring-providers/[^/]+/$'),
new RegExp('^api/0/organizations/[^/]+/integrations/$'),
Expand Down
32 changes: 32 additions & 0 deletions static/app/router/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1501,6 +1501,38 @@ function buildRoutes(): RouteObject[] {
import('sentry/views/settings/organizationServiceAccounts/newServiceAccount')
),
},
{
path: ':serviceAccountId/',
name: t('Service Account Details'),
children: [
{
index: true,
component: make(
() =>
import('sentry/views/settings/organizationServiceAccounts/serviceAccountDetails')
),
},
{
handle: {
settingsBreadcrumb: {
type: 'link',
label: t('Service Account'),
to: '/settings/:orgId/service-accounts/:serviceAccountId/',
},
},
children: [
{
path: 'tokens/new/',
name: t('Create Token'),
component: make(
() =>
import('sentry/views/settings/organizationServiceAccounts/newServiceAccountToken')
),
},
],
},
],
},
],
},
],
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/api/knownSentryApiUrls.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ export type KnownSentryApiUrls =
| '/organizations/$organizationIdOrSlug/service-accounts/'
| '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/'
| '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/'
| '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/$tokenId/'
| '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/viewer-context/'
| '/organizations/$organizationIdOrSlug/sessions/'
| '/organizations/$organizationIdOrSlug/shared/groups/$shareId/'
Expand Down
1 change: 1 addition & 0 deletions static/app/utils/useParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type ParamKeys =
| 'scrubbingId'
| 'searchId'
| 'sentryAppSlug'
| 'serviceAccountId'
| 'shareId'
| 'snapshotId'
| 'spanSlug'
Expand Down
12 changes: 10 additions & 2 deletions static/app/views/settings/organizationServiceAccounts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {useQuery} from '@tanstack/react-query';

import {LinkButton} from '@sentry/scraps/button';
import {InfoText} from '@sentry/scraps/info';
import {Link} from '@sentry/scraps/link';
import type {TableColumnConfig} from '@sentry/scraps/table';
import {Text} from '@sentry/scraps/text';

Expand Down Expand Up @@ -36,7 +37,7 @@ export const serviceAccountsOptions = ({orgSlug}: {orgSlug: string}) =>
}
);

function ScopeSummary({scopes}: {scopes: string[]}) {
export function ScopeSummary({scopes}: {scopes: string[]}) {
return (
<InfoText title={scopes.join(', ')}>
{tn('%s scope', '%s scopes', scopes.length)}
Expand Down Expand Up @@ -66,16 +67,22 @@ function ProjectAccess({
}

function ServiceAccountRow({
orgSlug,
serviceAccount,
projectsById,
}: {
orgSlug: string;
projectsById: Map<string, Project>;
serviceAccount: ServiceAccount;
}) {
return (
<SimpleTable.Row>
<SimpleTable.RowCell>
<Text bold>{serviceAccount.name}</Text>
<Text bold wordBreak="break-word">
<Link to={`/settings/${orgSlug}/service-accounts/${serviceAccount.id}/`}>
{serviceAccount.name}
</Link>
</Text>
</SimpleTable.RowCell>
<SimpleTable.RowCell>
<ScopeSummary scopes={serviceAccount.scopes} />
Expand Down Expand Up @@ -158,6 +165,7 @@ export default function OrganizationServiceAccounts() {
serviceAccounts.map(serviceAccount => (
<ServiceAccountRow
key={serviceAccount.id}
orgSlug={organization.slug}
serviceAccount={serviceAccount}
projectsById={projectsById}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import {
render,
renderGlobalModal,
screen,
userEvent,
waitFor,
} from 'sentry-test/reactTestingLibrary';

import {toast} from '@sentry/scraps/toast';

import OrganizationServiceAccountNewToken from './newServiceAccountToken';

const DAY_MS = 24 * 60 * 60 * 1000;

describe('OrganizationServiceAccountNewToken', () => {
const ACCOUNT_ENDPOINT = '/organizations/org-slug/service-accounts/1/';
const TOKENS_ENDPOINT = '/organizations/org-slug/service-accounts/1/tokens/';

const initialRouterConfig = {
location: {pathname: '/settings/org-slug/service-accounts/1/tokens/new/'},
route: '/settings/:orgId/service-accounts/:serviceAccountId/tokens/new/',
};

beforeEach(() => {
MockApiClient.clearMockResponses();
jest.spyOn(toast, 'error');
MockApiClient.addMockResponse({
url: ACCOUNT_ENDPOINT,
body: {
id: '1',
name: 'github-actions-deploy',
scopes: ['project:read', 'project:releases'],
projectIds: [],
allProjects: true,
dateCreated: '2026-11-01T00:00:00Z',
},
});
});

it('creates a token and shows it once', async () => {
const createMock = MockApiClient.addMockResponse({
url: TOKENS_ENDPOINT,
method: 'POST',
statusCode: 201,
body: {
id: '10',
name: 'ci-deploy',
scopes: ['project:releases'],
tokenLastCharacters: 'abcd',
dateCreated: '2026-11-02T00:00:00Z',
dateLastUsed: null,
dateExpires: null,
token: 'sntrym_secret-token-abcd',
},
});

const {router} = render(<OrganizationServiceAccountNewToken />, {
initialRouterConfig,
});
renderGlobalModal();

await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy');
await userEvent.click(screen.getByRole('textbox', {name: 'Permission scopes'}));
expect(screen.getByRole('menuitemcheckbox', {name: 'project:read'})).toBeChecked();
expect(
screen.getByRole('menuitemcheckbox', {name: 'project:releases'})
).toBeChecked();
await userEvent.click(screen.getByRole('menuitemcheckbox', {name: 'project:read'}));
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));

await waitFor(() =>
expect(createMock).toHaveBeenCalledWith(
TOKENS_ENDPOINT,
expect.objectContaining({
data: {
name: 'ci-deploy',
scopes: ['project:releases'],
expiresAt: expect.any(String),
},
})
)
);
const {expiresAt} = createMock.mock.calls[0][1].data;
const daysUntilExpiry = (new Date(expiresAt).getTime() - Date.now()) / DAY_MS;
expect(Math.round(daysUntilExpiry)).toBe(30);
expect(await screen.findByLabelText('Generated token')).toHaveValue(
'sntrym_secret-token-abcd'
);
expect(screen.getByText(/it won't be shown again/)).toBeInTheDocument();

await userEvent.click(screen.getByRole('button', {name: "I've saved it"}));
await waitFor(() =>
expect(router.location.pathname).toBe('/settings/org-slug/service-accounts/1/')
);
});

it('creates a token that never expires', async () => {
const createMock = MockApiClient.addMockResponse({
url: TOKENS_ENDPOINT,
method: 'POST',
statusCode: 201,
body: {id: '10', token: 'sntrym_secret-token-abcd'},
});

render(<OrganizationServiceAccountNewToken />, {initialRouterConfig});
renderGlobalModal();

await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy');
await userEvent.click(screen.getByText('30 days'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'No expiration'}));
expect(screen.getByText(/works until someone revokes it/)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));

await waitFor(() =>
expect(createMock).toHaveBeenCalledWith(
TOKENS_ENDPOINT,
expect.objectContaining({
data: expect.objectContaining({expiresAt: null}),
})
)
);
});

it('creates a token that expires on a custom date', async () => {
const createMock = MockApiClient.addMockResponse({
url: TOKENS_ENDPOINT,
method: 'POST',
statusCode: 201,
body: {id: '10', token: 'sntrym_secret-token-abcd'},
});

render(<OrganizationServiceAccountNewToken />, {initialRouterConfig});
renderGlobalModal();

await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy');
await userEvent.click(screen.getByText('30 days'));
await userEvent.click(screen.getByRole('menuitemradio', {name: 'Custom date'}));

const dateInput = await screen.findByLabelText('Expiration date');
await userEvent.type(dateInput, '2000-01-01');
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));
expect(await screen.findByText('Pick a date after today')).toBeInTheDocument();
expect(createMock).not.toHaveBeenCalled();

await userEvent.clear(dateInput);
await userEvent.type(dateInput, '2999-12-31');
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));

await waitFor(() =>
expect(createMock).toHaveBeenCalledWith(
TOKENS_ENDPOINT,
expect.objectContaining({
data: expect.objectContaining({
expiresAt: new Date('2999-12-31T23:59:59').toISOString(),
}),
})
)
);
});

it('requires at least one permission', async () => {
const createMock = MockApiClient.addMockResponse({
url: TOKENS_ENDPOINT,
method: 'POST',
body: {},
});

render(<OrganizationServiceAccountNewToken />, {initialRouterConfig});

await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy');
await userEvent.click(screen.getByRole('textbox', {name: 'Permission scopes'}));
await userEvent.click(screen.getByRole('menuitemcheckbox', {name: 'project:read'}));
await userEvent.click(
screen.getByRole('menuitemcheckbox', {name: 'project:releases'})
);
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));

expect(await screen.findByText('Pick at least one permission')).toBeInTheDocument();
expect(createMock).not.toHaveBeenCalled();
});

it('shows field errors from the API', async () => {
MockApiClient.addMockResponse({
url: TOKENS_ENDPOINT,
method: 'POST',
statusCode: 400,
body: {
detail: {
name: ['A token with this name already exists.'],
scopes: ['Scopes must be a subset of the service account scopes.'],
},
},
});

render(<OrganizationServiceAccountNewToken />, {initialRouterConfig});
renderGlobalModal();

await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy');
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));

expect(
await screen.findByText('A token with this name already exists.')
).toBeInTheDocument();
expect(
screen.getByText('Scopes must be a subset of the service account scopes.')
).toBeInTheDocument();
expect(screen.queryByLabelText('Generated token')).not.toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});

it('shows a string error detail as a toast', async () => {
MockApiClient.addMockResponse({
url: TOKENS_ENDPOINT,
method: 'POST',
statusCode: 400,
body: {detail: 'system.url-prefix is not set.'},
});

render(<OrganizationServiceAccountNewToken />, {initialRouterConfig});

await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy');
await userEvent.click(screen.getByRole('button', {name: 'Create Token'}));

await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith('system.url-prefix is not set.')
);
});
});
Loading
Loading