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

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;
 };
 
+export type OrgAuthToken = {
+  dateCreated: Date;
+  id: string;
+  name: string;
+  scopes: string[];
+  dateLastUsed?: Date;
+  projectLastUsedId?: string;
+  tokenLastCharacters?: string;
+};
+
 // Used in user session history.
 export type InternetProtocol = {
   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 {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
-import Alert from 'sentry/components/alert';
 import {Form, TextField} from 'sentry/components/forms';
 import FieldGroup from 'sentry/components/forms/fieldGroup';
 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 SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 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 {
+  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 withOrganization from 'sentry/utils/withOrganization';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 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 = {
   organization: Organization;
   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({
   token,
   organization,
 }: {
   organization: Organization;
-  token: TokenWip;
+  token: OrgAuthToken;
 }) {
   const initialData = {
     name: token.name,
     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 (
     <Form
       apiMethod="PUT"
       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
         name="name"
         label={t('Name')}
-        value={token.dateLastUsed}
         required
         help={t('A name to help you identify this token.')}
       />
@@ -96,24 +148,13 @@ function AuthTokenDetailsForm({
       <TextField
         name="tokenPreview"
         label={t('Token')}
-        value={tokenPreview(
-          token.tokenLastCharacters
-            ? getDynamicText({
-                value: token.tokenLastCharacters,
-                fixed: 'ABCD',
-              })
-            : '****'
-        )}
         disabled
         help={t('You can only view the token once after creation.')}
       />
 
       <FieldGroup
         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>
       </FieldGroup>
@@ -122,44 +163,25 @@ function AuthTokenDetailsForm({
 }
 
 export function OrganizationAuthTokensDetails({params, organization}: Props) {
-  const [token, setToken] = useState<TokenWip | null>(null);
-  const [hasLoadingError, setHasLoadingError] = useState(false);
-
   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 (
     <div>
       <SentryDocumentTitle 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>
         {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."
@@ -177,16 +199,16 @@ export function OrganizationAuthTokensDetails({params, organization}: Props) {
         <PanelHeader>{t('Auth Token Details')}</PanelHeader>
 
         <PanelBody>
-          {hasLoadingError && (
+          {isError && (
             <LoadingError
               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} />
           )}
         </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 Link from 'sentry/components/links/link';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import {PanelItem} from 'sentry/components/panels';
 import TimeSince from 'sentry/components/timeSince';
 import {Tooltip} from 'sentry/components/tooltip';
 import {IconSubtract} from 'sentry/icons';
 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 {tokenPreview, TokenWip} from 'sentry/views/settings/organizationAuthTokens';
+import {tokenPreview} from 'sentry/views/settings/organizationAuthTokens';
 
 function LastUsed({
   organization,
@@ -27,7 +25,7 @@ function LastUsed({
   if (dateLastUsed && projectLastUsed) {
     return (
       <Fragment>
-        {tct('Last used [date] in [project]', {
+        {tct('[date] in project [project]', {
           date: (
             <TimeSince
               date={getDynamicText({
@@ -49,16 +47,12 @@ function LastUsed({
   if (dateLastUsed) {
     return (
       <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>
     );
   }
@@ -66,7 +60,7 @@ function LastUsed({
   if (projectLastUsed) {
     return (
       <Fragment>
-        {tct('Last used in [project]', {
+        {tct('in project [project]', {
           project: (
             <Link to={`/settings/${organization.slug}/${projectLastUsed.slug}/`}>
               {projectLastUsed.name}
@@ -77,7 +71,7 @@ function LastUsed({
     );
   }
 
-  return <Fragment>{t('Never used')}</Fragment>;
+  return <NeverUsed>{t('never used')}</NeverUsed>;
 }
 
 export function OrganizationAuthTokensAuthTokenRow({
@@ -85,59 +79,25 @@ export function OrganizationAuthTokensAuthTokenRow({
   isRevoking,
   token,
   revokeToken,
-  canRevoke,
+  projectLastUsed,
 }: {
-  canRevoke: boolean;
   isRevoking: boolean;
   organization: Organization;
-  revokeToken: (token: TokenWip) => void;
-  token: TokenWip;
+  token: OrgAuthToken;
+  projectLastUsed?: Project;
+  revokeToken?: (token: OrgAuthToken) => void;
 }) {
   return (
-    <StyledPanelItem>
-      <StyledPanelHeader>
+    <Fragment>
+      <div>
         <Label>
           <Link to={`/settings/${organization.slug}/auth-tokens/${token.id}/`}>
             {token.name}
           </Link>
         </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 && (
-          <TokenPreview>
+          <TokenPreview aria-label={t('Token preview')}>
             {tokenPreview(
               getDynamicText({
                 value: token.tokenLastCharacters,
@@ -146,48 +106,67 @@ export function OrganizationAuthTokensAuthTokenRow({
             )}
           </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 Actions = styled('div')`
-  margin-left: auto;
+  display: flex;
+  justify-content: flex-end;
 `;
 
-const StyledPanelBody = styled('div')`
+const LastUsedDate = styled('div')`
   display: flex;
   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')`
-  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 Access from 'sentry/components/acl/access';
-import Alert from 'sentry/components/alert';
 import {Button} from 'sentry/components/button';
-import EmptyMessage from 'sentry/components/emptyMessage';
 import ExternalLink from 'sentry/components/links/externalLink';
 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 {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 {
+  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 SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 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({
@@ -58,60 +90,56 @@ export function OrganizationAuthTokensIndex({
 }: {
   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 = (
     <Button
       priority="primary"
@@ -123,10 +151,6 @@ export function OrganizationAuthTokensIndex({
     </Button>
   );
 
-  useEffect(() => {
-    fetchTokenList();
-  }, [fetchTokenList]);
-
   return (
     <Access access={['org:write']}>
       {({hasAccess}) => (
@@ -134,8 +158,6 @@ export function OrganizationAuthTokensIndex({
           <SentryDocumentTitle title={t('Auth Tokens')} />
           <SettingsPageHeader title={t('Auth Tokens')} action={createNewToken} />
 
-          <Alert>Note: This page is WIP and currently only shows mocked data.</Alert>
-
           <TextBlock>
             {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."
@@ -149,40 +171,30 @@ export function OrganizationAuthTokensIndex({
               }
             )}
           </TextBlock>
-          <Panel>
-            <PanelHeader>{t('Auth Token')}</PanelHeader>
 
-            <PanelBody>
-              {hasLoadingError && (
+          <ResponsivePanelTable
+            isLoading={isLoading || isError}
+            isEmpty={!isLoading && !tokenList?.length}
+            loader={
+              isError ? (
                 <LoadingError
                   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>
       )}
     </Access>
@@ -194,3 +206,13 @@ export function tokenPreview(tokenLastCharacters: string) {
 }
 
 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 {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
+import {Panel, PanelBody, PanelHeader, PanelItem} from 'sentry/components/panels';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
+import TextCopyInput from 'sentry/components/textCopyInput';
 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 SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 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 (
     <div>
       <SentryDocumentTitle title={t('Create New Auth Token')} />
@@ -31,7 +186,14 @@ export function OrganizationAuthTokensNewAuthToken(_props: {organization: Organi
         <PanelHeader>{t('Create New Auth Token')}</PanelHeader>
 
         <PanelBody>
-          <EmptyMessage>Coming soon</EmptyMessage>
+          {newToken ? (
+            <ShowNewToken token={newToken} organization={organization} />
+          ) : (
+            <AuthTokenCreateForm
+              organization={organization}
+              onCreatedToken={setNewToken}
+            />
+          )}
         </PanelBody>
       </Panel>
     </div>
@@ -39,3 +201,24 @@ export function OrganizationAuthTokensNewAuthToken(_props: {organization: Organi
 }
 
 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};
+`;