Просмотр исходного кода

feat(org-tokens): Implement UI for org token management (#51267)

This implements the management UI for the new org auth tokens.
Note the whole section is still not shown in the UI unless the feature
is enabled.
Francesco Novy 1 год назад
Родитель
Сommit
e49a813637

+ 10 - 0
static/app/types/user.tsx

@@ -104,6 +104,16 @@ export type ApiApplication = {
   termsUrl: string | null;
   termsUrl: string | null;
 };
 };
 
 
+export type OrgAuthToken = {
+  dateCreated: Date;
+  id: string;
+  name: string;
+  scopes: string[];
+  dateLastUsed?: Date;
+  projectLastUsedId?: string;
+  tokenLastCharacters?: string;
+};
+
 // Used in user session history.
 // Used in user session history.
 export type InternetProtocol = {
 export type InternetProtocol = {
   countryCode: string | null;
   countryCode: string | null;

+ 116 - 94
static/app/views/settings/organizationAuthTokens/authTokenDetails.tsx

@@ -1,8 +1,7 @@
-import {useCallback, useEffect, useState} from 'react';
+import {useCallback} from 'react';
 import {browserHistory} from 'react-router';
 import {browserHistory} from 'react-router';
 
 
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-import Alert from 'sentry/components/alert';
 import {Form, TextField} from 'sentry/components/forms';
 import {Form, TextField} from 'sentry/components/forms';
 import FieldGroup from 'sentry/components/forms/fieldGroup';
 import FieldGroup from 'sentry/components/forms/fieldGroup';
 import ExternalLink from 'sentry/components/links/externalLink';
 import ExternalLink from 'sentry/components/links/externalLink';
@@ -11,84 +10,137 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
 import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t, tct} from 'sentry/locale';
 import {t, tct} from 'sentry/locale';
-import {Organization, Project} from 'sentry/types';
-import {setDateToTime} from 'sentry/utils/dates';
-import getDynamicText from 'sentry/utils/getDynamicText';
+import {Organization, OrgAuthToken} from 'sentry/types';
 import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
 import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {
+  setApiQueryData,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import withOrganization from 'sentry/utils/withOrganization';
 import withOrganization from 'sentry/utils/withOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
-import {tokenPreview, TokenWip} from 'sentry/views/settings/organizationAuthTokens';
-
-function generateMockToken({
-  id,
-  name,
-  scopes,
-  dateCreated = new Date(),
-  dateLastUsed,
-  projectLastUsed,
-}: {
-  id: string;
-  name: string;
-  scopes: string[];
-  dateCreated?: Date;
-  dateLastUsed?: Date;
-  projectLastUsed?: Project;
-}): TokenWip {
-  return {
-    id,
-    name,
-    tokenLastCharacters: crypto.randomUUID().slice(0, 4),
-    scopes,
-    dateCreated,
-    dateLastUsed,
-    projectLastUsed,
-  };
-}
+import {
+  makeFetchOrgAuthTokensForOrgQueryKey,
+  tokenPreview,
+} from 'sentry/views/settings/organizationAuthTokens';
 
 
 type Props = {
 type Props = {
   organization: Organization;
   organization: Organization;
   params: {tokenId: string};
   params: {tokenId: string};
 };
 };
 
 
+type FetchOrgAuthTokenParameters = {
+  orgSlug: string;
+  tokenId: string;
+};
+type FetchOrgAuthTokenResponse = OrgAuthToken;
+type UpdateTokenQueryVariables = {
+  name: string;
+};
+
+export const makeFetchOrgAuthTokenKey = ({
+  orgSlug,
+  tokenId,
+}: FetchOrgAuthTokenParameters) =>
+  [`/organizations/${orgSlug}/org-auth-tokens/${tokenId}/`] as const;
+
 function AuthTokenDetailsForm({
 function AuthTokenDetailsForm({
   token,
   token,
   organization,
   organization,
 }: {
 }: {
   organization: Organization;
   organization: Organization;
-  token: TokenWip;
+  token: OrgAuthToken;
 }) {
 }) {
   const initialData = {
   const initialData = {
     name: token.name,
     name: token.name,
     tokenPreview: tokenPreview(token.tokenLastCharacters || '****'),
     tokenPreview: tokenPreview(token.tokenLastCharacters || '****'),
   };
   };
 
 
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/auth-tokens/`));
+  }, [organization.slug]);
+
+  const {mutate: submitToken} = useMutation<{}, RequestError, UpdateTokenQueryVariables>({
+    mutationFn: ({name}) =>
+      api.requestPromise(
+        `/organizations/${organization.slug}/org-auth-tokens/${token.id}/`,
+        {
+          method: 'PUT',
+          data: {
+            name,
+          },
+        }
+      ),
+
+    onSuccess: (_data, {name}) => {
+      addSuccessMessage(t('Updated auth token.'));
+
+      // Update get by id query
+      setApiQueryData(
+        queryClient,
+        makeFetchOrgAuthTokenKey({orgSlug: organization.slug, tokenId: token.id}),
+        (oldData: OrgAuthToken | undefined) => {
+          if (!oldData) {
+            return oldData;
+          }
+
+          oldData.name = name;
+
+          return oldData;
+        }
+      );
+
+      // Update get list query
+      setApiQueryData(
+        queryClient,
+        makeFetchOrgAuthTokensForOrgQueryKey({orgSlug: organization.slug}),
+        (oldData: OrgAuthToken[] | undefined) => {
+          if (!Array.isArray(oldData)) {
+            return oldData;
+          }
+
+          const existingToken = oldData.find(oldToken => oldToken.id === token.id);
+
+          if (existingToken) {
+            existingToken.name = name;
+          }
+
+          return oldData;
+        }
+      );
+
+      handleGoBack();
+    },
+    onError: error => {
+      const message = t('Failed to update the auth token.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
   return (
   return (
     <Form
     <Form
       apiMethod="PUT"
       apiMethod="PUT"
       initialData={initialData}
       initialData={initialData}
-      apiEndpoint={`/organizations/${organization.slug}/auth-tokens/${token.id}/`}
-      onSubmit={() => {
-        // TODO FN: Actually submit data
-
-        try {
-          const message = t('Successfully updated the auth token.');
-          addSuccessMessage(message);
-        } catch (error) {
-          const message = t('Failed to update the auth token.');
-          handleXhrErrorResponse(message, error);
-          addErrorMessage(message);
-        }
+      apiEndpoint={`/organizations/${organization.slug}/org-auth-tokens/${token.id}/`}
+      onSubmit={({name}) => {
+        submitToken({
+          name,
+        });
       }}
       }}
-      onCancel={() =>
-        browserHistory.push(normalizeUrl(`/settings/${organization.slug}/auth-tokens/`))
-      }
+      onCancel={handleGoBack}
     >
     >
       <TextField
       <TextField
         name="name"
         name="name"
         label={t('Name')}
         label={t('Name')}
-        value={token.dateLastUsed}
         required
         required
         help={t('A name to help you identify this token.')}
         help={t('A name to help you identify this token.')}
       />
       />
@@ -96,24 +148,13 @@ function AuthTokenDetailsForm({
       <TextField
       <TextField
         name="tokenPreview"
         name="tokenPreview"
         label={t('Token')}
         label={t('Token')}
-        value={tokenPreview(
-          token.tokenLastCharacters
-            ? getDynamicText({
-                value: token.tokenLastCharacters,
-                fixed: 'ABCD',
-              })
-            : '****'
-        )}
         disabled
         disabled
         help={t('You can only view the token once after creation.')}
         help={t('You can only view the token once after creation.')}
       />
       />
 
 
       <FieldGroup
       <FieldGroup
         label={t('Scopes')}
         label={t('Scopes')}
-        inline={false}
-        help={t(
-          'You cannot change the scopes of an existing token. If you need different scopes, please create a new token.'
-        )}
+        help={t('You cannot change the scopes of an existing token.')}
       >
       >
         <div>{token.scopes.slice().sort().join(', ')}</div>
         <div>{token.scopes.slice().sort().join(', ')}</div>
       </FieldGroup>
       </FieldGroup>
@@ -122,44 +163,25 @@ function AuthTokenDetailsForm({
 }
 }
 
 
 export function OrganizationAuthTokensDetails({params, organization}: Props) {
 export function OrganizationAuthTokensDetails({params, organization}: Props) {
-  const [token, setToken] = useState<TokenWip | null>(null);
-  const [hasLoadingError, setHasLoadingError] = useState(false);
-
   const {tokenId} = params;
   const {tokenId} = params;
 
 
-  const fetchToken = useCallback(async () => {
-    try {
-      // TODO FN: Actually do something here
-      await new Promise(resolve => setTimeout(resolve, 500));
-      setToken(
-        generateMockToken({
-          id: tokenId,
-          name: 'custom token',
-          scopes: ['org:ci'],
-          dateLastUsed: setDateToTime(new Date(), '00:05:00'),
-          projectLastUsed: {slug: 'my-project', name: 'My Project'} as Project,
-          dateCreated: setDateToTime(new Date(), '00:01:00'),
-        })
-      );
-      setHasLoadingError(false);
-    } catch (error) {
-      const message = t('Failed to load auth token.');
-      handleXhrErrorResponse(message, error);
-      setHasLoadingError(error);
+  const {
+    isLoading,
+    isError,
+    data: token,
+    refetch: refetchToken,
+  } = useApiQuery<FetchOrgAuthTokenResponse>(
+    makeFetchOrgAuthTokenKey({orgSlug: organization.slug, tokenId}),
+    {
+      staleTime: Infinity,
     }
     }
-  }, [tokenId]);
-
-  useEffect(() => {
-    fetchToken();
-  }, [fetchToken]);
+  );
 
 
   return (
   return (
     <div>
     <div>
       <SentryDocumentTitle title={t('Edit Auth Token')} />
       <SentryDocumentTitle title={t('Edit Auth Token')} />
       <SettingsPageHeader title={t('Edit Auth Token')} />
       <SettingsPageHeader title={t('Edit Auth Token')} />
 
 
-      <Alert>Note: This page is WIP and currently only shows mocked data.</Alert>
-
       <TextBlock>
       <TextBlock>
         {t(
         {t(
           "Authentication tokens allow you to perform actions against the Sentry API on behalf of your organization. They're the easiest way to get started using the API."
           "Authentication tokens allow you to perform actions against the Sentry API on behalf of your organization. They're the easiest way to get started using the API."
@@ -177,16 +199,16 @@ export function OrganizationAuthTokensDetails({params, organization}: Props) {
         <PanelHeader>{t('Auth Token Details')}</PanelHeader>
         <PanelHeader>{t('Auth Token Details')}</PanelHeader>
 
 
         <PanelBody>
         <PanelBody>
-          {hasLoadingError && (
+          {isError && (
             <LoadingError
             <LoadingError
               message={t('Failed to load auth token.')}
               message={t('Failed to load auth token.')}
-              onRetry={fetchToken}
+              onRetry={refetchToken}
             />
             />
           )}
           )}
 
 
-          {!hasLoadingError && !token && <LoadingIndicator />}
+          {isLoading && <LoadingIndicator />}
 
 
-          {!hasLoadingError && token && (
+          {!isLoading && !isError && token && (
             <AuthTokenDetailsForm token={token} organization={organization} />
             <AuthTokenDetailsForm token={token} organization={organization} />
           )}
           )}
         </PanelBody>
         </PanelBody>

+ 159 - 0
static/app/views/settings/organizationAuthTokens/authTokenRow.spec.tsx

@@ -0,0 +1,159 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import OrganizationsStore from 'sentry/stores/organizationsStore';
+import {OrgAuthToken} from 'sentry/types';
+import {OrganizationAuthTokensAuthTokenRow} from 'sentry/views/settings/organizationAuthTokens/authTokenRow';
+
+describe('OrganizationAuthTokensAuthTokenRow', function () {
+  const {organization, router} = initializeOrg();
+
+  const revokeToken = jest.fn();
+  const token: OrgAuthToken = {
+    id: '1',
+    name: 'My Token',
+    tokenLastCharacters: 'XYZ1',
+    dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+    scopes: ['org:read'],
+  };
+
+  const defaultProps = {
+    organization,
+    isRevoking: false,
+    token,
+    revokeToken,
+    projectLastUsed: undefined,
+    router,
+    location: router.location,
+    params: {orgId: organization.slug},
+    routes: router.routes,
+    route: {},
+    routeParams: router.params,
+  };
+
+  beforeEach(function () {
+    OrganizationsStore.addOrReplace(organization);
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('shows token without last used information', function () {
+    render(<OrganizationAuthTokensAuthTokenRow {...defaultProps} />);
+
+    expect(screen.getByLabelText('Token preview')).toHaveTextContent(
+      'sntrys_************XYZ1'
+    );
+    expect(screen.getByText('never used')).toBeInTheDocument();
+    expect(screen.getByText('My Token')).toBeInTheDocument();
+  });
+
+  describe('last used info', function () {
+    it('shows full last used info', function () {
+      const props = {
+        ...defaultProps,
+        projectLastUsed: TestStubs.Project(),
+        token: {
+          ...token,
+          dateLastUsed: new Date(),
+        },
+      };
+
+      render(<OrganizationAuthTokensAuthTokenRow {...props} />);
+
+      expect(screen.getByLabelText('Token preview')).toHaveTextContent(
+        'sntrys_************XYZ1'
+      );
+      expect(
+        screen.getByText(
+          textWithMarkupMatcher('a few seconds ago in project Project Name')
+        )
+      ).toBeInTheDocument();
+      expect(screen.getByText('My Token')).toBeInTheDocument();
+    });
+
+    it('shows last used project only', function () {
+      const props = {
+        ...defaultProps,
+        projectLastUsed: TestStubs.Project(),
+        token: {
+          ...token,
+        },
+      };
+
+      render(<OrganizationAuthTokensAuthTokenRow {...props} />);
+
+      expect(screen.getByLabelText('Token preview')).toHaveTextContent(
+        'sntrys_************XYZ1'
+      );
+      expect(
+        screen.getByText(textWithMarkupMatcher('in project Project Name'))
+      ).toBeInTheDocument();
+      expect(screen.getByText('My Token')).toBeInTheDocument();
+    });
+
+    it('shows last used date only', function () {
+      const props = {
+        ...defaultProps,
+        token: {
+          ...token,
+          dateLastUsed: new Date(),
+        },
+      };
+
+      render(<OrganizationAuthTokensAuthTokenRow {...props} />);
+
+      expect(screen.getByLabelText('Token preview')).toHaveTextContent(
+        'sntrys_************XYZ1'
+      );
+      expect(
+        screen.getByText(textWithMarkupMatcher('a few seconds ago'))
+      ).toBeInTheDocument();
+      expect(screen.getByText('My Token')).toBeInTheDocument();
+    });
+  });
+
+  describe('revoking', function () {
+    it('does not allow to revoke without access', function () {
+      const props = {
+        ...defaultProps,
+        revokeToken: undefined,
+      };
+
+      render(<OrganizationAuthTokensAuthTokenRow {...props} />);
+
+      expect(screen.getByRole('button', {name: 'Revoke My Token'})).toBeDisabled();
+    });
+
+    it('allows to revoke', async function () {
+      render(<OrganizationAuthTokensAuthTokenRow {...defaultProps} />);
+      renderGlobalModal();
+
+      expect(screen.getByRole('button', {name: 'Revoke My Token'})).toBeEnabled();
+
+      await userEvent.click(screen.getByRole('button', {name: 'Revoke My Token'}));
+      // Confirm modal
+      await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+
+      expect(revokeToken).toHaveBeenCalledWith(token);
+    });
+
+    it('does not allow to revoke while revoking in progress', function () {
+      const props = {
+        ...defaultProps,
+        isRevoking: true,
+      };
+
+      render(<OrganizationAuthTokensAuthTokenRow {...props} />);
+
+      expect(screen.getByRole('button', {name: 'Revoke My Token'})).toBeDisabled();
+    });
+  });
+});

+ 66 - 87
static/app/views/settings/organizationAuthTokens/authTokenRow.tsx

@@ -5,15 +5,13 @@ import {Button} from 'sentry/components/button';
 import Confirm from 'sentry/components/confirm';
 import Confirm from 'sentry/components/confirm';
 import Link from 'sentry/components/links/link';
 import Link from 'sentry/components/links/link';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {PanelItem} from 'sentry/components/panels';
 import TimeSince from 'sentry/components/timeSince';
 import TimeSince from 'sentry/components/timeSince';
 import {Tooltip} from 'sentry/components/tooltip';
 import {Tooltip} from 'sentry/components/tooltip';
 import {IconSubtract} from 'sentry/icons';
 import {IconSubtract} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import {t, tct} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {Organization, Project} from 'sentry/types';
+import {Organization, OrgAuthToken, Project} from 'sentry/types';
 import getDynamicText from 'sentry/utils/getDynamicText';
 import getDynamicText from 'sentry/utils/getDynamicText';
-import {tokenPreview, TokenWip} from 'sentry/views/settings/organizationAuthTokens';
+import {tokenPreview} from 'sentry/views/settings/organizationAuthTokens';
 
 
 function LastUsed({
 function LastUsed({
   organization,
   organization,
@@ -27,7 +25,7 @@ function LastUsed({
   if (dateLastUsed && projectLastUsed) {
   if (dateLastUsed && projectLastUsed) {
     return (
     return (
       <Fragment>
       <Fragment>
-        {tct('Last used [date] in [project]', {
+        {tct('[date] in project [project]', {
           date: (
           date: (
             <TimeSince
             <TimeSince
               date={getDynamicText({
               date={getDynamicText({
@@ -49,16 +47,12 @@ function LastUsed({
   if (dateLastUsed) {
   if (dateLastUsed) {
     return (
     return (
       <Fragment>
       <Fragment>
-        {tct('Last used [date]', {
-          date: (
-            <TimeSince
-              date={getDynamicText({
-                value: dateLastUsed,
-                fixed: new Date(1508208080000), // National Pasta Day
-              })}
-            />
-          ),
-        })}
+        <TimeSince
+          date={getDynamicText({
+            value: dateLastUsed,
+            fixed: new Date(1508208080000), // National Pasta Day
+          })}
+        />
       </Fragment>
       </Fragment>
     );
     );
   }
   }
@@ -66,7 +60,7 @@ function LastUsed({
   if (projectLastUsed) {
   if (projectLastUsed) {
     return (
     return (
       <Fragment>
       <Fragment>
-        {tct('Last used in [project]', {
+        {tct('in project [project]', {
           project: (
           project: (
             <Link to={`/settings/${organization.slug}/${projectLastUsed.slug}/`}>
             <Link to={`/settings/${organization.slug}/${projectLastUsed.slug}/`}>
               {projectLastUsed.name}
               {projectLastUsed.name}
@@ -77,7 +71,7 @@ function LastUsed({
     );
     );
   }
   }
 
 
-  return <Fragment>{t('Never used')}</Fragment>;
+  return <NeverUsed>{t('never used')}</NeverUsed>;
 }
 }
 
 
 export function OrganizationAuthTokensAuthTokenRow({
 export function OrganizationAuthTokensAuthTokenRow({
@@ -85,59 +79,25 @@ export function OrganizationAuthTokensAuthTokenRow({
   isRevoking,
   isRevoking,
   token,
   token,
   revokeToken,
   revokeToken,
-  canRevoke,
+  projectLastUsed,
 }: {
 }: {
-  canRevoke: boolean;
   isRevoking: boolean;
   isRevoking: boolean;
   organization: Organization;
   organization: Organization;
-  revokeToken: (token: TokenWip) => void;
-  token: TokenWip;
+  token: OrgAuthToken;
+  projectLastUsed?: Project;
+  revokeToken?: (token: OrgAuthToken) => void;
 }) {
 }) {
   return (
   return (
-    <StyledPanelItem>
-      <StyledPanelHeader>
+    <Fragment>
+      <div>
         <Label>
         <Label>
           <Link to={`/settings/${organization.slug}/auth-tokens/${token.id}/`}>
           <Link to={`/settings/${organization.slug}/auth-tokens/${token.id}/`}>
             {token.name}
             {token.name}
           </Link>
           </Link>
         </Label>
         </Label>
 
 
-        <Actions>
-          <Tooltip
-            title={t(
-              'You must be an organization owner, manager or admin to revoke a token.'
-            )}
-            disabled={canRevoke}
-          >
-            <Confirm
-              disabled={!canRevoke || isRevoking}
-              onConfirm={() => revokeToken(token)}
-              message={t(
-                'Are you sure you want to revoke this token? The token will not be usable anymore, and this cannot be undone.'
-              )}
-            >
-              <Button
-                size="sm"
-                onClick={() => revokeToken(token)}
-                disabled={isRevoking || !canRevoke}
-                icon={
-                  isRevoking ? (
-                    <LoadingIndicator mini />
-                  ) : (
-                    <IconSubtract isCircled size="xs" />
-                  )
-                }
-              >
-                {t('Revoke')}
-              </Button>
-            </Confirm>
-          </Tooltip>
-        </Actions>
-      </StyledPanelHeader>
-
-      <StyledPanelBody>
         {token.tokenLastCharacters && (
         {token.tokenLastCharacters && (
-          <TokenPreview>
+          <TokenPreview aria-label={t('Token preview')}>
             {tokenPreview(
             {tokenPreview(
               getDynamicText({
               getDynamicText({
                 value: token.tokenLastCharacters,
                 value: token.tokenLastCharacters,
@@ -146,48 +106,67 @@ export function OrganizationAuthTokensAuthTokenRow({
             )}
             )}
           </TokenPreview>
           </TokenPreview>
         )}
         )}
-
-        <LastUsedDate>
-          <LastUsed
-            dateLastUsed={token.dateLastUsed}
-            projectLastUsed={token.projectLastUsed}
-            organization={organization}
-          />
-        </LastUsedDate>
-      </StyledPanelBody>
-    </StyledPanelItem>
+      </div>
+
+      <LastUsedDate>
+        <LastUsed
+          dateLastUsed={token.dateLastUsed}
+          projectLastUsed={projectLastUsed}
+          organization={organization}
+        />
+      </LastUsedDate>
+
+      <Actions>
+        <Tooltip
+          title={t(
+            'You must be an organization owner, manager or admin to revoke a token.'
+          )}
+          disabled={!!revokeToken}
+        >
+          <Confirm
+            disabled={!revokeToken || isRevoking}
+            onConfirm={revokeToken ? () => revokeToken(token) : undefined}
+            message={t(
+              'Are you sure you want to revoke this token? The token will not be usable anymore, and this cannot be undone.'
+            )}
+          >
+            <Button
+              size="sm"
+              disabled={isRevoking || !revokeToken}
+              aria-label={t('Revoke %s', token.name)}
+              icon={
+                isRevoking ? (
+                  <LoadingIndicator mini />
+                ) : (
+                  <IconSubtract isCircled size="xs" />
+                )
+              }
+            >
+              {t('Revoke')}
+            </Button>
+          </Confirm>
+        </Tooltip>
+      </Actions>
+    </Fragment>
   );
   );
 }
 }
 
 
-const StyledPanelItem = styled(PanelItem)`
-  flex-direction: column;
-  padding: ${space(2)};
-  gap: ${space(1)};
-`;
-
-const StyledPanelHeader = styled('div')`
-  display: flex;
-  flex-wrap: wrap;
-  align-items: center;
-  gap: ${space(0.25)} ${space(1)};
-`;
-
 const Label = styled('div')``;
 const Label = styled('div')``;
 
 
 const Actions = styled('div')`
 const Actions = styled('div')`
-  margin-left: auto;
+  display: flex;
+  justify-content: flex-end;
 `;
 `;
 
 
-const StyledPanelBody = styled('div')`
+const LastUsedDate = styled('div')`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
 `;
 `;
 
 
-const LastUsedDate = styled('div')`
-  font-size: ${p => p.theme.fontSizeRelativeSmall};
-  margin-left: auto;
+const NeverUsed = styled('div')`
+  color: ${p => p.theme.gray300};
 `;
 `;
 
 
 const TokenPreview = styled('div')`
 const TokenPreview = styled('div')`
-  font-size: ${p => p.theme.fontSizeRelativeSmall};
+  color: ${p => p.theme.gray300};
 `;
 `;

+ 276 - 0
static/app/views/settings/organizationAuthTokens/index.spec.tsx

@@ -0,0 +1,276 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+  waitForElementToBeRemoved,
+} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import * as indicators from 'sentry/actionCreators/indicator';
+import OrganizationsStore from 'sentry/stores/organizationsStore';
+import {OrgAuthToken} from 'sentry/types';
+import {OrganizationAuthTokensIndex} from 'sentry/views/settings/organizationAuthTokens';
+
+describe('OrganizationAuthTokensIndex', function () {
+  const ENDPOINT = '/organizations/org-slug/org-auth-tokens/';
+  const PROJECTS_ENDPOINT = '/organizations/org-slug/projects/';
+  const {organization, project, router} = initializeOrg();
+
+  const defaultProps = {
+    organization,
+    router,
+    location: router.location,
+    params: {orgId: organization.slug},
+    routes: router.routes,
+    route: {},
+    routeParams: router.params,
+  };
+
+  let projectsMock: jest.Mock<any>;
+
+  beforeEach(function () {
+    OrganizationsStore.addOrReplace(organization);
+
+    projectsMock = MockApiClient.addMockResponse({
+      url: PROJECTS_ENDPOINT,
+      method: 'GET',
+      body: [project],
+    });
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('shows tokens', async function () {
+    const tokens: OrgAuthToken[] = [
+      {
+        id: '1',
+        name: 'My Token 1',
+        tokenLastCharacters: '1234',
+        dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+        scopes: ['org:read'],
+      },
+      {
+        id: '2',
+        name: 'My Token 2',
+        tokenLastCharacters: 'ABCD',
+        dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+        scopes: ['org:read'],
+        dateLastUsed: new Date(),
+        projectLastUsedId: project.id,
+      },
+    ];
+
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      body: tokens,
+    });
+
+    render(<OrganizationAuthTokensIndex {...defaultProps} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    // Then list
+    expect(screen.getByText('My Token 1')).toBeInTheDocument();
+    expect(screen.getByText('My Token 2')).toBeInTheDocument();
+    expect(screen.getByText('never used')).toBeInTheDocument();
+    expect(
+      await screen.findByText(
+        textWithMarkupMatcher('a few seconds ago in project Project Name')
+      )
+    ).toBeInTheDocument();
+    expect(screen.queryByTestId('loading-error')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+
+    expect(mock).toHaveBeenCalledTimes(1);
+    expect(mock).toHaveBeenCalledWith(ENDPOINT, expect.objectContaining({method: 'GET'}));
+    expect(projectsMock).toHaveBeenCalledTimes(1);
+    expect(projectsMock).toHaveBeenCalledWith(
+      PROJECTS_ENDPOINT,
+      expect.objectContaining({method: 'GET', query: {query: `id:${project.id}`}})
+    );
+  });
+
+  it('handle error when loading tokens', async function () {
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      statusCode: 400,
+    });
+
+    render(<OrganizationAuthTokensIndex {...defaultProps} />);
+
+    expect(await screen.findByTestId('loading-error')).toHaveTextContent(
+      'Failed to load auth tokens for the organization.'
+    );
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument();
+
+    expect(mock).toHaveBeenCalledTimes(1);
+  });
+
+  it('shows empty state', async function () {
+    const tokens: OrgAuthToken[] = [];
+
+    MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'GET',
+      body: tokens,
+    });
+
+    render(<OrganizationAuthTokensIndex {...defaultProps} />);
+
+    await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator'));
+
+    expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+    expect(screen.queryByTestId('loading-error')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
+  });
+
+  describe('revoking', function () {
+    it('allows to revoke tokens', async function () {
+      jest.spyOn(indicators, 'addSuccessMessage');
+
+      const tokens: OrgAuthToken[] = [
+        {
+          id: '1',
+          name: 'My Token 1',
+          tokenLastCharacters: '1234',
+          dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+          scopes: ['org:read'],
+        },
+        {
+          id: '2',
+          name: 'My Token 2',
+          tokenLastCharacters: 'ABCD',
+          dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+          scopes: ['org:read'],
+        },
+        {
+          id: '3',
+          name: 'My Token 3',
+          tokenLastCharacters: 'ABCD',
+          dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+          scopes: ['org:read'],
+        },
+      ];
+
+      MockApiClient.addMockResponse({
+        url: ENDPOINT,
+        method: 'GET',
+        body: tokens,
+      });
+
+      const deleteMock = MockApiClient.addMockResponse({
+        url: `${ENDPOINT}2/`,
+        method: 'DELETE',
+      });
+
+      render(<OrganizationAuthTokensIndex {...defaultProps} />);
+      renderGlobalModal();
+
+      expect(await screen.findByText('My Token 1')).toBeInTheDocument();
+      expect(screen.getByText('My Token 2')).toBeInTheDocument();
+      expect(screen.getByText('My Token 3')).toBeInTheDocument();
+
+      expect(screen.getByLabelText('Revoke My Token 2')).toBeEnabled();
+
+      await userEvent.click(screen.getByLabelText('Revoke My Token 2'));
+      // Confirm modal
+      await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+
+      expect(screen.getByText('My Token 1')).toBeInTheDocument();
+      expect(screen.queryByText('My Token 2')).not.toBeInTheDocument();
+      expect(screen.getByText('My Token 3')).toBeInTheDocument();
+
+      expect(indicators.addSuccessMessage).toHaveBeenCalledWith(
+        'Revoked auth token for the organization.'
+      );
+
+      expect(deleteMock).toHaveBeenCalledTimes(1);
+    });
+
+    it('handles API error when revoking token', async function () {
+      jest.spyOn(indicators, 'addErrorMessage');
+
+      const tokens: OrgAuthToken[] = [
+        {
+          id: '1',
+          name: 'My Token 1',
+          tokenLastCharacters: '1234',
+          dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+          scopes: ['org:read'],
+        },
+      ];
+
+      MockApiClient.addMockResponse({
+        url: ENDPOINT,
+        method: 'GET',
+        body: tokens,
+      });
+
+      const deleteMock = MockApiClient.addMockResponse({
+        url: `${ENDPOINT}1/`,
+        method: 'DELETE',
+        statusCode: 400,
+      });
+
+      render(<OrganizationAuthTokensIndex {...defaultProps} />);
+      renderGlobalModal();
+
+      expect(await screen.findByText('My Token 1')).toBeInTheDocument();
+
+      expect(screen.getByLabelText('Revoke My Token 1')).toBeEnabled();
+
+      await userEvent.click(screen.getByLabelText('Revoke My Token 1'));
+      // Confirm modal
+      await userEvent.click(screen.getByRole('button', {name: 'Confirm'}));
+
+      expect(screen.getByText('My Token 1')).toBeInTheDocument();
+
+      expect(indicators.addErrorMessage).toHaveBeenCalledWith(
+        'Failed to revoke the auth token for the organization.'
+      );
+
+      expect(deleteMock).toHaveBeenCalledTimes(1);
+    });
+
+    it('does not allow to revoke without permission', async function () {
+      const org = TestStubs.Organization({
+        access: ['org:read'],
+      });
+
+      const tokens: OrgAuthToken[] = [
+        {
+          id: '1',
+          name: 'My Token 1',
+          tokenLastCharacters: '1234',
+          dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+          scopes: ['org:read'],
+        },
+      ];
+
+      const props = {
+        ...defaultProps,
+        organization: org,
+      };
+
+      MockApiClient.addMockResponse({
+        url: ENDPOINT,
+        method: 'GET',
+        body: tokens,
+      });
+
+      render(<OrganizationAuthTokensIndex {...props} />, {organization: org});
+
+      expect(await screen.findByText('My Token 1')).toBeInTheDocument();
+
+      expect(screen.getByLabelText('Revoke My Token 1')).toBeDisabled();
+    });
+  });
+});

+ 145 - 123
static/app/views/settings/organizationAuthTokens/index.tsx

@@ -1,56 +1,88 @@
-import {Fragment, useCallback, useEffect, useState} from 'react';
+import {Fragment} from 'react';
+import styled from '@emotion/styled';
 
 
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
 import Access from 'sentry/components/acl/access';
 import Access from 'sentry/components/acl/access';
-import Alert from 'sentry/components/alert';
 import {Button} from 'sentry/components/button';
 import {Button} from 'sentry/components/button';
-import EmptyMessage from 'sentry/components/emptyMessage';
 import ExternalLink from 'sentry/components/links/externalLink';
 import ExternalLink from 'sentry/components/links/externalLink';
 import LoadingError from 'sentry/components/loadingError';
 import LoadingError from 'sentry/components/loadingError';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
+import {PanelTable} from 'sentry/components/panels';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {t, tct} from 'sentry/locale';
 import {t, tct} from 'sentry/locale';
-import {Organization, Project} from 'sentry/types';
-import {setDateToTime} from 'sentry/utils/dates';
+import {Organization, OrgAuthToken, Project} from 'sentry/types';
 import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
 import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {
+  setApiQueryData,
+  useApiQuery,
+  useMutation,
+  useQueryClient,
+} from 'sentry/utils/queryClient';
+import RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
 import withOrganization from 'sentry/utils/withOrganization';
 import withOrganization from 'sentry/utils/withOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import {OrganizationAuthTokensAuthTokenRow} from 'sentry/views/settings/organizationAuthTokens/authTokenRow';
 import {OrganizationAuthTokensAuthTokenRow} from 'sentry/views/settings/organizationAuthTokens/authTokenRow';
 
 
-export type TokenWip = {
-  dateCreated: Date;
-  id: string;
-  name: string;
-  scopes: string[];
-  dateLastUsed?: Date;
-  projectLastUsed?: Project;
-  tokenLastCharacters?: string;
+type FetchOrgAuthTokensResponse = OrgAuthToken[];
+type FetchOrgAuthTokensParameters = {
+  orgSlug: string;
 };
 };
+type RevokeTokenQueryVariables = {
+  token: OrgAuthToken;
+};
+
+export const makeFetchOrgAuthTokensForOrgQueryKey = ({
+  orgSlug,
+}: FetchOrgAuthTokensParameters) =>
+  [`/organizations/${orgSlug}/org-auth-tokens/`] as const;
 
 
-function generateMockToken({
-  name,
-  scopes,
-  dateCreated = new Date(),
-  dateLastUsed,
-  projectLastUsed,
+function TokenList({
+  organization,
+  tokenList,
+  isRevoking,
+  revokeToken,
 }: {
 }: {
-  name: string;
-  scopes: string[];
-  dateCreated?: Date;
-  dateLastUsed?: Date;
-  projectLastUsed?: Project;
-}): TokenWip {
-  return {
-    id: crypto.randomUUID(),
-    name,
-    tokenLastCharacters: crypto.randomUUID().slice(0, 4),
-    scopes,
-    dateCreated,
-    dateLastUsed,
-    projectLastUsed,
-  };
+  isRevoking: boolean;
+  organization: Organization;
+  tokenList: OrgAuthToken[];
+  revokeToken?: (data: {token: OrgAuthToken}) => void;
+}) {
+  const apiEndpoint = `/organizations/${organization.slug}/projects/`;
+
+  const projectIds = tokenList
+    .map(token => token.projectLastUsedId)
+    .filter(id => !!id) as string[];
+
+  const idQueryParams = projectIds.map(id => `id:${id}`).join(' ');
+
+  const {data: projects} = useApiQuery<Project[]>(
+    [apiEndpoint, {query: {query: idQueryParams}}],
+    {
+      staleTime: 0,
+      enabled: projectIds.length > 0,
+    }
+  );
+
+  return (
+    <Fragment>
+      {tokenList.map(token => {
+        const projectLastUsed = token.projectLastUsedId
+          ? projects?.find(p => p.id === token.projectLastUsedId)
+          : undefined;
+        return (
+          <OrganizationAuthTokensAuthTokenRow
+            key={token.id}
+            organization={organization}
+            token={token}
+            isRevoking={isRevoking}
+            revokeToken={revokeToken ? () => revokeToken({token}) : undefined}
+            projectLastUsed={projectLastUsed}
+          />
+        );
+      })}
+    </Fragment>
+  );
 }
 }
 
 
 export function OrganizationAuthTokensIndex({
 export function OrganizationAuthTokensIndex({
@@ -58,60 +90,56 @@ export function OrganizationAuthTokensIndex({
 }: {
 }: {
   organization: Organization;
   organization: Organization;
 }) {
 }) {
-  const [tokenList, setTokenList] = useState<TokenWip[] | null>(null);
-  const [hasLoadingError, setHasLoadingError] = useState(false);
-  const [isRevoking, setIsRevoking] = useState(false);
-
-  const fetchTokenList = useCallback(async () => {
-    try {
-      // TODO FN: Actually do something here
-      await new Promise(resolve => setTimeout(resolve, 500));
-      setTokenList([
-        generateMockToken({
-          name: 'custom token',
-          scopes: ['org:ci'],
-          dateLastUsed: setDateToTime(new Date(), '00:05:00'),
-          projectLastUsed: {slug: 'my-project', name: 'My Project'} as Project,
-          dateCreated: setDateToTime(new Date(), '00:01:00'),
-        }),
-        generateMockToken({
-          name: 'my-project CI token',
-          scopes: ['org:ci'],
-          dateLastUsed: new Date('2023-06-09'),
-        }),
-        generateMockToken({name: 'my-pro2 CI token', scopes: ['org:ci']}),
-      ]);
-      setHasLoadingError(false);
-    } catch (error) {
-      const message = t('Failed to load auth tokens for the organization.');
-      handleXhrErrorResponse(message, error);
-      setHasLoadingError(error);
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const {
+    isLoading,
+    isError,
+    data: tokenList,
+    refetch: refetchTokenList,
+  } = useApiQuery<FetchOrgAuthTokensResponse>(
+    makeFetchOrgAuthTokensForOrgQueryKey({orgSlug: organization.slug}),
+    {
+      staleTime: Infinity,
     }
     }
-  }, []);
-
-  const handleRevokeToken = useCallback(
-    async (token: TokenWip) => {
-      try {
-        setIsRevoking(true);
-        // TODO FN: Actually do something here
-        await new Promise(resolve => setTimeout(resolve, 500));
-        const newTokens = (tokenList || []).filter(
-          tokenCompare => tokenCompare !== token
-        );
-        setTokenList(newTokens);
-
-        addSuccessMessage(t('Revoked auth token for the organization.'));
-      } catch (error) {
-        const message = t('Failed to revoke the auth token for the organization.');
-        handleXhrErrorResponse(message, error);
-        addErrorMessage(message);
-      } finally {
-        setIsRevoking(false);
-      }
-    },
-    [tokenList]
   );
   );
 
 
+  const {mutate: handleRevokeToken, isLoading: isRevoking} = useMutation<
+    {},
+    RequestError,
+    RevokeTokenQueryVariables
+  >({
+    mutationFn: ({token}) =>
+      api.requestPromise(
+        `/organizations/${organization.slug}/org-auth-tokens/${token.id}/`,
+        {
+          method: 'DELETE',
+        }
+      ),
+
+    onSuccess: (_data, {token}) => {
+      addSuccessMessage(t('Revoked auth token for the organization.'));
+
+      setApiQueryData(
+        queryClient,
+        makeFetchOrgAuthTokensForOrgQueryKey({orgSlug: organization.slug}),
+        oldData => {
+          if (!Array.isArray(oldData)) {
+            return oldData;
+          }
+
+          return oldData.filter(oldToken => oldToken.id !== token.id);
+        }
+      );
+    },
+    onError: error => {
+      const message = t('Failed to revoke the auth token for the organization.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
   const createNewToken = (
   const createNewToken = (
     <Button
     <Button
       priority="primary"
       priority="primary"
@@ -123,10 +151,6 @@ export function OrganizationAuthTokensIndex({
     </Button>
     </Button>
   );
   );
 
 
-  useEffect(() => {
-    fetchTokenList();
-  }, [fetchTokenList]);
-
   return (
   return (
     <Access access={['org:write']}>
     <Access access={['org:write']}>
       {({hasAccess}) => (
       {({hasAccess}) => (
@@ -134,8 +158,6 @@ export function OrganizationAuthTokensIndex({
           <SentryDocumentTitle title={t('Auth Tokens')} />
           <SentryDocumentTitle title={t('Auth Tokens')} />
           <SettingsPageHeader title={t('Auth Tokens')} action={createNewToken} />
           <SettingsPageHeader title={t('Auth Tokens')} action={createNewToken} />
 
 
-          <Alert>Note: This page is WIP and currently only shows mocked data.</Alert>
-
           <TextBlock>
           <TextBlock>
             {t(
             {t(
               "Authentication tokens allow you to perform actions against the Sentry API on behalf of your organization. They're the easiest way to get started using the API."
               "Authentication tokens allow you to perform actions against the Sentry API on behalf of your organization. They're the easiest way to get started using the API."
@@ -149,40 +171,30 @@ export function OrganizationAuthTokensIndex({
               }
               }
             )}
             )}
           </TextBlock>
           </TextBlock>
-          <Panel>
-            <PanelHeader>{t('Auth Token')}</PanelHeader>
 
 
-            <PanelBody>
-              {hasLoadingError && (
+          <ResponsivePanelTable
+            isLoading={isLoading || isError}
+            isEmpty={!isLoading && !tokenList?.length}
+            loader={
+              isError ? (
                 <LoadingError
                 <LoadingError
                   message={t('Failed to load auth tokens for the organization.')}
                   message={t('Failed to load auth tokens for the organization.')}
-                  onRetry={fetchTokenList}
+                  onRetry={refetchTokenList}
                 />
                 />
-              )}
-
-              {!hasLoadingError && !tokenList && <LoadingIndicator />}
-
-              {!hasLoadingError && tokenList && tokenList.length === 0 && (
-                <EmptyMessage>
-                  {t("You haven't created any authentication tokens yet.")}
-                </EmptyMessage>
-              )}
-
-              {!hasLoadingError &&
-                tokenList &&
-                tokenList.length > 0 &&
-                tokenList.map(token => (
-                  <OrganizationAuthTokensAuthTokenRow
-                    key={token.id}
-                    organization={organization}
-                    token={token}
-                    isRevoking={isRevoking}
-                    revokeToken={hasAccess ? handleRevokeToken : () => {}}
-                    canRevoke={hasAccess}
-                  />
-                ))}
-            </PanelBody>
-          </Panel>
+              ) : undefined
+            }
+            emptyMessage={t("You haven't created any authentication tokens yet.")}
+            headers={[t('Auth token'), t('Last access'), '']}
+          >
+            {!isError && !isLoading && !!tokenList?.length && (
+              <TokenList
+                organization={organization}
+                tokenList={tokenList}
+                isRevoking={isRevoking}
+                revokeToken={hasAccess ? handleRevokeToken : undefined}
+              />
+            )}
+          </ResponsivePanelTable>
         </Fragment>
         </Fragment>
       )}
       )}
     </Access>
     </Access>
@@ -194,3 +206,13 @@ export function tokenPreview(tokenLastCharacters: string) {
 }
 }
 
 
 export default withOrganization(OrganizationAuthTokensIndex);
 export default withOrganization(OrganizationAuthTokensIndex);
+
+const ResponsivePanelTable = styled(PanelTable)`
+  @media (max-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 1fr 1fr;
+
+    > *:nth-child(3n + 2) {
+      display: none;
+    }
+  }
+`;

+ 97 - 0
static/app/views/settings/organizationAuthTokens/newAuthToken.spec.tsx

@@ -0,0 +1,97 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import * as indicators from 'sentry/actionCreators/indicator';
+import OrganizationsStore from 'sentry/stores/organizationsStore';
+import {OrgAuthToken} from 'sentry/types';
+import {OrganizationAuthTokensNewAuthToken} from 'sentry/views/settings/organizationAuthTokens/newAuthToken';
+
+describe('OrganizationAuthTokensNewAuthToken', function () {
+  const ENDPOINT = '/organizations/org-slug/org-auth-tokens/';
+  const {organization, router} = initializeOrg();
+
+  const defaultProps = {
+    organization,
+    router,
+    location: router.location,
+    params: {orgId: organization.slug},
+    routes: router.routes,
+    route: {},
+    routeParams: router.params,
+  };
+
+  beforeEach(function () {
+    OrganizationsStore.addOrReplace(organization);
+  });
+
+  afterEach(function () {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('can create token', async function () {
+    render(<OrganizationAuthTokensNewAuthToken {...defaultProps} />);
+
+    const generatedToken: OrgAuthToken & {token: string} = {
+      id: '1',
+      name: 'My Token',
+      token: 'sntrys_XXXXXXX',
+      tokenLastCharacters: 'XXXX',
+      dateCreated: new Date('2023-01-01T00:00:00.000Z'),
+      scopes: ['org:read'],
+    };
+
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'POST',
+      body: generatedToken,
+    });
+
+    expect(screen.queryByLabelText('Generated token')).not.toBeInTheDocument();
+
+    await userEvent.type(screen.getByLabelText('Name'), 'My Token');
+    await userEvent.click(screen.getByRole('button', {name: 'Create Auth Token'}));
+
+    expect(screen.getByLabelText('Generated token')).toHaveValue('sntrys_XXXXXXX');
+    expect(screen.queryByLabelText('Name')).not.toBeInTheDocument();
+
+    expect(mock).toHaveBeenCalledWith(
+      ENDPOINT,
+      expect.objectContaining({
+        data: {name: 'My Token'},
+      })
+    );
+  });
+
+  it('handles API errors when creating token', async function () {
+    jest.spyOn(indicators, 'addErrorMessage');
+
+    render(<OrganizationAuthTokensNewAuthToken {...defaultProps} />);
+
+    const mock = MockApiClient.addMockResponse({
+      url: ENDPOINT,
+      method: 'POST',
+      body: {
+        details: ['Test API error occurred.'],
+      },
+      statusCode: 400,
+    });
+
+    expect(screen.queryByLabelText('Generated token')).not.toBeInTheDocument();
+
+    await userEvent.type(screen.getByLabelText('Name'), 'My Token');
+    await userEvent.click(screen.getByRole('button', {name: 'Create Auth Token'}));
+
+    expect(screen.queryByLabelText('Generated token')).not.toBeInTheDocument();
+
+    expect(indicators.addErrorMessage).toHaveBeenCalledWith(
+      'Failed to create a new auth token.'
+    );
+
+    expect(mock).toHaveBeenCalledWith(
+      ENDPOINT,
+      expect.objectContaining({
+        data: {name: 'My Token'},
+      })
+    );
+  });
+});

+ 188 - 5
static/app/views/settings/organizationAuthTokens/newAuthToken.tsx

@@ -1,14 +1,169 @@
-import EmptyMessage from 'sentry/components/emptyMessage';
+import {useCallback, useState} from 'react';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import Alert from 'sentry/components/alert';
+import {Button} from 'sentry/components/button';
+import {Form, TextField} from 'sentry/components/forms';
+import FieldGroup from 'sentry/components/forms/fieldGroup';
 import ExternalLink from 'sentry/components/links/externalLink';
 import ExternalLink from 'sentry/components/links/externalLink';
-import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
+import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import TextCopyInput from 'sentry/components/textCopyInput';
 import {t, tct} from 'sentry/locale';
 import {t, tct} from 'sentry/locale';
-import {Organization} from 'sentry/types';
+import {space} from 'sentry/styles/space';
+import {Organization, OrgAuthToken} from 'sentry/types';
+import getDynamicText from 'sentry/utils/getDynamicText';
+import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
+import {useMutation, useQueryClient} from 'sentry/utils/queryClient';
+import RequestError from 'sentry/utils/requestError/requestError';
+import useApi from 'sentry/utils/useApi';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
 import withOrganization from 'sentry/utils/withOrganization';
 import withOrganization from 'sentry/utils/withOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
+import {makeFetchOrgAuthTokensForOrgQueryKey} from 'sentry/views/settings/organizationAuthTokens';
+
+type CreateTokenQueryVariables = {
+  name: string;
+};
+
+type OrgAuthTokenWithToken = OrgAuthToken & {token: string};
+
+type CreateOrgAuthTokensResponse = OrgAuthTokenWithToken;
+
+function AuthTokenCreateForm({
+  organization,
+  onCreatedToken,
+}: {
+  onCreatedToken: (token: OrgAuthTokenWithToken) => void;
+  organization: Organization;
+}) {
+  const initialData = {
+    name: '',
+  };
+
+  const api = useApi();
+  const queryClient = useQueryClient();
+
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/auth-tokens/`));
+  }, [organization.slug]);
+
+  const {mutate: submitToken} = useMutation<
+    CreateOrgAuthTokensResponse,
+    RequestError,
+    CreateTokenQueryVariables
+  >({
+    mutationFn: ({name}) =>
+      api.requestPromise(`/organizations/${organization.slug}/org-auth-tokens/`, {
+        method: 'POST',
+        data: {
+          name,
+        },
+      }),
+
+    onSuccess: (token: OrgAuthTokenWithToken) => {
+      addSuccessMessage(t('Created auth token.'));
+
+      queryClient.invalidateQueries({
+        queryKey: makeFetchOrgAuthTokensForOrgQueryKey({orgSlug: organization.slug}),
+      });
+
+      onCreatedToken(token);
+    },
+    onError: error => {
+      const message = t('Failed to create a new auth token.');
+      handleXhrErrorResponse(message, error);
+      addErrorMessage(message);
+    },
+  });
+
+  return (
+    <Form
+      apiMethod="POST"
+      initialData={initialData}
+      apiEndpoint={`/organizations/${organization.slug}/org-auth-tokens/`}
+      onSubmit={({name}) => {
+        submitToken({
+          name,
+        });
+      }}
+      onCancel={handleGoBack}
+      submitLabel={t('Create Auth Token')}
+      requireChanges
+    >
+      <TextField
+        name="name"
+        label={t('Name')}
+        required
+        help={t('A name to help you identify this token.')}
+      />
+
+      <FieldGroup
+        label={t('Scopes')}
+        help={t('Organization auth tokens currently have a limited set of scopes.')}
+      >
+        <div>
+          <div>org:ci</div>
+          <ScopeHelpText>{t('Source Map Upload, Release Creation')}</ScopeHelpText>
+        </div>
+      </FieldGroup>
+    </Form>
+  );
+}
+
+function ShowNewToken({
+  token,
+  organization,
+}: {
+  organization: Organization;
+  token: OrgAuthTokenWithToken;
+}) {
+  const handleGoBack = useCallback(() => {
+    browserHistory.push(normalizeUrl(`/settings/${organization.slug}/auth-tokens/`));
+  }, [organization.slug]);
+
+  return (
+    <div>
+      <Alert type="warning" showIcon system>
+        {t("Please copy this token to a safe place — it won't be shown again!")}
+      </Alert>
+
+      <PanelItem>
+        <InputWrapper>
+          <FieldGroupNoPadding
+            label={t('Token')}
+            help={t('You can only view this token when it was created.')}
+            inline
+            flexibleControlStateSize
+          >
+            <TextCopyInput aria-label={t('Generated token')}>
+              {getDynamicText({value: token.token, fixed: 'ORG_AUTH_TOKEN'})}
+            </TextCopyInput>
+          </FieldGroupNoPadding>
+        </InputWrapper>
+      </PanelItem>
+
+      <PanelItem>
+        <ButtonWrapper>
+          <Button onClick={handleGoBack} priority="primary">
+            {t('Done')}
+          </Button>
+        </ButtonWrapper>
+      </PanelItem>
+    </div>
+  );
+}
+
+export function OrganizationAuthTokensNewAuthToken({
+  organization,
+}: {
+  organization: Organization;
+}) {
+  const [newToken, setNewToken] = useState<OrgAuthTokenWithToken | null>(null);
 
 
-export function OrganizationAuthTokensNewAuthToken(_props: {organization: Organization}) {
   return (
   return (
     <div>
     <div>
       <SentryDocumentTitle title={t('Create New Auth Token')} />
       <SentryDocumentTitle title={t('Create New Auth Token')} />
@@ -31,7 +186,14 @@ export function OrganizationAuthTokensNewAuthToken(_props: {organization: Organi
         <PanelHeader>{t('Create New Auth Token')}</PanelHeader>
         <PanelHeader>{t('Create New Auth Token')}</PanelHeader>
 
 
         <PanelBody>
         <PanelBody>
-          <EmptyMessage>Coming soon</EmptyMessage>
+          {newToken ? (
+            <ShowNewToken token={newToken} organization={organization} />
+          ) : (
+            <AuthTokenCreateForm
+              organization={organization}
+              onCreatedToken={setNewToken}
+            />
+          )}
         </PanelBody>
         </PanelBody>
       </Panel>
       </Panel>
     </div>
     </div>
@@ -39,3 +201,24 @@ export function OrganizationAuthTokensNewAuthToken(_props: {organization: Organi
 }
 }
 
 
 export default withOrganization(OrganizationAuthTokensNewAuthToken);
 export default withOrganization(OrganizationAuthTokensNewAuthToken);
+
+const InputWrapper = styled('div')`
+  flex: 1;
+`;
+
+const ButtonWrapper = styled('div')`
+  margin-left: auto;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  font-size: ${p => p.theme.fontSizeSmall};
+  gap: ${space(1)};
+`;
+
+const FieldGroupNoPadding = styled(FieldGroup)`
+  padding: 0;
+`;
+
+const ScopeHelpText = styled('div')`
+  color: ${p => p.theme.gray300};
+`;