@spinnaker/core 2026.1.1 → 2026.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apitoken/ApiTokenService.d.ts +34 -0
- package/dist/apitoken/ApiTokensPage.d.ts +15 -0
- package/dist/apitoken/ApiTokensPageContainer.d.ts +2 -0
- package/dist/apitoken/CreateApiTokenModal.d.ts +20 -0
- package/dist/apitoken/RevokeApiTokenButton.d.ts +7 -0
- package/dist/apitoken/apitoken.module.d.ts +1 -0
- package/dist/apitoken/apitoken.states.d.ts +1 -0
- package/dist/apitoken/index.d.ts +6 -0
- package/dist/application/application.initializers.d.ts +7 -0
- package/dist/application/index.d.ts +2 -0
- package/dist/authentication/AuthenticationService.d.ts +1 -0
- package/dist/config/settings.d.ts +0 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.js +36 -36
- package/dist/index.js.map +1 -1
- package/dist/notification/selector/types/index.d.ts +0 -1
- package/package.json +12 -7
- package/rollup.config.js +16 -0
- package/src/apitoken/ApiTokenService.spec.ts +199 -0
- package/src/apitoken/ApiTokenService.ts +65 -0
- package/src/apitoken/ApiTokensPage.less +81 -0
- package/src/apitoken/ApiTokensPage.tsx +370 -0
- package/src/apitoken/ApiTokensPageContainer.tsx +85 -0
- package/src/apitoken/CreateApiTokenModal.tsx +228 -0
- package/src/apitoken/RevokeApiTokenButton.tsx +79 -0
- package/src/apitoken/apitoken.module.ts +20 -0
- package/src/apitoken/apitoken.states.ts +43 -0
- package/src/apitoken/index.ts +20 -0
- package/src/application/application.initializers.spec.ts +34 -0
- package/src/application/application.initializers.ts +40 -0
- package/src/application/application.module.ts +2 -0
- package/src/application/index.ts +6 -0
- package/src/authentication/AuthenticationInitializer.spec.ts +16 -0
- package/src/authentication/AuthenticationInitializer.ts +4 -0
- package/src/authentication/AuthenticationService.spec.ts +18 -0
- package/src/authentication/AuthenticationService.ts +3 -0
- package/src/authentication/userMenu/UserMenu.tsx +17 -3
- package/src/config/settings.ts +0 -1
- package/src/core.module.ts +2 -0
- package/src/index.ts +1 -0
- package/src/notification/notification.types.ts +0 -2
- package/src/notification/selector/types/index.ts +0 -1
- package/src/notification/selector/types/microsoftteams/MicrosoftTeamsNotificationType.tsx +1 -1
- package/dist/notification/selector/types/bearychat/BearychatNotificationType.d.ts +0 -5
- package/dist/notification/selector/types/bearychat/beary.notification.d.ts +0 -2
- package/src/notification/selector/types/bearychat/BearychatNotificationType.tsx +0 -19
- package/src/notification/selector/types/bearychat/beary.notification.ts +0 -8
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Copyright 2026 DoorDash, Inc.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
import { Modal } from 'react-bootstrap';
|
|
17
|
+
|
|
18
|
+
import type { IApiToken, IApiTokenServiceAccount } from './ApiTokenService';
|
|
19
|
+
import { ApiTokenService } from './ApiTokenService';
|
|
20
|
+
import { CreateApiTokenModal } from './CreateApiTokenModal';
|
|
21
|
+
import { RevokeApiTokenButton } from './RevokeApiTokenButton';
|
|
22
|
+
|
|
23
|
+
import './ApiTokensPage.less';
|
|
24
|
+
|
|
25
|
+
export interface IApiTokensPageProps {
|
|
26
|
+
isAdmin: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the user may mint personal tokens (from GET /auth/user → canMintApiTokens).
|
|
29
|
+
* When false, the "New Token" button is hidden; SA-token creation is gated on admin status.
|
|
30
|
+
*/
|
|
31
|
+
canMintApiTokens: boolean;
|
|
32
|
+
/** From GET /auth/user. Always pass the server value — never hard-code a fallback. */
|
|
33
|
+
maxUserTokenLifetimeDays: number;
|
|
34
|
+
/** From GET /auth/user. Always pass the server value — never hard-code a fallback. */
|
|
35
|
+
maxServiceAccountTokenLifetimeDays: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface INewTokenDisplay {
|
|
39
|
+
token: string;
|
|
40
|
+
name: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ApiTokensPage({
|
|
44
|
+
isAdmin,
|
|
45
|
+
canMintApiTokens,
|
|
46
|
+
maxUserTokenLifetimeDays,
|
|
47
|
+
maxServiceAccountTokenLifetimeDays,
|
|
48
|
+
}: IApiTokensPageProps) {
|
|
49
|
+
const [tokens, setTokens] = useState<IApiToken[]>([]);
|
|
50
|
+
const [loading, setLoading] = useState(true);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [showCreate, setShowCreate] = useState<'user' | 'serviceAccount' | null>(null);
|
|
53
|
+
const [newTokenDisplay, setNewTokenDisplay] = useState<INewTokenDisplay | null>(null);
|
|
54
|
+
const [copied, setCopied] = useState(false);
|
|
55
|
+
const [serviceAccounts, setServiceAccounts] = useState<IApiTokenServiceAccount[]>([]);
|
|
56
|
+
const [allUserTokens, setAllUserTokens] = useState<IApiToken[]>([]);
|
|
57
|
+
const [allUserTokensLoading, setAllUserTokensLoading] = useState(false);
|
|
58
|
+
|
|
59
|
+
const loadTokens = useCallback(() => {
|
|
60
|
+
setLoading(true);
|
|
61
|
+
ApiTokenService.listTokens()
|
|
62
|
+
.then((data) => {
|
|
63
|
+
setTokens(data);
|
|
64
|
+
setLoading(false);
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
setError('Failed to load tokens');
|
|
68
|
+
setLoading(false);
|
|
69
|
+
});
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const loadAllUserTokens = useCallback(() => {
|
|
73
|
+
if (!isAdmin) return;
|
|
74
|
+
setAllUserTokensLoading(true);
|
|
75
|
+
ApiTokenService.listAllUserTokens()
|
|
76
|
+
.then((data) => {
|
|
77
|
+
setAllUserTokens(data);
|
|
78
|
+
setAllUserTokensLoading(false);
|
|
79
|
+
})
|
|
80
|
+
.catch(() => setAllUserTokensLoading(false));
|
|
81
|
+
}, [isAdmin]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
loadTokens();
|
|
85
|
+
}, [loadTokens]);
|
|
86
|
+
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (isAdmin) {
|
|
89
|
+
ApiTokenService.listApiTokenServiceAccounts()
|
|
90
|
+
.then(setServiceAccounts)
|
|
91
|
+
.catch(() => setServiceAccounts([]));
|
|
92
|
+
loadAllUserTokens();
|
|
93
|
+
}
|
|
94
|
+
}, [isAdmin, loadAllUserTokens]);
|
|
95
|
+
|
|
96
|
+
const handleCreated = (token: IApiToken & { token: string }) => {
|
|
97
|
+
setShowCreate(null);
|
|
98
|
+
setNewTokenDisplay({ token: token.token, name: token.name });
|
|
99
|
+
loadTokens();
|
|
100
|
+
if (isAdmin) loadAllUserTokens();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleCopy = () => {
|
|
104
|
+
if (newTokenDisplay) {
|
|
105
|
+
navigator.clipboard.writeText(newTokenDisplay.token).then(() => {
|
|
106
|
+
setCopied(true);
|
|
107
|
+
setTimeout(() => setCopied(false), 2000);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const userTokens = tokens.filter((t) => t.principalType === 'USER');
|
|
113
|
+
const saTokens = tokens.filter((t) => t.principalType === 'SERVICE_ACCOUNT');
|
|
114
|
+
|
|
115
|
+
const formatExpiry = (expiresAt: string | null | undefined) => {
|
|
116
|
+
if (!expiresAt) {
|
|
117
|
+
return <span className="text-muted">Never</span>;
|
|
118
|
+
}
|
|
119
|
+
const date = new Date(expiresAt);
|
|
120
|
+
const expired = date < new Date();
|
|
121
|
+
return (
|
|
122
|
+
<span className={expired ? 'text-danger' : undefined}>
|
|
123
|
+
{date.toLocaleDateString()}
|
|
124
|
+
{expired ? ' (expired)' : ''}
|
|
125
|
+
</span>
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const renderEmptyState = (context: 'user' | 'serviceAccount') => {
|
|
130
|
+
const userBlurb = canMintApiTokens
|
|
131
|
+
? 'Create a personal API token to authenticate programmatic requests as yourself.'
|
|
132
|
+
: 'You do not have permission to create personal API tokens. Contact a Spinnaker administrator if you need access.';
|
|
133
|
+
return (
|
|
134
|
+
<div className="text-center ApiTokensPage-empty">
|
|
135
|
+
<span className="glyphicon glyphicon-lock ApiTokensPage-empty-icon" />
|
|
136
|
+
<p className="ApiTokensPage-empty-title">No tokens yet</p>
|
|
137
|
+
<p className="text-muted ApiTokensPage-empty-body">
|
|
138
|
+
{context === 'user'
|
|
139
|
+
? userBlurb
|
|
140
|
+
: 'Create a service account token to allow automation to call the Spinnaker API.'}
|
|
141
|
+
</p>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const renderTokenTable = (rows: IApiToken[], context: 'user' | 'serviceAccount', onRevoked?: () => void) => {
|
|
147
|
+
if (rows.length === 0) {
|
|
148
|
+
return renderEmptyState(context);
|
|
149
|
+
}
|
|
150
|
+
const handleRevoked = onRevoked ?? loadTokens;
|
|
151
|
+
return (
|
|
152
|
+
<div className="table-responsive">
|
|
153
|
+
<table className="table table-condensed table-bordered">
|
|
154
|
+
<thead>
|
|
155
|
+
<tr>
|
|
156
|
+
<th>Name</th>
|
|
157
|
+
{context === 'serviceAccount' && <th>Service Account</th>}
|
|
158
|
+
<th>Created By</th>
|
|
159
|
+
<th>Expires</th>
|
|
160
|
+
<th>Last Used</th>
|
|
161
|
+
<th className="ApiTokensPage-actions-column" />
|
|
162
|
+
</tr>
|
|
163
|
+
</thead>
|
|
164
|
+
<tbody>
|
|
165
|
+
{rows.map((t) => (
|
|
166
|
+
<tr key={t.id}>
|
|
167
|
+
<td>{t.name}</td>
|
|
168
|
+
{context === 'serviceAccount' && (
|
|
169
|
+
<td>
|
|
170
|
+
<code>{t.principalId}</code>
|
|
171
|
+
</td>
|
|
172
|
+
)}
|
|
173
|
+
<td>{t.createdByUserId}</td>
|
|
174
|
+
<td>{formatExpiry(t.expiresAt)}</td>
|
|
175
|
+
<td>
|
|
176
|
+
{t.lastUsedAt ? (
|
|
177
|
+
new Date(t.lastUsedAt).toLocaleDateString()
|
|
178
|
+
) : (
|
|
179
|
+
<span className="text-muted">never</span>
|
|
180
|
+
)}
|
|
181
|
+
</td>
|
|
182
|
+
<td className="ApiTokensPage-actions-column">
|
|
183
|
+
<RevokeApiTokenButton tokenId={t.id} tokenName={t.name} onRevoked={handleRevoked} />
|
|
184
|
+
</td>
|
|
185
|
+
</tr>
|
|
186
|
+
))}
|
|
187
|
+
</tbody>
|
|
188
|
+
</table>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="container ApiTokensPage">
|
|
195
|
+
{/* One-time token display modal */}
|
|
196
|
+
{newTokenDisplay && (
|
|
197
|
+
<Modal show onHide={() => setNewTokenDisplay(null)}>
|
|
198
|
+
<Modal.Header closeButton>
|
|
199
|
+
<Modal.Title>Token Created: {newTokenDisplay.name}</Modal.Title>
|
|
200
|
+
</Modal.Header>
|
|
201
|
+
<Modal.Body>
|
|
202
|
+
<div className="alert alert-warning">
|
|
203
|
+
<strong>Copy this token now.</strong> It will not be shown again.
|
|
204
|
+
</div>
|
|
205
|
+
<div className="input-group">
|
|
206
|
+
<input type="text" className="form-control" readOnly value={newTokenDisplay.token} />
|
|
207
|
+
<span className="input-group-btn">
|
|
208
|
+
<button className="btn btn-default" onClick={handleCopy}>
|
|
209
|
+
{copied ? '✓ Copied' : 'Copy'}
|
|
210
|
+
</button>
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
</Modal.Body>
|
|
214
|
+
<Modal.Footer>
|
|
215
|
+
<button className="btn btn-primary" onClick={() => setNewTokenDisplay(null)}>
|
|
216
|
+
Done
|
|
217
|
+
</button>
|
|
218
|
+
</Modal.Footer>
|
|
219
|
+
</Modal>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* Create modals */}
|
|
223
|
+
{showCreate && (
|
|
224
|
+
<CreateApiTokenModal
|
|
225
|
+
context={showCreate}
|
|
226
|
+
maxLifetimeDays={
|
|
227
|
+
showCreate === 'serviceAccount' ? maxServiceAccountTokenLifetimeDays : maxUserTokenLifetimeDays
|
|
228
|
+
}
|
|
229
|
+
serviceAccounts={showCreate === 'serviceAccount' ? serviceAccounts : undefined}
|
|
230
|
+
existingTokenNames={showCreate === 'user' ? new Set(userTokens.map((t) => t.name)) : undefined}
|
|
231
|
+
onClose={() => setShowCreate(null)}
|
|
232
|
+
onCreated={handleCreated}
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* Global load/error states */}
|
|
237
|
+
{loading && (
|
|
238
|
+
<div className="text-center ApiTokensPage-status">
|
|
239
|
+
<span className="fa fa-spinner fa-spin fa-2x text-muted" />
|
|
240
|
+
<p className="text-muted ApiTokensPage-status-subtitle">Loading tokens…</p>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{!loading && error && (
|
|
245
|
+
<div className="text-center ApiTokensPage-status">
|
|
246
|
+
<span className="glyphicon glyphicon-exclamation-sign text-danger ApiTokensPage-status-icon" />
|
|
247
|
+
<p className="ApiTokensPage-status-title">Failed to load tokens</p>
|
|
248
|
+
<p className="text-muted">There was a problem communicating with the API. Please try again.</p>
|
|
249
|
+
<button className="btn btn-default" onClick={loadTokens}>
|
|
250
|
+
Retry
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{!loading && !error && (
|
|
256
|
+
<>
|
|
257
|
+
<div className="content-section ApiTokensPage-section">
|
|
258
|
+
<div className="flex-container-h baseline ApiTokensPage-section-header">
|
|
259
|
+
<h3>My Tokens</h3>
|
|
260
|
+
{canMintApiTokens && (
|
|
261
|
+
<button className="btn btn-primary btn-sm" onClick={() => setShowCreate('user')}>
|
|
262
|
+
<span className="glyphicon glyphicon-plus" /> New Token
|
|
263
|
+
</button>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
{renderTokenTable(userTokens, 'user')}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{isAdmin && (
|
|
270
|
+
<>
|
|
271
|
+
<div className="content-section ApiTokensPage-section">
|
|
272
|
+
<div className="flex-container-h baseline ApiTokensPage-section-header">
|
|
273
|
+
<h3>Service Account Tokens</h3>
|
|
274
|
+
<span
|
|
275
|
+
title={
|
|
276
|
+
serviceAccounts.length === 0
|
|
277
|
+
? 'No service accounts are configured. Add entries under service-accounts in front50.yml.'
|
|
278
|
+
: undefined
|
|
279
|
+
}
|
|
280
|
+
>
|
|
281
|
+
<button
|
|
282
|
+
className="btn btn-primary btn-sm"
|
|
283
|
+
onClick={() => setShowCreate('serviceAccount')}
|
|
284
|
+
disabled={serviceAccounts.length === 0}
|
|
285
|
+
>
|
|
286
|
+
<span className="glyphicon glyphicon-plus" /> New SA Token
|
|
287
|
+
</button>
|
|
288
|
+
</span>
|
|
289
|
+
</div>
|
|
290
|
+
{serviceAccounts.length === 0 ? (
|
|
291
|
+
<div className="text-center ApiTokensPage-empty">
|
|
292
|
+
<span className="glyphicon glyphicon-cog ApiTokensPage-empty-icon" />
|
|
293
|
+
<p className="ApiTokensPage-empty-title">No service accounts configured</p>
|
|
294
|
+
<p className="text-muted ApiTokensPage-empty-body">
|
|
295
|
+
Add entries under <code>service-accounts</code> in <code>front50.yml</code> to enable service
|
|
296
|
+
account tokens.
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
) : (
|
|
300
|
+
renderTokenTable(saTokens, 'serviceAccount', () => {
|
|
301
|
+
loadTokens();
|
|
302
|
+
loadAllUserTokens();
|
|
303
|
+
})
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div className="content-section ApiTokensPage-section">
|
|
308
|
+
<div className="flex-container-h baseline ApiTokensPage-section-header">
|
|
309
|
+
<h3>All User Tokens</h3>
|
|
310
|
+
<span className="label label-default ApiTokensPage-admin-badge">Admin view</span>
|
|
311
|
+
</div>
|
|
312
|
+
{allUserTokensLoading ? (
|
|
313
|
+
<div className="text-center ApiTokensPage-inline-loader">
|
|
314
|
+
<span className="fa fa-spinner fa-spin text-muted" />
|
|
315
|
+
</div>
|
|
316
|
+
) : allUserTokens.length === 0 ? (
|
|
317
|
+
<div className="text-center ApiTokensPage-empty">
|
|
318
|
+
<span className="glyphicon glyphicon-user ApiTokensPage-empty-icon" />
|
|
319
|
+
<p className="ApiTokensPage-empty-title">No user tokens</p>
|
|
320
|
+
<p className="text-muted ApiTokensPage-empty-body">No users have created API tokens yet.</p>
|
|
321
|
+
</div>
|
|
322
|
+
) : (
|
|
323
|
+
<div className="table-responsive">
|
|
324
|
+
<table className="table table-condensed table-bordered">
|
|
325
|
+
<thead>
|
|
326
|
+
<tr>
|
|
327
|
+
<th>Name</th>
|
|
328
|
+
<th>User</th>
|
|
329
|
+
<th>Expires</th>
|
|
330
|
+
<th>Last Used</th>
|
|
331
|
+
<th className="ApiTokensPage-actions-column" />
|
|
332
|
+
</tr>
|
|
333
|
+
</thead>
|
|
334
|
+
<tbody>
|
|
335
|
+
{allUserTokens.map((t) => (
|
|
336
|
+
<tr key={t.id}>
|
|
337
|
+
<td>{t.name}</td>
|
|
338
|
+
<td>{t.principalId}</td>
|
|
339
|
+
<td>{formatExpiry(t.expiresAt)}</td>
|
|
340
|
+
<td>
|
|
341
|
+
{t.lastUsedAt ? (
|
|
342
|
+
new Date(t.lastUsedAt).toLocaleDateString()
|
|
343
|
+
) : (
|
|
344
|
+
<span className="text-muted">never</span>
|
|
345
|
+
)}
|
|
346
|
+
</td>
|
|
347
|
+
<td className="ApiTokensPage-actions-column">
|
|
348
|
+
<RevokeApiTokenButton
|
|
349
|
+
tokenId={t.id}
|
|
350
|
+
tokenName={t.name}
|
|
351
|
+
onRevoked={() => {
|
|
352
|
+
loadTokens();
|
|
353
|
+
loadAllUserTokens();
|
|
354
|
+
}}
|
|
355
|
+
/>
|
|
356
|
+
</td>
|
|
357
|
+
</tr>
|
|
358
|
+
))}
|
|
359
|
+
</tbody>
|
|
360
|
+
</table>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
</>
|
|
365
|
+
)}
|
|
366
|
+
</>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Copyright 2026 DoorDash, Inc.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import React, { useEffect, useState } from 'react';
|
|
16
|
+
|
|
17
|
+
import { ApiTokensPage } from './ApiTokensPage';
|
|
18
|
+
import { REST } from '../api/ApiService';
|
|
19
|
+
|
|
20
|
+
interface IAuthUserResponse {
|
|
21
|
+
isAdmin?: boolean;
|
|
22
|
+
canMintApiTokens?: boolean;
|
|
23
|
+
maxUserTokenLifetimeDays?: number;
|
|
24
|
+
maxServiceAccountTokenLifetimeDays?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ApiTokensPageContainer = () => {
|
|
28
|
+
const [authUser, setAuthUser] = useState<IAuthUserResponse | null>(null);
|
|
29
|
+
const [loading, setLoading] = useState(true);
|
|
30
|
+
const [error, setError] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
REST('/auth/user')
|
|
34
|
+
.get<IAuthUserResponse>()
|
|
35
|
+
.then((user: IAuthUserResponse) => {
|
|
36
|
+
setAuthUser(user);
|
|
37
|
+
setLoading(false);
|
|
38
|
+
})
|
|
39
|
+
.catch(() => {
|
|
40
|
+
setError(true);
|
|
41
|
+
setLoading(false);
|
|
42
|
+
});
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
if (loading) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="container">
|
|
48
|
+
<h3 className="text-center">
|
|
49
|
+
<span className="glyphicon glyphicon-asterisk glyphicon-spinning" /> Loading…
|
|
50
|
+
</h3>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (error || !authUser) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="container">
|
|
58
|
+
<div className="alert alert-danger">Failed to load user information. Please refresh and try again.</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const isAdmin = authUser.isAdmin ?? false;
|
|
64
|
+
const canMintApiTokens = authUser.canMintApiTokens ?? isAdmin;
|
|
65
|
+
|
|
66
|
+
// Users who are not in an allowed minting group (and are not admins) may not access this page.
|
|
67
|
+
if (!canMintApiTokens && !isAdmin) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="container">
|
|
70
|
+
<div className="alert alert-warning">
|
|
71
|
+
You do not have permission to manage API tokens. Contact a Spinnaker administrator if you need access.
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<ApiTokensPage
|
|
79
|
+
isAdmin={isAdmin}
|
|
80
|
+
canMintApiTokens={canMintApiTokens}
|
|
81
|
+
maxUserTokenLifetimeDays={authUser.maxUserTokenLifetimeDays ?? 0}
|
|
82
|
+
maxServiceAccountTokenLifetimeDays={authUser.maxServiceAccountTokenLifetimeDays ?? 0}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Copyright 2026 DoorDash, Inc.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import React, { useEffect, useState } from 'react';
|
|
16
|
+
import { Modal } from 'react-bootstrap';
|
|
17
|
+
|
|
18
|
+
import type { IApiToken, IApiTokenServiceAccount, ICreateApiTokenRequest } from './ApiTokenService';
|
|
19
|
+
import { ApiTokenService } from './ApiTokenService';
|
|
20
|
+
|
|
21
|
+
export type TokenContext = 'user' | 'serviceAccount';
|
|
22
|
+
|
|
23
|
+
const MAX_TOKEN_NAME_LENGTH = 64;
|
|
24
|
+
|
|
25
|
+
export interface ICreateApiTokenModalProps {
|
|
26
|
+
context: TokenContext;
|
|
27
|
+
/**
|
|
28
|
+
* Max lifetime in days for this token type (from GET /auth/user). Caps the date picker.
|
|
29
|
+
* Always pass the server value — never hard-code a fallback.
|
|
30
|
+
*/
|
|
31
|
+
maxLifetimeDays: number;
|
|
32
|
+
/** Pre-loaded service accounts; avoids a redundant fetch from the modal. */
|
|
33
|
+
serviceAccounts?: IApiTokenServiceAccount[];
|
|
34
|
+
/** Names already owned by the current principal — for client-side duplicate detection. */
|
|
35
|
+
existingTokenNames?: Set<string>;
|
|
36
|
+
onClose: () => void;
|
|
37
|
+
onCreated: (token: IApiToken & { token: string }) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function CreateApiTokenModal({
|
|
41
|
+
context,
|
|
42
|
+
maxLifetimeDays,
|
|
43
|
+
serviceAccounts: serviceAccountsProp,
|
|
44
|
+
existingTokenNames,
|
|
45
|
+
onClose,
|
|
46
|
+
onCreated,
|
|
47
|
+
}: ICreateApiTokenModalProps) {
|
|
48
|
+
const [name, setName] = useState('');
|
|
49
|
+
const [useForever, setUseForever] = useState(true);
|
|
50
|
+
const [customExpiry, setCustomExpiry] = useState('');
|
|
51
|
+
const [selectedSA, setSelectedSA] = useState('');
|
|
52
|
+
const [serviceAccounts, setServiceAccounts] = useState<IApiTokenServiceAccount[]>(serviceAccountsProp ?? []);
|
|
53
|
+
const [creating, setCreating] = useState(false);
|
|
54
|
+
const [error, setError] = useState<string | null>(null);
|
|
55
|
+
|
|
56
|
+
const todayStr = new Date().toISOString().slice(0, 10);
|
|
57
|
+
const maxDate = new Date();
|
|
58
|
+
maxDate.setDate(maxDate.getDate() + maxLifetimeDays);
|
|
59
|
+
const maxDateStr = maxDate.toISOString().slice(0, 10);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (context === 'serviceAccount' && !serviceAccountsProp) {
|
|
63
|
+
ApiTokenService.listApiTokenServiceAccounts()
|
|
64
|
+
.then(setServiceAccounts)
|
|
65
|
+
.catch(() => setError('Failed to load service accounts'));
|
|
66
|
+
}
|
|
67
|
+
}, [context, serviceAccountsProp]);
|
|
68
|
+
|
|
69
|
+
const handleToggleForever = (forever: boolean) => {
|
|
70
|
+
setUseForever(forever);
|
|
71
|
+
if (forever) {
|
|
72
|
+
setCustomExpiry('');
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
setError(null);
|
|
79
|
+
setCreating(true);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const request: ICreateApiTokenRequest = {
|
|
83
|
+
name,
|
|
84
|
+
principalType: context === 'serviceAccount' ? 'SERVICE_ACCOUNT' : 'USER',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (context === 'serviceAccount') {
|
|
88
|
+
if (!selectedSA) {
|
|
89
|
+
setError('Please select a service account');
|
|
90
|
+
setCreating(false);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
request.principalId = selectedSA;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Omitting expiresAt: Gate applies the configured max lifetime.
|
|
97
|
+
if (!useForever && customExpiry) {
|
|
98
|
+
request.expiresAt = new Date(customExpiry).toISOString();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const created = await ApiTokenService.createToken(request);
|
|
102
|
+
onCreated(created);
|
|
103
|
+
} catch (e: any) {
|
|
104
|
+
setError(e?.data?.message ?? 'Failed to create token');
|
|
105
|
+
setCreating(false);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const nameTrimmed = name.trim();
|
|
110
|
+
const nameAlreadyTaken = !!existingTokenNames && nameTrimmed.length > 0 && existingTokenNames.has(nameTrimmed);
|
|
111
|
+
const nameTooLong = nameTrimmed.length > MAX_TOKEN_NAME_LENGTH;
|
|
112
|
+
const isSubmittable = nameTrimmed && !nameAlreadyTaken && !nameTooLong && (useForever || !!customExpiry);
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Modal show onHide={onClose}>
|
|
116
|
+
<Modal.Header closeButton>
|
|
117
|
+
<Modal.Title>{context === 'serviceAccount' ? 'Create Service Account Token' : 'Create API Token'}</Modal.Title>
|
|
118
|
+
</Modal.Header>
|
|
119
|
+
<form onSubmit={handleSubmit}>
|
|
120
|
+
<Modal.Body>
|
|
121
|
+
{error && (
|
|
122
|
+
<div className="alert alert-danger" role="alert">
|
|
123
|
+
{error}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<div className={`form-group${nameAlreadyTaken || nameTooLong ? ' has-error' : ''}`}>
|
|
128
|
+
<label htmlFor="token-name">Name</label>
|
|
129
|
+
<input
|
|
130
|
+
id="token-name"
|
|
131
|
+
className="form-control"
|
|
132
|
+
type="text"
|
|
133
|
+
placeholder="e.g. my-ci-pipeline"
|
|
134
|
+
value={name}
|
|
135
|
+
onChange={(e) => setName(e.target.value.slice(0, MAX_TOKEN_NAME_LENGTH))}
|
|
136
|
+
maxLength={MAX_TOKEN_NAME_LENGTH}
|
|
137
|
+
required
|
|
138
|
+
autoFocus
|
|
139
|
+
/>
|
|
140
|
+
{nameAlreadyTaken && (
|
|
141
|
+
<span className="help-block">
|
|
142
|
+
You already have a token named “{nameTrimmed}”. Choose a different name.
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
{!nameAlreadyTaken && (
|
|
146
|
+
<span className="help-block text-muted">
|
|
147
|
+
{nameTrimmed.length}/{MAX_TOKEN_NAME_LENGTH} characters
|
|
148
|
+
</span>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{context === 'serviceAccount' && (
|
|
153
|
+
<div className="form-group">
|
|
154
|
+
<label htmlFor="token-sa">Service Account</label>
|
|
155
|
+
<select
|
|
156
|
+
id="token-sa"
|
|
157
|
+
className="form-control"
|
|
158
|
+
value={selectedSA}
|
|
159
|
+
onChange={(e) => setSelectedSA(e.target.value)}
|
|
160
|
+
required
|
|
161
|
+
>
|
|
162
|
+
<option value="">— select a service account —</option>
|
|
163
|
+
{serviceAccounts.map((sa) => (
|
|
164
|
+
<option key={sa.name} value={sa.name}>
|
|
165
|
+
{sa.name}
|
|
166
|
+
</option>
|
|
167
|
+
))}
|
|
168
|
+
</select>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
<div className="form-group">
|
|
173
|
+
<label>Expiry</label>
|
|
174
|
+
<div>
|
|
175
|
+
<label style={{ fontWeight: 'normal', marginRight: 16 }}>
|
|
176
|
+
<input
|
|
177
|
+
type="radio"
|
|
178
|
+
name="expiry-mode"
|
|
179
|
+
style={{ marginRight: 6 }}
|
|
180
|
+
checked={useForever}
|
|
181
|
+
onChange={() => handleToggleForever(true)}
|
|
182
|
+
/>
|
|
183
|
+
{context === 'serviceAccount' ? (
|
|
184
|
+
<>Never expires</>
|
|
185
|
+
) : (
|
|
186
|
+
<>
|
|
187
|
+
Maximum <small className="text-muted">({maxLifetimeDays} days, set by server policy)</small>
|
|
188
|
+
</>
|
|
189
|
+
)}
|
|
190
|
+
</label>
|
|
191
|
+
<label style={{ fontWeight: 'normal' }}>
|
|
192
|
+
<input
|
|
193
|
+
type="radio"
|
|
194
|
+
name="expiry-mode"
|
|
195
|
+
style={{ marginRight: 6 }}
|
|
196
|
+
checked={!useForever}
|
|
197
|
+
onChange={() => handleToggleForever(false)}
|
|
198
|
+
/>
|
|
199
|
+
Custom date <small className="text-muted">(up to {maxLifetimeDays} days)</small>
|
|
200
|
+
</label>
|
|
201
|
+
</div>
|
|
202
|
+
{!useForever && (
|
|
203
|
+
<input
|
|
204
|
+
id="token-expiry"
|
|
205
|
+
className="form-control"
|
|
206
|
+
style={{ marginTop: 8 }}
|
|
207
|
+
type="date"
|
|
208
|
+
min={todayStr}
|
|
209
|
+
max={maxDateStr}
|
|
210
|
+
value={customExpiry}
|
|
211
|
+
onChange={(e) => setCustomExpiry(e.target.value)}
|
|
212
|
+
required
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
</Modal.Body>
|
|
217
|
+
<Modal.Footer>
|
|
218
|
+
<button type="button" className="btn btn-default" onClick={onClose} disabled={creating}>
|
|
219
|
+
Cancel
|
|
220
|
+
</button>
|
|
221
|
+
<button type="submit" className="btn btn-primary" disabled={creating || !isSubmittable}>
|
|
222
|
+
{creating ? 'Creating…' : 'Create Token'}
|
|
223
|
+
</button>
|
|
224
|
+
</Modal.Footer>
|
|
225
|
+
</form>
|
|
226
|
+
</Modal>
|
|
227
|
+
);
|
|
228
|
+
}
|