diff --git a/static/app/data/controlsiloUrlPatterns.ts b/static/app/data/controlsiloUrlPatterns.ts index 5c68325fc333..633579350154 100644 --- a/static/app/data/controlsiloUrlPatterns.ts +++ b/static/app/data/controlsiloUrlPatterns.ts @@ -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/$'), diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index e2c098b27469..aff33a588714 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -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') + ), + }, + ], + }, + ], + }, ], }, ], diff --git a/static/app/utils/api/knownSentryApiUrls.generated.ts b/static/app/utils/api/knownSentryApiUrls.generated.ts index ef412f36ff16..a15a664aa213 100644 --- a/static/app/utils/api/knownSentryApiUrls.generated.ts +++ b/static/app/utils/api/knownSentryApiUrls.generated.ts @@ -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/' diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx index 6413305c37fa..a573f60a5eed 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -63,6 +63,7 @@ type ParamKeys = | 'scrubbingId' | 'searchId' | 'sentryAppSlug' + | 'serviceAccountId' | 'shareId' | 'snapshotId' | 'spanSlug' diff --git a/static/app/views/settings/organizationServiceAccounts/index.tsx b/static/app/views/settings/organizationServiceAccounts/index.tsx index 689f2442a6df..3829c2096bb0 100644 --- a/static/app/views/settings/organizationServiceAccounts/index.tsx +++ b/static/app/views/settings/organizationServiceAccounts/index.tsx @@ -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'; @@ -36,7 +37,7 @@ export const serviceAccountsOptions = ({orgSlug}: {orgSlug: string}) => } ); -function ScopeSummary({scopes}: {scopes: string[]}) { +export function ScopeSummary({scopes}: {scopes: string[]}) { return ( {tn('%s scope', '%s scopes', scopes.length)} @@ -66,16 +67,22 @@ function ProjectAccess({ } function ServiceAccountRow({ + orgSlug, serviceAccount, projectsById, }: { + orgSlug: string; projectsById: Map; serviceAccount: ServiceAccount; }) { return ( - {serviceAccount.name} + + + {serviceAccount.name} + + @@ -158,6 +165,7 @@ export default function OrganizationServiceAccounts() { serviceAccounts.map(serviceAccount => ( diff --git a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx new file mode 100644 index 000000000000..478aa8b6dfd3 --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx @@ -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(, { + 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(, {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(, {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(, {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(, {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(, {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.') + ); + }); +}); diff --git a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx new file mode 100644 index 000000000000..60b335737595 --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx @@ -0,0 +1,288 @@ +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; +import {z} from 'zod'; + +import {Button} from '@sentry/scraps/button'; +import {defaultFormOptions, setFieldErrors, useScrapsForm} from '@sentry/scraps/form'; +import {Flex} from '@sentry/scraps/layout'; +import {toast} from '@sentry/scraps/toast'; + +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {t} from 'sentry/locale'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {RequestError} from 'sentry/utils/requestError/requestError'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useParams} from 'sentry/utils/useParams'; +import {displayNewToken} from 'sentry/views/settings/components/newTokenHandler'; +import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; + +import {getServiceAccountScopeOptions} from './scopes'; +import { + serviceAccountOptions, + serviceAccountTokensOptions, +} from './serviceAccountDetails'; +import type {NewServiceAccountToken, ServiceAccount} from './types'; + +const EXPIRATION_OPTIONS = [ + {value: '30', label: t('30 days')}, + {value: '60', label: t('60 days')}, + {value: '90', label: t('90 days')}, + {value: 'custom', label: t('Custom date')}, + {value: 'never', label: t('No expiration')}, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function toDateInputValue(date: Date) { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${date.getFullYear()}-${month}-${day}`; +} + +const schema = z + .object({ + name: z.string().trim().min(1, t('Name is required')), + scopes: z.array(z.string()).min(1, t('Pick at least one permission')), + expiration: z.string(), + customDate: z.string(), + }) + .superRefine((values, ctx) => { + if (values.expiration !== 'custom') { + return; + } + // Date inputs give YYYY-MM-DD, so tomorrow or later compares as greater. + if (!values.customDate || values.customDate <= toDateInputValue(new Date())) { + ctx.addIssue({ + code: 'custom', + path: ['customDate'], + message: t('Pick a date after today'), + }); + } + }); + +type FormValues = z.infer; + +function getExpiresAt({expiration, customDate}: FormValues): string | null { + if (expiration === 'never') { + return null; + } + if (expiration === 'custom') { + // The token works through the end of the picked day, in the user's timezone. + return new Date(`${customDate}T23:59:59`).toISOString(); + } + return new Date(Date.now() + Number(expiration) * DAY_MS).toISOString(); +} + +const errorDetailSchema = z.union([ + z.string(), + z.object({ + name: z.array(z.string()).optional(), + scopes: z.array(z.string()).optional(), + expiresAt: z.array(z.string()).optional(), + }), +]); + +function getErrorDetail(error: RequestError) { + const result = errorDetailSchema.safeParse(error.responseJSON?.detail); + return result.success ? result.data : null; +} + +function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { + const organization = useOrganization(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const detailsPath = `/settings/${organization.slug}/service-accounts/${serviceAccount.id}/`; + const handleGoBack = () => navigate(detailsPath); + + const mutation = useMutation({ + mutationFn: (values: FormValues) => + fetchMutation({ + url: getApiUrl( + '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/', + { + path: { + organizationIdOrSlug: organization.slug, + serviceAccountId: serviceAccount.id, + }, + } + ), + method: 'POST', + data: { + name: values.name.trim(), + scopes: values.scopes, + expiresAt: getExpiresAt(values), + }, + }), + onSuccess: token => { + queryClient.invalidateQueries({ + queryKey: serviceAccountTokensOptions({ + orgSlug: organization.slug, + serviceAccountId: serviceAccount.id, + }).queryKey, + }); + displayNewToken(token.token, handleGoBack); + }, + }); + + const defaultValues: FormValues = { + name: '', + scopes: serviceAccount.scopes, + expiration: '30', + customDate: '', + }; + + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues, + validators: {onDynamic: schema}, + onSubmit: async ({value, formApi}) => { + try { + await mutation.mutateAsync(value); + } catch (error) { + if (!(error instanceof RequestError)) { + throw error; + } + const detail = getErrorDetail(error); + if (typeof detail === 'string') { + toast.error(detail); + return; + } + const nameError = detail?.name?.[0]; + const scopesError = detail?.scopes?.[0]; + const expiresAtError = detail?.expiresAt?.[0]; + if (nameError || scopesError || expiresAtError) { + setFieldErrors(formApi, { + ...(nameError ? {name: {message: nameError}} : {}), + ...(scopesError ? {scopes: {message: scopesError}} : {}), + ...(expiresAtError ? {expiration: {message: expiresAtError}} : {}), + }); + } else { + toast.error(t('Failed to create the token.')); + } + } + }, + }); + + return ( + + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + + {field => ( + + + + )} + + state.values.expiration === 'custom'}> + {isCustom => + isCustom && ( + + {field => ( + + + + )} + + ) + } + + + + + + + {t('Create Token')} + + + + ); +} + +export default function OrganizationServiceAccountNewToken() { + const organization = useOrganization(); + const {serviceAccountId} = useParams<{serviceAccountId: string}>(); + + const { + data: serviceAccount, + isPending, + isError, + refetch, + } = useQuery(serviceAccountOptions({orgSlug: organization.slug, serviceAccountId})); + + return ( + + + {isError && ( + + )} + {isPending && } + {serviceAccount && } + + ); +} diff --git a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx new file mode 100644 index 000000000000..e65a3e8b52b1 --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx @@ -0,0 +1,162 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import { + act, + render, + renderGlobalModal, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import {ProjectsStore} from 'sentry/stores/projectsStore'; + +import OrganizationServiceAccountDetails from './serviceAccountDetails'; + +describe('OrganizationServiceAccountDetails', () => { + const ACCOUNT_ENDPOINT = '/organizations/org-slug/service-accounts/1/'; + const TOKENS_ENDPOINT = '/organizations/org-slug/service-accounts/1/tokens/'; + + const writeOrganization = OrganizationFixture({ + access: ['org:read', 'service_account:read', 'service_account:write'], + }); + + const initialRouterConfig = { + location: {pathname: '/settings/org-slug/service-accounts/1/'}, + route: '/settings/:orgId/service-accounts/:serviceAccountId/', + }; + + const token = { + id: '10', + name: 'ci-deploy', + scopes: ['project:read', 'project:releases'], + tokenLastCharacters: 'abcd', + dateCreated: '2026-11-02T00:00:00Z', + dateLastUsed: null, + dateExpires: null, + }; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + act(() => + ProjectsStore.loadInitialData([ProjectFixture({id: '2', slug: 'backend'})]) + ); + MockApiClient.addMockResponse({ + url: ACCOUNT_ENDPOINT, + body: { + id: '1', + name: 'github-actions-deploy', + scopes: ['project:read', 'project:releases'], + projectIds: ['2'], + allProjects: false, + dateCreated: '2026-11-01T00:00:00Z', + }, + }); + }); + + it('shows the service account and its tokens', async () => { + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, body: [token]}); + + render(, {initialRouterConfig}); + + expect(await screen.findByText('ci-deploy')).toBeInTheDocument(); + expect(screen.getByText('github-actions-deploy')).toBeInTheDocument(); + expect(screen.getByText('project:read, project:releases')).toBeInTheDocument(); + expect(screen.getByText('backend')).toBeInTheDocument(); + expect(screen.getByText('sntrym_************abcd')).toBeInTheDocument(); + expect(screen.getByText('2 scopes')).toBeInTheDocument(); + // Never used, and never expires. + expect(screen.getAllByText('Never')).toHaveLength(2); + expect(screen.queryByText('Expired')).not.toBeInTheDocument(); + }); + + it('shows when tokens expire and marks expired ones', async () => { + MockApiClient.addMockResponse({ + url: TOKENS_ENDPOINT, + body: [ + {...token, id: '11', name: 'expired-token', dateExpires: '2010-01-15T12:00:00Z'}, + {...token, id: '12', name: 'active-token', dateExpires: '2999-01-15T12:00:00Z'}, + ], + }); + + render(, {initialRouterConfig}); + + expect(await screen.findByText('expired-token')).toBeInTheDocument(); + expect(screen.getByText('Jan 15, 2010')).toBeInTheDocument(); + expect(screen.getByText('Jan 15, 2999')).toBeInTheDocument(); + expect(screen.getAllByText('Expired')).toHaveLength(1); + }); + + it('shows an empty state', async () => { + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, body: []}); + + render(, {initialRouterConfig}); + + expect(await screen.findByText(/No tokens yet/)).toBeInTheDocument(); + }); + + it('shows an error when tokens fail to load', async () => { + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, statusCode: 500, body: {}}); + + render(, {initialRouterConfig}); + + expect(await screen.findByText('Failed to load tokens.')).toBeInTheDocument(); + }); + + it('revokes a token after confirmation', async () => { + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, body: [token]}); + const deleteMock = MockApiClient.addMockResponse({ + url: `${TOKENS_ENDPOINT}10/`, + method: 'DELETE', + statusCode: 204, + }); + + render(, { + initialRouterConfig, + organization: writeOrganization, + }); + renderGlobalModal(); + + await userEvent.click(await screen.findByRole('button', {name: 'Revoke ci-deploy'})); + expect( + await screen.findByText(/Revoke sntrym_\*{12}abcd\? Anything using it/) + ).toBeInTheDocument(); + expect(deleteMock).not.toHaveBeenCalled(); + + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, body: []}); + await userEvent.click(screen.getByRole('button', {name: 'Revoke'})); + + await waitFor(() => expect(deleteMock).toHaveBeenCalled()); + expect(await screen.findByText(/No tokens yet/)).toBeInTheDocument(); + }); + + it('disables create and revoke without service_account:write', async () => { + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, body: [token]}); + + render(, { + initialRouterConfig, + organization: OrganizationFixture({access: ['org:read', 'service_account:read']}), + }); + + expect(await screen.findByRole('button', {name: 'Revoke ci-deploy'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'New Token'})).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('links New Token to the create page', async () => { + MockApiClient.addMockResponse({url: TOKENS_ENDPOINT, body: []}); + + render(, { + initialRouterConfig, + organization: writeOrganization, + }); + + expect(await screen.findByRole('button', {name: 'New Token'})).toHaveAttribute( + 'href', + '/settings/org-slug/service-accounts/1/tokens/new/' + ); + }); +}); diff --git a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx new file mode 100644 index 000000000000..b7def85ce7aa --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx @@ -0,0 +1,339 @@ +import {useState} from 'react'; +import {IconAdd} from '@sentry/icons/add'; +import {IconDelete} from '@sentry/icons/delete'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; + +import {Tag} from '@sentry/scraps/badge'; +import {Button, LinkButton} from '@sentry/scraps/button'; +import {Confirm} from '@sentry/scraps/confirm'; +import {DisplayField} from '@sentry/scraps/form'; +import {Flex, Stack} from '@sentry/scraps/layout'; +import type {TableColumnConfig} from '@sentry/scraps/table'; +import {Heading, Text} from '@sentry/scraps/text'; +import {toast} from '@sentry/scraps/toast'; +import {Tooltip} from '@sentry/scraps/tooltip'; + +import {hasEveryAccess} from 'sentry/components/acl/access'; +import {DateTime} from 'sentry/components/dateTime'; +import {LoadingError} from 'sentry/components/loadingError'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {Panel} from 'sentry/components/panels/panel'; +import {PanelBody} from 'sentry/components/panels/panelBody'; +import {PanelHeader} from 'sentry/components/panels/panelHeader'; +import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; +import {SimpleTable} from 'sentry/components/tables/simpleTable'; +import {TimeSince} from 'sentry/components/timeSince'; +import {t} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useParams} from 'sentry/utils/useParams'; +import {useProjects} from 'sentry/utils/useProjects'; +import {SettingsPageHeader} from 'sentry/views/settings/components/settingsPageHeader'; +import {tokenPreview} from 'sentry/views/settings/organizationAuthTokens/tokenUtils'; + +import {ScopeSummary} from './index'; +import type {ServiceAccount, ServiceAccountToken} from './types'; + +// Matches AuthTokenType.SERVICE_ACCOUNT in src/sentry/types/token.py. +const SERVICE_ACCOUNT_TOKEN_PREFIX = 'sntrym_'; + +const TOKEN_COLUMNS: TableColumnConfig[] = [ + {key: 'token', width: 'minmax(0, 1fr)'}, + {key: 'permissions', width: 'max-content'}, + {key: 'created', width: 'max-content'}, + {key: 'lastUsed', width: 'max-content'}, + {key: 'expires', width: 'max-content'}, + {key: 'actions', width: 'max-content'}, +]; + +type ServiceAccountParams = {orgSlug: string; serviceAccountId: string}; + +export const serviceAccountOptions = ({ + orgSlug, + serviceAccountId, +}: ServiceAccountParams) => + apiOptions.as()( + '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/', + { + path: {organizationIdOrSlug: orgSlug, serviceAccountId}, + staleTime: 0, + } + ); + +export const serviceAccountTokensOptions = ({ + orgSlug, + serviceAccountId, +}: ServiceAccountParams) => + apiOptions.as()( + '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/', + { + path: {organizationIdOrSlug: orgSlug, serviceAccountId}, + staleTime: 0, + } + ); + +function ProjectAccessList({serviceAccount}: {serviceAccount: ServiceAccount}) { + const {projects} = useProjects(); + + if (serviceAccount.allProjects) { + return {t('All projects')}; + } + if (serviceAccount.projectIds.length === 0) { + return {t('No projects')}; + } + const projectsById = new Map(projects.map(project => [project.id, project])); + const slugs = serviceAccount.projectIds.map(id => projectsById.get(id)?.slug ?? id); + return {slugs.join(', ')}; +} + +function ServiceAccountSummary({serviceAccount}: {serviceAccount: ServiceAccount}) { + return ( + + {t('Service Account')} + + + + {serviceAccount.scopes.length > 0 + ? serviceAccount.scopes.join(', ') + : t('No permissions')} + + + + + + + + + + + + + ); +} + +function TokenExpiry({dateExpires}: {dateExpires: string | null}) { + const [now] = useState(() => Date.now()); + + if (!dateExpires) { + return {t('Never')}; + } + return ( + + + + + {new Date(dateExpires).getTime() <= now && ( + {t('Expired')} + )} + + ); +} + +function TokenRow({ + token, + canRevoke, + isRevoking, + onRevoke, +}: { + canRevoke: boolean; + isRevoking: boolean; + onRevoke: (token: ServiceAccountToken) => void; + token: ServiceAccountToken; +}) { + const preview = tokenPreview(token.tokenLastCharacters, SERVICE_ACCOUNT_TOKEN_PREFIX); + + return ( + + + + {token.name} + + + {preview} + + + + + + + + + + + + {token.dateLastUsed ? ( + + + + ) : ( + {t('Never')} + )} + + + + + + + onRevoke(token)} + priority="danger" + confirmText={t('Revoke')} + message={t( + 'Revoke %s? Anything using it will stop working. This cannot be undone.', + preview + )} + > + + + + + + ); +} + +function TokensSection({serviceAccount}: {serviceAccount: ServiceAccount}) { + const organization = useOrganization(); + const queryClient = useQueryClient(); + const canWrite = hasEveryAccess(['service_account:write'], {organization}); + const tokensOptions = serviceAccountTokensOptions({ + orgSlug: organization.slug, + serviceAccountId: serviceAccount.id, + }); + + const {data: tokens, isPending, isError, refetch} = useQuery(tokensOptions); + + const {mutate: revokeToken, isPending: isRevoking} = useMutation({ + mutationFn: (token: ServiceAccountToken) => + fetchMutation({ + url: getApiUrl( + '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/$tokenId/', + { + path: { + organizationIdOrSlug: organization.slug, + serviceAccountId: serviceAccount.id, + tokenId: token.id, + }, + } + ), + method: 'DELETE', + }), + onSuccess: (_data, token) => { + toast.success(t('Revoked token %s.', token.name)); + queryClient.invalidateQueries({queryKey: tokensOptions.queryKey}); + }, + onError: () => { + toast.error(t('Failed to revoke the token.')); + }, + }); + + return ( + + + + + {t('Tokens')} + + + {t( + 'Tokens authenticate as this service account. A token can have at most the permissions of the account.' + )} + + + } + to={`/settings/${organization.slug}/service-accounts/${serviceAccount.id}/tokens/new/`} + disabled={!canWrite} + tooltipProps={{ + title: canWrite + ? undefined + : t('You need the Manager or Owner role to create tokens.'), + }} + > + {t('New Token')} + + + + {t('Token')} + {t('Permissions')} + {t('Created')} + {t('Last used')} + {t('Expires')} + + + } + > + {isError && ( + + )} + {!isError && isPending && } + {!isError && !isPending && tokens.length === 0 && ( + + {t('No tokens yet. Create one to call the Sentry API as this account.')} + + )} + {!isError && + !isPending && + tokens.map(token => ( + + ))} + + + ); +} + +export default function OrganizationServiceAccountDetails() { + const organization = useOrganization(); + const {serviceAccountId} = useParams<{serviceAccountId: string}>(); + + const { + data: serviceAccount, + isPending, + isError, + refetch, + } = useQuery(serviceAccountOptions({orgSlug: organization.slug, serviceAccountId})); + + if (isError) { + return ( + + ); + } + + if (isPending) { + return ; + } + + return ( + + + + + + ); +} diff --git a/static/app/views/settings/organizationServiceAccounts/types.tsx b/static/app/views/settings/organizationServiceAccounts/types.tsx index 4f267666998c..6ac41cec2883 100644 --- a/static/app/views/settings/organizationServiceAccounts/types.tsx +++ b/static/app/views/settings/organizationServiceAccounts/types.tsx @@ -6,3 +6,15 @@ export type ServiceAccount = { projectIds: string[]; scopes: string[]; }; + +export type ServiceAccountToken = { + dateCreated: string; + dateExpires: string | null; + dateLastUsed: string | null; + id: string; + name: string; + scopes: string[]; + tokenLastCharacters: string; +}; + +export type NewServiceAccountToken = ServiceAccountToken & {token: string};