Skip to content

Commit e49a813

Browse files
authored
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.
1 parent a843073 commit e49a813

File tree

8 files changed

+1057
-309
lines changed

8 files changed

+1057
-309
lines changed

static/app/types/user.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ export type ApiApplication = {
104104
termsUrl: string | null;
105105
};
106106

107+
export type OrgAuthToken = {
108+
dateCreated: Date;
109+
id: string;
110+
name: string;
111+
scopes: string[];
112+
dateLastUsed?: Date;
113+
projectLastUsedId?: string;
114+
tokenLastCharacters?: string;
115+
};
116+
107117
// Used in user session history.
108118
export type InternetProtocol = {
109119
countryCode: string | null;

static/app/views/settings/organizationAuthTokens/authTokenDetails.tsx

Lines changed: 116 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import {useCallback, useEffect, useState} from 'react';
1+
import {useCallback} from 'react';
22
import {browserHistory} from 'react-router';
33

44
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
5-
import Alert from 'sentry/components/alert';
65
import {Form, TextField} from 'sentry/components/forms';
76
import FieldGroup from 'sentry/components/forms/fieldGroup';
87
import ExternalLink from 'sentry/components/links/externalLink';
@@ -11,109 +10,151 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
1110
import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
1211
import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
1312
import {t, tct} from 'sentry/locale';
14-
import {Organization, Project} from 'sentry/types';
15-
import {setDateToTime} from 'sentry/utils/dates';
16-
import getDynamicText from 'sentry/utils/getDynamicText';
13+
import {Organization, OrgAuthToken} from 'sentry/types';
1714
import {handleXhrErrorResponse} from 'sentry/utils/handleXhrErrorResponse';
15+
import {
16+
setApiQueryData,
17+
useApiQuery,
18+
useMutation,
19+
useQueryClient,
20+
} from 'sentry/utils/queryClient';
21+
import RequestError from 'sentry/utils/requestError/requestError';
22+
import useApi from 'sentry/utils/useApi';
1823
import {normalizeUrl} from 'sentry/utils/withDomainRequired';
1924
import withOrganization from 'sentry/utils/withOrganization';
2025
import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
2126
import TextBlock from 'sentry/views/settings/components/text/textBlock';
22-
import {tokenPreview, TokenWip} from 'sentry/views/settings/organizationAuthTokens';
23-
24-
function generateMockToken({
25-
id,
26-
name,
27-
scopes,
28-
dateCreated = new Date(),
29-
dateLastUsed,
30-
projectLastUsed,
31-
}: {
32-
id: string;
33-
name: string;
34-
scopes: string[];
35-
dateCreated?: Date;
36-
dateLastUsed?: Date;
37-
projectLastUsed?: Project;
38-
}): TokenWip {
39-
return {
40-
id,
41-
name,
42-
tokenLastCharacters: crypto.randomUUID().slice(0, 4),
43-
scopes,
44-
dateCreated,
45-
dateLastUsed,
46-
projectLastUsed,
47-
};
48-
}
27+
import {
28+
makeFetchOrgAuthTokensForOrgQueryKey,
29+
tokenPreview,
30+
} from 'sentry/views/settings/organizationAuthTokens';
4931

5032
type Props = {
5133
organization: Organization;
5234
params: {tokenId: string};
5335
};
5436

37+
type FetchOrgAuthTokenParameters = {
38+
orgSlug: string;
39+
tokenId: string;
40+
};
41+
type FetchOrgAuthTokenResponse = OrgAuthToken;
42+
type UpdateTokenQueryVariables = {
43+
name: string;
44+
};
45+
46+
export const makeFetchOrgAuthTokenKey = ({
47+
orgSlug,
48+
tokenId,
49+
}: FetchOrgAuthTokenParameters) =>
50+
[`/organizations/${orgSlug}/org-auth-tokens/${tokenId}/`] as const;
51+
5552
function AuthTokenDetailsForm({
5653
token,
5754
organization,
5855
}: {
5956
organization: Organization;
60-
token: TokenWip;
57+
token: OrgAuthToken;
6158
}) {
6259
const initialData = {
6360
name: token.name,
6461
tokenPreview: tokenPreview(token.tokenLastCharacters || '****'),
6562
};
6663

64+
const api = useApi();
65+
const queryClient = useQueryClient();
66+
67+
const handleGoBack = useCallback(() => {
68+
browserHistory.push(normalizeUrl(`/settings/${organization.slug}/auth-tokens/`));
69+
}, [organization.slug]);
70+
71+
const {mutate: submitToken} = useMutation<{}, RequestError, UpdateTokenQueryVariables>({
72+
mutationFn: ({name}) =>
73+
api.requestPromise(
74+
`/organizations/${organization.slug}/org-auth-tokens/${token.id}/`,
75+
{
76+
method: 'PUT',
77+
data: {
78+
name,
79+
},
80+
}
81+
),
82+
83+
onSuccess: (_data, {name}) => {
84+
addSuccessMessage(t('Updated auth token.'));
85+
86+
// Update get by id query
87+
setApiQueryData(
88+
queryClient,
89+
makeFetchOrgAuthTokenKey({orgSlug: organization.slug, tokenId: token.id}),
90+
(oldData: OrgAuthToken | undefined) => {
91+
if (!oldData) {
92+
return oldData;
93+
}
94+
95+
oldData.name = name;
96+
97+
return oldData;
98+
}
99+
);
100+
101+
// Update get list query
102+
setApiQueryData(
103+
queryClient,
104+
makeFetchOrgAuthTokensForOrgQueryKey({orgSlug: organization.slug}),
105+
(oldData: OrgAuthToken[] | undefined) => {
106+
if (!Array.isArray(oldData)) {
107+
return oldData;
108+
}
109+
110+
const existingToken = oldData.find(oldToken => oldToken.id === token.id);
111+
112+
if (existingToken) {
113+
existingToken.name = name;
114+
}
115+
116+
return oldData;
117+
}
118+
);
119+
120+
handleGoBack();
121+
},
122+
onError: error => {
123+
const message = t('Failed to update the auth token.');
124+
handleXhrErrorResponse(message, error);
125+
addErrorMessage(message);
126+
},
127+
});
128+
67129
return (
68130
<Form
69131
apiMethod="PUT"
70132
initialData={initialData}
71-
apiEndpoint={`/organizations/${organization.slug}/auth-tokens/${token.id}/`}
72-
onSubmit={() => {
73-
// TODO FN: Actually submit data
74-
75-
try {
76-
const message = t('Successfully updated the auth token.');
77-
addSuccessMessage(message);
78-
} catch (error) {
79-
const message = t('Failed to update the auth token.');
80-
handleXhrErrorResponse(message, error);
81-
addErrorMessage(message);
82-
}
133+
apiEndpoint={`/organizations/${organization.slug}/org-auth-tokens/${token.id}/`}
134+
onSubmit={({name}) => {
135+
submitToken({
136+
name,
137+
});
83138
}}
84-
onCancel={() =>
85-
browserHistory.push(normalizeUrl(`/settings/${organization.slug}/auth-tokens/`))
86-
}
139+
onCancel={handleGoBack}
87140
>
88141
<TextField
89142
name="name"
90143
label={t('Name')}
91-
value={token.dateLastUsed}
92144
required
93145
help={t('A name to help you identify this token.')}
94146
/>
95147

96148
<TextField
97149
name="tokenPreview"
98150
label={t('Token')}
99-
value={tokenPreview(
100-
token.tokenLastCharacters
101-
? getDynamicText({
102-
value: token.tokenLastCharacters,
103-
fixed: 'ABCD',
104-
})
105-
: '****'
106-
)}
107151
disabled
108152
help={t('You can only view the token once after creation.')}
109153
/>
110154

111155
<FieldGroup
112156
label={t('Scopes')}
113-
inline={false}
114-
help={t(
115-
'You cannot change the scopes of an existing token. If you need different scopes, please create a new token.'
116-
)}
157+
help={t('You cannot change the scopes of an existing token.')}
117158
>
118159
<div>{token.scopes.slice().sort().join(', ')}</div>
119160
</FieldGroup>
@@ -122,44 +163,25 @@ function AuthTokenDetailsForm({
122163
}
123164

124165
export function OrganizationAuthTokensDetails({params, organization}: Props) {
125-
const [token, setToken] = useState<TokenWip | null>(null);
126-
const [hasLoadingError, setHasLoadingError] = useState(false);
127-
128166
const {tokenId} = params;
129167

130-
const fetchToken = useCallback(async () => {
131-
try {
132-
// TODO FN: Actually do something here
133-
await new Promise(resolve => setTimeout(resolve, 500));
134-
setToken(
135-
generateMockToken({
136-
id: tokenId,
137-
name: 'custom token',
138-
scopes: ['org:ci'],
139-
dateLastUsed: setDateToTime(new Date(), '00:05:00'),
140-
projectLastUsed: {slug: 'my-project', name: 'My Project'} as Project,
141-
dateCreated: setDateToTime(new Date(), '00:01:00'),
142-
})
143-
);
144-
setHasLoadingError(false);
145-
} catch (error) {
146-
const message = t('Failed to load auth token.');
147-
handleXhrErrorResponse(message, error);
148-
setHasLoadingError(error);
168+
const {
169+
isLoading,
170+
isError,
171+
data: token,
172+
refetch: refetchToken,
173+
} = useApiQuery<FetchOrgAuthTokenResponse>(
174+
makeFetchOrgAuthTokenKey({orgSlug: organization.slug, tokenId}),
175+
{
176+
staleTime: Infinity,
149177
}
150-
}, [tokenId]);
151-
152-
useEffect(() => {
153-
fetchToken();
154-
}, [fetchToken]);
178+
);
155179

156180
return (
157181
<div>
158182
<SentryDocumentTitle title={t('Edit Auth Token')} />
159183
<SettingsPageHeader title={t('Edit Auth Token')} />
160184

161-
<Alert>Note: This page is WIP and currently only shows mocked data.</Alert>
162-
163185
<TextBlock>
164186
{t(
165187
"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) {
177199
<PanelHeader>{t('Auth Token Details')}</PanelHeader>
178200

179201
<PanelBody>
180-
{hasLoadingError && (
202+
{isError && (
181203
<LoadingError
182204
message={t('Failed to load auth token.')}
183-
onRetry={fetchToken}
205+
onRetry={refetchToken}
184206
/>
185207
)}
186208

187-
{!hasLoadingError && !token && <LoadingIndicator />}
209+
{isLoading && <LoadingIndicator />}
188210

189-
{!hasLoadingError && token && (
211+
{!isLoading && !isError && token && (
190212
<AuthTokenDetailsForm token={token} organization={organization} />
191213
)}
192214
</PanelBody>

0 commit comments

Comments
 (0)