From d582e74d55f5fa3b0328c64265c9ce75bea22a66 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:28:45 +0000 Subject: [PATCH 1/5] feat(service-accounts): Add details page with token list, create and revoke Link each service account to a details page that shows its permissions and project access, lists its active tokens, and lets managers revoke them or create new ones. A new token is shown once in the same modal org auth tokens use, then the page returns to the details view. Co-Authored-By: Shaun Kaasten <900809+skaasten@users.noreply.github.com> --- static/app/router/routes.tsx | 32 ++ .../utils/api/knownSentryApiUrls.generated.ts | 1 + static/app/utils/useParams.tsx | 1 + .../organizationServiceAccounts/index.tsx | 12 +- .../newServiceAccountToken.spec.tsx | 124 +++++++ .../newServiceAccountToken.tsx | 174 ++++++++++ .../serviceAccountDetails.spec.tsx | 143 ++++++++ .../serviceAccountDetails.tsx | 313 ++++++++++++++++++ .../organizationServiceAccounts/types.tsx | 12 + 9 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx create mode 100644 static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx create mode 100644 static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx create mode 100644 static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx 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..84b4f157785c --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx @@ -0,0 +1,124 @@ +import { + render, + renderGlobalModal, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import OrganizationServiceAccountNewToken from './newServiceAccountToken'; + +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(); + 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'); + expect(screen.getByRole('checkbox', {name: 'project:read'})).toBeChecked(); + await userEvent.click(screen.getByRole('checkbox', {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']}, + }) + ) + ); + 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('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('checkbox', {name: 'project:read'})); + await userEvent.click(screen.getByRole('checkbox', {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: { + 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(); + }); +}); diff --git a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx new file mode 100644 index 000000000000..689b6943a846 --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx @@ -0,0 +1,174 @@ +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, Stack} 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 {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors'; +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 { + serviceAccountOptions, + serviceAccountTokensOptions, +} from './serviceAccountDetails'; +import type {NewServiceAccountToken, ServiceAccount} from './types'; + +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')), +}); + +type FormValues = z.infer; + +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: ({name, scopes}: FormValues) => + fetchMutation({ + url: getApiUrl( + '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/', + { + path: { + organizationIdOrSlug: organization.slug, + serviceAccountId: serviceAccount.id, + }, + } + ), + method: 'POST', + data: {name: name.trim(), scopes}, + }), + onSuccess: token => { + queryClient.invalidateQueries({ + queryKey: serviceAccountTokensOptions({ + orgSlug: organization.slug, + serviceAccountId: serviceAccount.id, + }).queryKey, + }); + displayNewToken(token.token, handleGoBack); + }, + }); + + const defaultValues: FormValues = {name: '', scopes: serviceAccount.scopes}; + + 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 fieldErrors = requestErrorToFieldErrors(error, formApi.state.values); + if (Object.keys(fieldErrors).length > 0) { + setFieldErrors(formApi, fieldErrors); + return; + } + const detail = error.responseJSON?.detail; + toast.error( + typeof detail === 'string' ? detail : t('Failed to create the token.') + ); + } + }, + }); + + return ( + + + + {field => ( + + + + )} + + + {field => ( + + + + {serviceAccount.scopes.map(scope => ( + + {scope} + + ))} + + + + )} + + + + + + + {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..a6d26155b200 --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx @@ -0,0 +1,143 @@ +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(); + expect(screen.getByText('Never')).toBeInTheDocument(); + }); + + 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..d5a3b08e68ac --- /dev/null +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx @@ -0,0 +1,313 @@ +import {IconAdd} from '@sentry/icons/add'; +import {IconDelete} from '@sentry/icons/delete'; +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; + +import {Button, LinkButton} from '@sentry/scraps/button'; +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 {Confirm} from 'sentry/components/confirm'; +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: '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 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')} + + + } + > + {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}; From 1c7c0a45dccc91df7d0a10340b1d583de8a918ee Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:45:19 +0000 Subject: [PATCH 2/5] ref(service-accounts): Use the scope multi-select and detail errors on the token form Match the create service account form: pick token scopes with the searchable multi-select, limited to the account's own scopes, and read validation errors from the response's detail object as the token endpoints return them. Co-Authored-By: Shaun Kaasten <900809+skaasten@users.noreply.github.com> --- .../newServiceAccountToken.spec.tsx | 43 ++++++++++-- .../newServiceAccountToken.tsx | 69 ++++++++++++------- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx index 84b4f157785c..831e651b6e9b 100644 --- a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx @@ -6,6 +6,8 @@ import { waitFor, } from 'sentry-test/reactTestingLibrary'; +import {toast} from '@sentry/scraps/toast'; + import OrganizationServiceAccountNewToken from './newServiceAccountToken'; describe('OrganizationServiceAccountNewToken', () => { @@ -19,6 +21,7 @@ describe('OrganizationServiceAccountNewToken', () => { beforeEach(() => { MockApiClient.clearMockResponses(); + jest.spyOn(toast, 'error'); MockApiClient.addMockResponse({ url: ACCOUNT_ENDPOINT, body: { @@ -55,8 +58,12 @@ describe('OrganizationServiceAccountNewToken', () => { renderGlobalModal(); await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy'); - expect(screen.getByRole('checkbox', {name: 'project:read'})).toBeChecked(); - await userEvent.click(screen.getByRole('checkbox', {name: 'project:read'})); + 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(() => @@ -88,8 +95,11 @@ describe('OrganizationServiceAccountNewToken', () => { render(, {initialRouterConfig}); await userEvent.type(await screen.findByRole('textbox', {name: 'Name'}), 'ci-deploy'); - await userEvent.click(screen.getByRole('checkbox', {name: 'project:read'})); - await userEvent.click(screen.getByRole('checkbox', {name: 'project:releases'})); + 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(); @@ -102,8 +112,10 @@ describe('OrganizationServiceAccountNewToken', () => { method: 'POST', statusCode: 400, body: { - name: ['A token with this name already exists.'], - scopes: ['Scopes must be a subset of the service account scopes.'], + detail: { + name: ['A token with this name already exists.'], + scopes: ['Scopes must be a subset of the service account scopes.'], + }, }, }); @@ -120,5 +132,24 @@ describe('OrganizationServiceAccountNewToken', () => { 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 index 689b6943a846..c2a36fbef0b2 100644 --- a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx @@ -3,7 +3,7 @@ import {z} from 'zod'; import {Button} from '@sentry/scraps/button'; import {defaultFormOptions, setFieldErrors, useScrapsForm} from '@sentry/scraps/form'; -import {Flex, Stack} from '@sentry/scraps/layout'; +import {Flex} from '@sentry/scraps/layout'; import {toast} from '@sentry/scraps/toast'; import {LoadingError} from 'sentry/components/loadingError'; @@ -13,13 +13,13 @@ 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 {requestErrorToFieldErrors} from 'sentry/utils/requestError/requestErrorToFieldErrors'; 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, @@ -33,6 +33,19 @@ const schema = z.object({ type FormValues = z.infer; +const errorDetailSchema = z.union([ + z.string(), + z.object({ + name: z.array(z.string()).optional(), + scopes: 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(); @@ -80,15 +93,21 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { if (!(error instanceof RequestError)) { throw error; } - const fieldErrors = requestErrorToFieldErrors(error, formApi.state.values); - if (Object.keys(fieldErrors).length > 0) { - setFieldErrors(formApi, fieldErrors); + const detail = getErrorDetail(error); + if (typeof detail === 'string') { + toast.error(detail); return; } - const detail = error.responseJSON?.detail; - toast.error( - typeof detail === 'string' ? detail : t('Failed to create the token.') - ); + const nameError = detail?.name?.[0]; + const scopesError = detail?.scopes?.[0]; + if (nameError || scopesError) { + setFieldErrors(formApi, { + ...(nameError ? {name: {message: nameError}} : {}), + ...(scopesError ? {scopes: {message: scopesError}} : {}), + }); + } else { + toast.error(t('Failed to create the token.')); + } } }, }); @@ -113,21 +132,23 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { {field => ( - - - - {serviceAccount.scopes.map(scope => ( - - {scope} - - ))} - - - + + + )} From b2e381e8df021dbfb2dea225039644fe854e4efe Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:48:16 +0000 Subject: [PATCH 3/5] fix(service-accounts): Import Confirm from @sentry/scraps master moved Confirm into the scraps package (#130251), and CI builds this stack merged with master. --- .../organizationServiceAccounts/serviceAccountDetails.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx index d5a3b08e68ac..44f6d104679b 100644 --- a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx @@ -3,6 +3,7 @@ import {IconDelete} from '@sentry/icons/delete'; import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; 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'; @@ -11,7 +12,6 @@ import {toast} from '@sentry/scraps/toast'; import {Tooltip} from '@sentry/scraps/tooltip'; import {hasEveryAccess} from 'sentry/components/acl/access'; -import {Confirm} from 'sentry/components/confirm'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; From 34e6eb684ed6d34556859b816bb0123e61c6a68b Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:38:11 +0000 Subject: [PATCH 4/5] feat(service-accounts): Route service account token URLs to control silo Frontend half of the service account token endpoints in #130483, split out because frontend and backend deploy separately. It lands first: the patterns are harmless before the backend routes exist, and #130483's test_no_missing_urls passes once this is in. Co-Authored-By: shaun.kaasten <900809+skaasten@users.noreply.github.com> --- static/app/data/controlsiloUrlPatterns.ts | 1 + 1 file changed, 1 insertion(+) 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/$'), From 18d078d455ab7cafaece8925c0149b630ba17a1f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:57:54 +0000 Subject: [PATCH 5/5] feat(service-accounts): Let token creators pick an expiration The New Token form gets an Expiration select: 30 (default), 60 or 90 days, a custom date, or no expiration with a warning. The token list gets an Expires column, and expired tokens show an Expired tag. --- .../newServiceAccountToken.spec.tsx | 75 +++++++++++- .../newServiceAccountToken.tsx | 109 ++++++++++++++++-- .../serviceAccountDetails.spec.tsx | 21 +++- .../serviceAccountDetails.tsx | 26 +++++ 4 files changed, 221 insertions(+), 10 deletions(-) diff --git a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx index 831e651b6e9b..478aa8b6dfd3 100644 --- a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.spec.tsx @@ -10,6 +10,8 @@ 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/'; @@ -70,10 +72,17 @@ describe('OrganizationServiceAccountNewToken', () => { expect(createMock).toHaveBeenCalledWith( TOKENS_ENDPOINT, expect.objectContaining({ - data: {name: 'ci-deploy', scopes: ['project:releases']}, + 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' ); @@ -85,6 +94,70 @@ describe('OrganizationServiceAccountNewToken', () => { ); }); + 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, diff --git a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx index c2a36fbef0b2..60b335737595 100644 --- a/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx +++ b/static/app/views/settings/organizationServiceAccounts/newServiceAccountToken.tsx @@ -26,18 +26,62 @@ import { } from './serviceAccountDetails'; import type {NewServiceAccountToken, ServiceAccount} from './types'; -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')), -}); +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(), }), ]); @@ -55,7 +99,7 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { const handleGoBack = () => navigate(detailsPath); const mutation = useMutation({ - mutationFn: ({name, scopes}: FormValues) => + mutationFn: (values: FormValues) => fetchMutation({ url: getApiUrl( '/organizations/$organizationIdOrSlug/service-accounts/$serviceAccountId/tokens/', @@ -67,7 +111,11 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { } ), method: 'POST', - data: {name: name.trim(), scopes}, + data: { + name: values.name.trim(), + scopes: values.scopes, + expiresAt: getExpiresAt(values), + }, }), onSuccess: token => { queryClient.invalidateQueries({ @@ -80,7 +128,12 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { }, }); - const defaultValues: FormValues = {name: '', scopes: serviceAccount.scopes}; + const defaultValues: FormValues = { + name: '', + scopes: serviceAccount.scopes, + expiration: '30', + customDate: '', + }; const form = useScrapsForm({ ...defaultFormOptions, @@ -100,10 +153,12 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { } const nameError = detail?.name?.[0]; const scopesError = detail?.scopes?.[0]; - if (nameError || scopesError) { + 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.')); @@ -151,6 +206,44 @@ function NewTokenForm({serviceAccount}: {serviceAccount: ServiceAccount}) { )} + + {field => ( + + + + )} + + state.values.expiration === 'custom'}> + {isCustom => + isCustom && ( + + {field => ( + + + + )} + + ) + } + diff --git a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx index a6d26155b200..e65a3e8b52b1 100644 --- a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.spec.tsx @@ -66,7 +66,26 @@ describe('OrganizationServiceAccountDetails', () => { expect(screen.getByText('backend')).toBeInTheDocument(); expect(screen.getByText('sntrym_************abcd')).toBeInTheDocument(); expect(screen.getByText('2 scopes')).toBeInTheDocument(); - expect(screen.getByText('Never')).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 () => { diff --git a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx index 44f6d104679b..b7def85ce7aa 100644 --- a/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx +++ b/static/app/views/settings/organizationServiceAccounts/serviceAccountDetails.tsx @@ -1,7 +1,9 @@ +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'; @@ -12,6 +14,7 @@ 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'; @@ -41,6 +44,7 @@ const TOKEN_COLUMNS: TableColumnConfig[] = [ {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'}, ]; @@ -109,6 +113,24 @@ function ServiceAccountSummary({serviceAccount}: {serviceAccount: ServiceAccount ); } +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, @@ -149,6 +171,9 @@ function TokenRow({ {t('Never')} )} + + + {t('Permissions')} {t('Created')} {t('Last used')} + {t('Expires')} }