@shipfox/client-runners 0.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/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +64 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +112 -0
- package/LICENSE +21 -0
- package/dist/components/create-manual-registration-token-form.d.ts +10 -0
- package/dist/components/create-manual-registration-token-form.d.ts.map +1 -0
- package/dist/components/create-manual-registration-token-form.js +206 -0
- package/dist/components/create-manual-registration-token-form.js.map +1 -0
- package/dist/components/create-provisioner-token-form.d.ts +10 -0
- package/dist/components/create-provisioner-token-form.d.ts.map +1 -0
- package/dist/components/create-provisioner-token-form.js +206 -0
- package/dist/components/create-provisioner-token-form.js.map +1 -0
- package/dist/components/form-errors.d.ts +6 -0
- package/dist/components/form-errors.d.ts.map +1 -0
- package/dist/components/form-errors.js +9 -0
- package/dist/components/form-errors.js.map +1 -0
- package/dist/components/manual-registration-token-errors.d.ts +2 -0
- package/dist/components/manual-registration-token-errors.d.ts.map +1 -0
- package/dist/components/manual-registration-token-errors.js +14 -0
- package/dist/components/manual-registration-token-errors.js.map +1 -0
- package/dist/components/manual-registration-token-format.d.ts +5 -0
- package/dist/components/manual-registration-token-format.d.ts.map +1 -0
- package/dist/components/manual-registration-token-format.js +14 -0
- package/dist/components/manual-registration-token-format.js.map +1 -0
- package/dist/components/manual-registration-token-list.d.ts +8 -0
- package/dist/components/manual-registration-token-list.d.ts.map +1 -0
- package/dist/components/manual-registration-token-list.js +293 -0
- package/dist/components/manual-registration-token-list.js.map +1 -0
- package/dist/components/manual-registration-tokens-settings-section.d.ts +4 -0
- package/dist/components/manual-registration-tokens-settings-section.d.ts.map +1 -0
- package/dist/components/manual-registration-tokens-settings-section.js +109 -0
- package/dist/components/manual-registration-tokens-settings-section.js.map +1 -0
- package/dist/components/provisioner-token-errors.d.ts +2 -0
- package/dist/components/provisioner-token-errors.d.ts.map +1 -0
- package/dist/components/provisioner-token-errors.js +13 -0
- package/dist/components/provisioner-token-errors.js.map +1 -0
- package/dist/components/provisioner-token-form-errors.d.ts +6 -0
- package/dist/components/provisioner-token-form-errors.d.ts.map +1 -0
- package/dist/components/provisioner-token-form-errors.js +9 -0
- package/dist/components/provisioner-token-form-errors.js.map +1 -0
- package/dist/components/provisioner-token-format.d.ts +21 -0
- package/dist/components/provisioner-token-format.d.ts.map +1 -0
- package/dist/components/provisioner-token-format.js +36 -0
- package/dist/components/provisioner-token-format.js.map +1 -0
- package/dist/components/provisioner-token-list.d.ts +9 -0
- package/dist/components/provisioner-token-list.d.ts.map +1 -0
- package/dist/components/provisioner-token-list.js +344 -0
- package/dist/components/provisioner-token-list.js.map +1 -0
- package/dist/components/provisioner-tokens-settings-section.d.ts +4 -0
- package/dist/components/provisioner-tokens-settings-section.d.ts.map +1 -0
- package/dist/components/provisioner-tokens-settings-section.js +117 -0
- package/dist/components/provisioner-tokens-settings-section.js.map +1 -0
- package/dist/components/token-date.d.ts +6 -0
- package/dist/components/token-date.d.ts.map +1 -0
- package/dist/components/token-date.js +27 -0
- package/dist/components/token-date.js.map +1 -0
- package/dist/components/token-expiration-select.d.ts +11 -0
- package/dist/components/token-expiration-select.d.ts.map +1 -0
- package/dist/components/token-expiration-select.js +68 -0
- package/dist/components/token-expiration-select.js.map +1 -0
- package/dist/components/token-name.d.ts +4 -0
- package/dist/components/token-name.d.ts.map +1 -0
- package/dist/components/token-name.js +24 -0
- package/dist/components/token-name.js.map +1 -0
- package/dist/components/token-settings-sections.stories.d.ts +26 -0
- package/dist/components/token-settings-sections.stories.d.ts.map +1 -0
- package/dist/components/token-settings-sections.stories.js +370 -0
- package/dist/components/token-settings-sections.stories.js.map +1 -0
- package/dist/hooks/api/manual-registration-tokens.d.ts +83 -0
- package/dist/hooks/api/manual-registration-tokens.d.ts.map +1 -0
- package/dist/hooks/api/manual-registration-tokens.js +53 -0
- package/dist/hooks/api/manual-registration-tokens.js.map +1 -0
- package/dist/hooks/api/provisioner-tokens.d.ts +125 -0
- package/dist/hooks/api/provisioner-tokens.d.ts.map +1 -0
- package/dist/hooks/api/provisioner-tokens.js +81 -0
- package/dist/hooks/api/provisioner-tokens.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +88 -0
- package/src/components/create-manual-registration-token-form.tsx +197 -0
- package/src/components/create-provisioner-token-form.tsx +193 -0
- package/src/components/form-errors.test.ts +24 -0
- package/src/components/form-errors.ts +9 -0
- package/src/components/manual-registration-token-errors.ts +13 -0
- package/src/components/manual-registration-token-format.test.ts +48 -0
- package/src/components/manual-registration-token-format.ts +18 -0
- package/src/components/manual-registration-token-list.tsx +246 -0
- package/src/components/manual-registration-tokens-settings-section.test.tsx +251 -0
- package/src/components/manual-registration-tokens-settings-section.tsx +110 -0
- package/src/components/provisioner-token-errors.ts +12 -0
- package/src/components/provisioner-token-form-errors.test.ts +35 -0
- package/src/components/provisioner-token-form-errors.ts +9 -0
- package/src/components/provisioner-token-format.test.ts +96 -0
- package/src/components/provisioner-token-format.ts +40 -0
- package/src/components/provisioner-token-list.tsx +280 -0
- package/src/components/provisioner-tokens-settings-section.test.tsx +362 -0
- package/src/components/provisioner-tokens-settings-section.tsx +114 -0
- package/src/components/token-date.tsx +27 -0
- package/src/components/token-expiration-select.tsx +64 -0
- package/src/components/token-name.tsx +19 -0
- package/src/components/token-settings-sections.stories.tsx +360 -0
- package/src/hooks/api/manual-registration-tokens.test.ts +87 -0
- package/src/hooks/api/manual-registration-tokens.ts +71 -0
- package/src/hooks/api/provisioner-tokens.test.ts +127 -0
- package/src/hooks/api/provisioner-tokens.ts +102 -0
- package/src/index.ts +4 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +74 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
2
|
+
import {Toaster} from '@shipfox/react-ui/toast';
|
|
3
|
+
import {formatDate, formatTimestamp} from '@shipfox/react-ui/utils';
|
|
4
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
5
|
+
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import type {ReactElement} from 'react';
|
|
8
|
+
import {WorkspaceProvisionerTokensSettingsSection} from './provisioner-tokens-settings-section.js';
|
|
9
|
+
|
|
10
|
+
const RUNNERS_TEST_WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
|
|
11
|
+
const PROVISIONER_TOKEN_ID = '33333333-3333-4333-8333-333333333333';
|
|
12
|
+
const LAST_SEEN_TEXT = /Last seen/;
|
|
13
|
+
|
|
14
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
15
|
+
return new Response(JSON.stringify(body), {
|
|
16
|
+
status: 200,
|
|
17
|
+
headers: {'content-type': 'application/json'},
|
|
18
|
+
...init,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function provisionerToken(overrides: Partial<Record<string, string | null>> = {}) {
|
|
23
|
+
return {
|
|
24
|
+
id: PROVISIONER_TOKEN_ID,
|
|
25
|
+
workspace_id: RUNNERS_TEST_WORKSPACE_ID,
|
|
26
|
+
prefix: 'sf_pt_abcde',
|
|
27
|
+
name: 'Docker provisioner',
|
|
28
|
+
created_by_user_id: '22222222-2222-4222-8222-222222222222',
|
|
29
|
+
revoked_by_user_id: null,
|
|
30
|
+
expires_at: '2026-05-09T00:00:00.000Z',
|
|
31
|
+
revoked_at: null,
|
|
32
|
+
last_seen_at: null,
|
|
33
|
+
created_at: '2026-05-08T00:00:00.000Z',
|
|
34
|
+
updated_at: '2026-05-08T00:00:00.000Z',
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function activeProvisioner(overrides: Partial<Record<string, string | null>> = {}) {
|
|
40
|
+
return {
|
|
41
|
+
id: PROVISIONER_TOKEN_ID,
|
|
42
|
+
name: 'Docker provisioner',
|
|
43
|
+
prefix: 'sf_pt_abcde',
|
|
44
|
+
last_seen_at: '2026-05-08T01:00:00.000Z',
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderProvisionerTokens(element: ReactElement) {
|
|
50
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
51
|
+
|
|
52
|
+
return render(
|
|
53
|
+
<QueryClientProvider client={queryClient}>
|
|
54
|
+
{element}
|
|
55
|
+
<Toaster />
|
|
56
|
+
</QueryClientProvider>,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function firstButton(name: string): HTMLElement {
|
|
61
|
+
const button = screen.getAllByRole('button', {name})[0];
|
|
62
|
+
if (!button) throw new Error(`Button not found: ${name}`);
|
|
63
|
+
return button;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lastButton(name: string): HTMLElement {
|
|
67
|
+
const buttons = screen.getAllByRole('button', {name});
|
|
68
|
+
const button = buttons.at(-1);
|
|
69
|
+
if (!button) throw new Error(`Button not found: ${name}`);
|
|
70
|
+
return button;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function chooseTokenAction(user: ReturnType<typeof userEvent.setup>, tokenName: string) {
|
|
74
|
+
await user.click(firstButton(`Open ${tokenName} token actions`));
|
|
75
|
+
const menuItem = await screen.findByRole('menuitem', {name: 'Revoke token'});
|
|
76
|
+
expect(menuItem.querySelector('svg')).not.toBeInTheDocument();
|
|
77
|
+
|
|
78
|
+
await user.click(menuItem);
|
|
79
|
+
expect(await screen.findByRole('dialog')).toBeVisible();
|
|
80
|
+
expect(screen.getByText('Revoke token?')).toBeVisible();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('WorkspaceProvisionerTokensSettingsSection', () => {
|
|
84
|
+
test('renders an empty usable-token state', async () => {
|
|
85
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
86
|
+
const request = input as Request;
|
|
87
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
88
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
89
|
+
}
|
|
90
|
+
return Promise.resolve(jsonResponse({tokens: []}));
|
|
91
|
+
});
|
|
92
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
93
|
+
|
|
94
|
+
renderProvisionerTokens(
|
|
95
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(await screen.findByText('No usable provisioner registration tokens')).toBeVisible();
|
|
99
|
+
expect(screen.getByRole('button', {name: 'Create token'})).toBeVisible();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('creates a token and reveals the raw token inline once', async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
let tokens = [] as ReturnType<typeof provisionerToken>[];
|
|
105
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
106
|
+
const request = input as Request;
|
|
107
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
108
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
109
|
+
}
|
|
110
|
+
if (request.method === 'POST' && request.url.endsWith('/provisioners/tokens')) {
|
|
111
|
+
tokens = [provisionerToken({name: 'Docker provisioner'})];
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
jsonResponse(
|
|
114
|
+
{
|
|
115
|
+
...provisionerToken({name: 'Docker provisioner'}),
|
|
116
|
+
raw_token: 'sf_pt_raw-created-token',
|
|
117
|
+
},
|
|
118
|
+
{status: 201},
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return Promise.resolve(jsonResponse({tokens}));
|
|
123
|
+
});
|
|
124
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
125
|
+
|
|
126
|
+
renderProvisionerTokens(
|
|
127
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
128
|
+
);
|
|
129
|
+
await screen.findByText('No usable provisioner registration tokens');
|
|
130
|
+
await user.click(screen.getByRole('button', {name: 'Create token'}));
|
|
131
|
+
fireEvent.change(await screen.findByLabelText('Token name'), {
|
|
132
|
+
target: {value: 'Docker provisioner'},
|
|
133
|
+
});
|
|
134
|
+
await user.click(lastButton('Create token'));
|
|
135
|
+
|
|
136
|
+
expect(await screen.findByText('Token created')).toBeVisible();
|
|
137
|
+
expect(screen.getByText('sf_pt_raw-created-token')).toBeVisible();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('renders compact dates with exact timestamp tooltips', async () => {
|
|
141
|
+
const user = userEvent.setup();
|
|
142
|
+
const createdAt = '2026-05-08T00:00:00.000Z';
|
|
143
|
+
const expiresAt = '2026-05-09T00:00:00.000Z';
|
|
144
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
145
|
+
const request = input as Request;
|
|
146
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
147
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
148
|
+
}
|
|
149
|
+
return Promise.resolve(
|
|
150
|
+
jsonResponse({
|
|
151
|
+
tokens: [provisionerToken({created_at: createdAt, expires_at: expiresAt})],
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
156
|
+
|
|
157
|
+
renderProvisionerTokens(
|
|
158
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
159
|
+
);
|
|
160
|
+
const expiresDate = await screen.findAllByText(formatDate(expiresAt));
|
|
161
|
+
const createdDate = await screen.findAllByText(formatDate(createdAt));
|
|
162
|
+
const expiresDateTrigger = expiresDate[0];
|
|
163
|
+
const createdDateTrigger = createdDate[0];
|
|
164
|
+
if (!expiresDateTrigger || !createdDateTrigger) throw new Error('Token dates not rendered');
|
|
165
|
+
|
|
166
|
+
expect(screen.queryByText(formatTimestamp(expiresAt))).not.toBeInTheDocument();
|
|
167
|
+
await user.hover(expiresDateTrigger);
|
|
168
|
+
|
|
169
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent(formatTimestamp(expiresAt));
|
|
170
|
+
|
|
171
|
+
await user.unhover(expiresDateTrigger);
|
|
172
|
+
await user.hover(createdDateTrigger);
|
|
173
|
+
|
|
174
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent(formatTimestamp(createdAt));
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('truncates long token names and shows the full name in a tooltip', async () => {
|
|
178
|
+
const user = userEvent.setup();
|
|
179
|
+
const tokenName = 'docker-provisioner-for-west-coast-gpu-burst-capacity-and-fallback';
|
|
180
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
181
|
+
const request = input as Request;
|
|
182
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
183
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
184
|
+
}
|
|
185
|
+
return Promise.resolve(jsonResponse({tokens: [provisionerToken({name: tokenName})]}));
|
|
186
|
+
});
|
|
187
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
188
|
+
|
|
189
|
+
renderProvisionerTokens(
|
|
190
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
191
|
+
);
|
|
192
|
+
const nameTrigger = await screen.findAllByRole('button', {name: tokenName});
|
|
193
|
+
const visibleNameTrigger = nameTrigger[0];
|
|
194
|
+
if (!visibleNameTrigger) throw new Error('Token name not rendered');
|
|
195
|
+
|
|
196
|
+
expect(screen.getByRole('table')).toHaveClass('table-fixed');
|
|
197
|
+
expect(visibleNameTrigger).toHaveClass('truncate');
|
|
198
|
+
await user.hover(visibleNameTrigger);
|
|
199
|
+
|
|
200
|
+
expect(await screen.findByRole('tooltip')).toHaveTextContent(tokenName);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('surfaces create errors without clearing the form', async () => {
|
|
204
|
+
const user = userEvent.setup();
|
|
205
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
206
|
+
const request = input as Request;
|
|
207
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
208
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
209
|
+
}
|
|
210
|
+
if (request.method === 'POST' && request.url.endsWith('/provisioners/tokens')) {
|
|
211
|
+
return Promise.resolve(
|
|
212
|
+
jsonResponse({message: 'Token quota reached', code: 'quota-reached'}, {status: 422}),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return Promise.resolve(jsonResponse({tokens: []}));
|
|
216
|
+
});
|
|
217
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
218
|
+
|
|
219
|
+
renderProvisionerTokens(
|
|
220
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
221
|
+
);
|
|
222
|
+
await screen.findByText('No usable provisioner registration tokens');
|
|
223
|
+
await user.click(screen.getByRole('button', {name: 'Create token'}));
|
|
224
|
+
fireEvent.change(await screen.findByLabelText('Token name'), {
|
|
225
|
+
target: {value: 'Docker provisioner'},
|
|
226
|
+
});
|
|
227
|
+
await user.click(lastButton('Create token'));
|
|
228
|
+
|
|
229
|
+
expect(await screen.findByText('Could not create token')).toBeVisible();
|
|
230
|
+
expect(screen.getByText('Token quota reached')).toBeVisible();
|
|
231
|
+
expect(screen.getByLabelText('Token name')).toHaveValue('Docker provisioner');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('renders connected, last-seen, and never-connected statuses', async () => {
|
|
235
|
+
const activeId = '44444444-4444-4444-8444-444444444444';
|
|
236
|
+
const staleId = '55555555-5555-4555-8555-555555555555';
|
|
237
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
238
|
+
const request = input as Request;
|
|
239
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
240
|
+
return Promise.resolve(
|
|
241
|
+
jsonResponse({
|
|
242
|
+
provisioners: [activeProvisioner({id: activeId, name: 'Active provisioner'})],
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return Promise.resolve(
|
|
247
|
+
jsonResponse({
|
|
248
|
+
tokens: [
|
|
249
|
+
provisionerToken({id: activeId, name: 'Active provisioner'}),
|
|
250
|
+
provisionerToken({
|
|
251
|
+
id: staleId,
|
|
252
|
+
name: 'Stale provisioner',
|
|
253
|
+
last_seen_at: '2026-05-08T01:00:00.000Z',
|
|
254
|
+
}),
|
|
255
|
+
provisionerToken({name: 'New provisioner'}),
|
|
256
|
+
],
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
261
|
+
|
|
262
|
+
renderProvisionerTokens(
|
|
263
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
expect((await screen.findAllByText('Connected')).length).toBeGreaterThan(0);
|
|
267
|
+
expect(screen.getAllByText(LAST_SEEN_TEXT).length).toBeGreaterThan(0);
|
|
268
|
+
expect(screen.getAllByText('Never connected').length).toBeGreaterThan(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('renders recency when active provisioners cannot load', async () => {
|
|
272
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
273
|
+
const request = input as Request;
|
|
274
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
275
|
+
return Promise.resolve(
|
|
276
|
+
jsonResponse({message: 'Server error', code: 'server-error'}, {status: 500}),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return Promise.resolve(
|
|
280
|
+
jsonResponse({
|
|
281
|
+
tokens: [
|
|
282
|
+
provisionerToken({
|
|
283
|
+
last_seen_at: '2026-05-08T01:00:00.000Z',
|
|
284
|
+
}),
|
|
285
|
+
],
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
290
|
+
|
|
291
|
+
renderProvisionerTokens(
|
|
292
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect((await screen.findAllByText(LAST_SEEN_TEXT)).length).toBeGreaterThan(0);
|
|
296
|
+
expect(screen.getAllByText('Docker provisioner').length).toBeGreaterThan(0);
|
|
297
|
+
expect(
|
|
298
|
+
screen.queryByText('Could not load provisioner registration tokens'),
|
|
299
|
+
).not.toBeInTheDocument();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('revokes a token after row confirmation and removes it from the list', async () => {
|
|
303
|
+
const user = userEvent.setup();
|
|
304
|
+
let tokens = [provisionerToken()];
|
|
305
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
306
|
+
const request = input as Request;
|
|
307
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
308
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
309
|
+
}
|
|
310
|
+
if (request.method === 'POST' && request.url.includes('/revoke')) {
|
|
311
|
+
tokens = [];
|
|
312
|
+
return Promise.resolve(
|
|
313
|
+
jsonResponse(
|
|
314
|
+
provisionerToken({
|
|
315
|
+
revoked_at: '2026-05-08T01:00:00.000Z',
|
|
316
|
+
revoked_by_user_id: '22222222-2222-4222-8222-222222222222',
|
|
317
|
+
}),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return Promise.resolve(jsonResponse({tokens}));
|
|
322
|
+
});
|
|
323
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
324
|
+
|
|
325
|
+
renderProvisionerTokens(
|
|
326
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
327
|
+
);
|
|
328
|
+
expect((await screen.findAllByText('Docker provisioner')).length).toBeGreaterThan(0);
|
|
329
|
+
await chooseTokenAction(user, 'Docker provisioner');
|
|
330
|
+
await user.click(lastButton('Revoke'));
|
|
331
|
+
|
|
332
|
+
await waitFor(() => expect(screen.queryByText('Docker provisioner')).not.toBeInTheDocument());
|
|
333
|
+
expect(screen.getByText('No usable provisioner registration tokens')).toBeVisible();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('shows a recoverable revoke error', async () => {
|
|
337
|
+
const user = userEvent.setup();
|
|
338
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
339
|
+
const request = input as Request;
|
|
340
|
+
if (request.url.endsWith('/provisioners/active')) {
|
|
341
|
+
return Promise.resolve(jsonResponse({provisioners: []}));
|
|
342
|
+
}
|
|
343
|
+
if (request.method === 'POST' && request.url.includes('/revoke')) {
|
|
344
|
+
return Promise.resolve(
|
|
345
|
+
jsonResponse({message: 'Provisioner token not found', code: 'not-found'}, {status: 404}),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return Promise.resolve(jsonResponse({tokens: [provisionerToken()]}));
|
|
349
|
+
});
|
|
350
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
351
|
+
|
|
352
|
+
renderProvisionerTokens(
|
|
353
|
+
<WorkspaceProvisionerTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
|
|
354
|
+
);
|
|
355
|
+
expect((await screen.findAllByText('Docker provisioner')).length).toBeGreaterThan(0);
|
|
356
|
+
await chooseTokenAction(user, 'Docker provisioner');
|
|
357
|
+
await user.click(lastButton('Revoke'));
|
|
358
|
+
|
|
359
|
+
expect(await screen.findByText('Provisioner token not found')).toBeVisible();
|
|
360
|
+
expect(screen.getAllByText('Docker provisioner').length).toBeGreaterThan(0);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type {CreateProvisionerTokenResponseDto} from '@shipfox/api-runners-dto';
|
|
2
|
+
import {QueryLoadError} from '@shipfox/client-ui';
|
|
3
|
+
import {Button} from '@shipfox/react-ui/button';
|
|
4
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
5
|
+
import {
|
|
6
|
+
Modal,
|
|
7
|
+
ModalBody,
|
|
8
|
+
ModalContent,
|
|
9
|
+
ModalHeader,
|
|
10
|
+
ModalTitle,
|
|
11
|
+
ModalTrigger,
|
|
12
|
+
} from '@shipfox/react-ui/modal';
|
|
13
|
+
import {RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
|
|
14
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
15
|
+
import {useMemo, useState} from 'react';
|
|
16
|
+
import {
|
|
17
|
+
useActiveProvisionersQuery,
|
|
18
|
+
useProvisionerTokensQuery,
|
|
19
|
+
} from '#hooks/api/provisioner-tokens.js';
|
|
20
|
+
import {
|
|
21
|
+
CreatedProvisionerTokenPanel,
|
|
22
|
+
CreateProvisionerTokenForm,
|
|
23
|
+
} from './create-provisioner-token-form.js';
|
|
24
|
+
import {
|
|
25
|
+
EmptyProvisionerTokens,
|
|
26
|
+
ProvisionerTokenList,
|
|
27
|
+
ProvisionerTokenTableSkeleton,
|
|
28
|
+
} from './provisioner-token-list.js';
|
|
29
|
+
|
|
30
|
+
export function WorkspaceProvisionerTokensSettingsSection({workspaceId}: {workspaceId: string}) {
|
|
31
|
+
const tokensQuery = useProvisionerTokensQuery(workspaceId);
|
|
32
|
+
const activeProvisionersQuery = useActiveProvisionersQuery(workspaceId);
|
|
33
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
34
|
+
const [createdToken, setCreatedToken] = useState<CreateProvisionerTokenResponseDto | null>(null);
|
|
35
|
+
const tokens = tokensQuery.data?.tokens ?? [];
|
|
36
|
+
const activeIds = useMemo(
|
|
37
|
+
() => new Set(activeProvisionersQuery.data?.provisioners.map((provisioner) => provisioner.id)),
|
|
38
|
+
[activeProvisionersQuery.data],
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function handleOpenChange(nextOpen: boolean) {
|
|
42
|
+
setIsModalOpen(nextOpen);
|
|
43
|
+
if (!nextOpen) {
|
|
44
|
+
setCreatedToken(null);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="flex min-w-0 flex-col gap-32">
|
|
50
|
+
<section className="flex flex-col gap-16">
|
|
51
|
+
<div className="flex items-center justify-between gap-16 max-[640px]:items-start">
|
|
52
|
+
<div className="flex flex-col gap-4">
|
|
53
|
+
<Header variant="h3">Runner provisioner registration tokens</Header>
|
|
54
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
55
|
+
Register runner provisioners that connect to this workspace and create runners
|
|
56
|
+
dynamically based on demand.
|
|
57
|
+
</Text>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="flex items-center gap-12">
|
|
60
|
+
{(tokensQuery.isFetching && !tokensQuery.isPending) ||
|
|
61
|
+
(activeProvisionersQuery.isFetching && !activeProvisionersQuery.isPending) ? (
|
|
62
|
+
<Icon
|
|
63
|
+
name="loader4Line"
|
|
64
|
+
className="mt-2 size-18 text-foreground-neutral-muted"
|
|
65
|
+
aria-label="Refreshing provisioner tokens"
|
|
66
|
+
/>
|
|
67
|
+
) : null}
|
|
68
|
+
<Modal open={isModalOpen} onOpenChange={handleOpenChange}>
|
|
69
|
+
<ModalTrigger asChild>
|
|
70
|
+
<Button>Create token</Button>
|
|
71
|
+
</ModalTrigger>
|
|
72
|
+
<ModalContent aria-describedby={undefined}>
|
|
73
|
+
<ModalTitle className="sr-only">Create provisioner registration token</ModalTitle>
|
|
74
|
+
<ModalHeader>
|
|
75
|
+
<Text
|
|
76
|
+
size="lg"
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
className="overflow-ellipsis overflow-hidden whitespace-nowrap"
|
|
79
|
+
>
|
|
80
|
+
Create provisioner registration token
|
|
81
|
+
</Text>
|
|
82
|
+
</ModalHeader>
|
|
83
|
+
{createdToken ? (
|
|
84
|
+
<ModalBody className="gap-16">
|
|
85
|
+
<CreatedProvisionerTokenPanel token={createdToken} />
|
|
86
|
+
</ModalBody>
|
|
87
|
+
) : (
|
|
88
|
+
<CreateProvisionerTokenForm
|
|
89
|
+
workspaceId={workspaceId}
|
|
90
|
+
onCreated={setCreatedToken}
|
|
91
|
+
/>
|
|
92
|
+
)}
|
|
93
|
+
</ModalContent>
|
|
94
|
+
</Modal>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{tokensQuery.isPending ? <ProvisionerTokenTableSkeleton /> : null}
|
|
99
|
+
|
|
100
|
+
{tokensQuery.isError && tokensQuery.data === undefined ? (
|
|
101
|
+
<QueryLoadError query={tokensQuery} subject="provisioner registration tokens" />
|
|
102
|
+
) : null}
|
|
103
|
+
|
|
104
|
+
{tokensQuery.data !== undefined && tokens.length === 0 ? <EmptyProvisionerTokens /> : null}
|
|
105
|
+
|
|
106
|
+
{tokens.length > 0 ? (
|
|
107
|
+
<RelativeTimeProvider>
|
|
108
|
+
<ProvisionerTokenList workspaceId={workspaceId} tokens={tokens} activeIds={activeIds} />
|
|
109
|
+
</RelativeTimeProvider>
|
|
110
|
+
) : null}
|
|
111
|
+
</section>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
2
|
+
|
|
3
|
+
export function TokenDate({
|
|
4
|
+
value,
|
|
5
|
+
date,
|
|
6
|
+
timestamp,
|
|
7
|
+
}: {
|
|
8
|
+
value: string | null;
|
|
9
|
+
date: string;
|
|
10
|
+
timestamp: string | undefined;
|
|
11
|
+
}) {
|
|
12
|
+
if (!value || !timestamp) return <>{date}</>;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Tooltip>
|
|
16
|
+
<TooltipTrigger asChild>
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
className="cursor-help rounded-4 outline-none focus-visible:shadow-button-neutral-focus"
|
|
20
|
+
>
|
|
21
|
+
<time dateTime={value}>{date}</time>
|
|
22
|
+
</button>
|
|
23
|
+
</TooltipTrigger>
|
|
24
|
+
<TooltipContent>{timestamp}</TooltipContent>
|
|
25
|
+
</Tooltip>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {useFormField} from '@shipfox/react-ui/form-field';
|
|
2
|
+
import {
|
|
3
|
+
Select,
|
|
4
|
+
SelectContent,
|
|
5
|
+
SelectItem,
|
|
6
|
+
SelectTrigger,
|
|
7
|
+
SelectValue,
|
|
8
|
+
} from '@shipfox/react-ui/select';
|
|
9
|
+
|
|
10
|
+
export type TokenExpirationOption =
|
|
11
|
+
| '86400'
|
|
12
|
+
| '604800'
|
|
13
|
+
| '2592000'
|
|
14
|
+
| '7776000'
|
|
15
|
+
| '15552000'
|
|
16
|
+
| '31536000'
|
|
17
|
+
| 'never';
|
|
18
|
+
|
|
19
|
+
export const expirationOptions: Array<{value: TokenExpirationOption; label: string}> = [
|
|
20
|
+
{value: '86400', label: '1 day'},
|
|
21
|
+
{value: '604800', label: '7 days'},
|
|
22
|
+
{value: '2592000', label: '30 days'},
|
|
23
|
+
{value: '7776000', label: '90 days'},
|
|
24
|
+
{value: '15552000', label: '180 days'},
|
|
25
|
+
{value: '31536000', label: '1 year'},
|
|
26
|
+
{value: 'never', label: 'Never'},
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function expirationHint(expiration: TokenExpirationOption): string {
|
|
30
|
+
if (expiration === 'never') return 'Token will not expire.';
|
|
31
|
+
const expiresAt = new Date(Date.now() + Number(expiration) * 1000);
|
|
32
|
+
const formatted = new Intl.DateTimeFormat(undefined, {dateStyle: 'medium'}).format(expiresAt);
|
|
33
|
+
return `Expires on ${formatted}.`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ExpirationSelect({
|
|
37
|
+
value,
|
|
38
|
+
onValueChange,
|
|
39
|
+
}: {
|
|
40
|
+
value: TokenExpirationOption;
|
|
41
|
+
onValueChange: (next: TokenExpirationOption) => void;
|
|
42
|
+
}) {
|
|
43
|
+
const wiring = useFormField();
|
|
44
|
+
return (
|
|
45
|
+
<Select value={value} onValueChange={(next) => onValueChange(next as TokenExpirationOption)}>
|
|
46
|
+
<SelectTrigger
|
|
47
|
+
id={wiring.id}
|
|
48
|
+
aria-invalid={wiring['aria-invalid']}
|
|
49
|
+
aria-describedby={wiring['aria-describedby']}
|
|
50
|
+
aria-label="Token expiration"
|
|
51
|
+
className="w-full"
|
|
52
|
+
>
|
|
53
|
+
<SelectValue placeholder="Select expiration" />
|
|
54
|
+
</SelectTrigger>
|
|
55
|
+
<SelectContent>
|
|
56
|
+
{expirationOptions.map((option) => (
|
|
57
|
+
<SelectItem key={option.value} value={option.value}>
|
|
58
|
+
{option.label}
|
|
59
|
+
</SelectItem>
|
|
60
|
+
))}
|
|
61
|
+
</SelectContent>
|
|
62
|
+
</Select>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
2
|
+
|
|
3
|
+
export function TokenName({name}: {name: string}) {
|
|
4
|
+
return (
|
|
5
|
+
<Tooltip>
|
|
6
|
+
<TooltipTrigger asChild>
|
|
7
|
+
<button
|
|
8
|
+
type="button"
|
|
9
|
+
className="block w-full min-w-0 cursor-help truncate rounded-4 text-left text-sm leading-20 font-medium text-foreground-neutral-base outline-none focus-visible:shadow-button-neutral-focus"
|
|
10
|
+
>
|
|
11
|
+
{name}
|
|
12
|
+
</button>
|
|
13
|
+
</TooltipTrigger>
|
|
14
|
+
<TooltipContent>
|
|
15
|
+
<span className="block max-w-[360px] break-words">{name}</span>
|
|
16
|
+
</TooltipContent>
|
|
17
|
+
</Tooltip>
|
|
18
|
+
);
|
|
19
|
+
}
|