@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.
Files changed (120) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +64 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +112 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/create-manual-registration-token-form.d.ts +10 -0
  10. package/dist/components/create-manual-registration-token-form.d.ts.map +1 -0
  11. package/dist/components/create-manual-registration-token-form.js +206 -0
  12. package/dist/components/create-manual-registration-token-form.js.map +1 -0
  13. package/dist/components/create-provisioner-token-form.d.ts +10 -0
  14. package/dist/components/create-provisioner-token-form.d.ts.map +1 -0
  15. package/dist/components/create-provisioner-token-form.js +206 -0
  16. package/dist/components/create-provisioner-token-form.js.map +1 -0
  17. package/dist/components/form-errors.d.ts +6 -0
  18. package/dist/components/form-errors.d.ts.map +1 -0
  19. package/dist/components/form-errors.js +9 -0
  20. package/dist/components/form-errors.js.map +1 -0
  21. package/dist/components/manual-registration-token-errors.d.ts +2 -0
  22. package/dist/components/manual-registration-token-errors.d.ts.map +1 -0
  23. package/dist/components/manual-registration-token-errors.js +14 -0
  24. package/dist/components/manual-registration-token-errors.js.map +1 -0
  25. package/dist/components/manual-registration-token-format.d.ts +5 -0
  26. package/dist/components/manual-registration-token-format.d.ts.map +1 -0
  27. package/dist/components/manual-registration-token-format.js +14 -0
  28. package/dist/components/manual-registration-token-format.js.map +1 -0
  29. package/dist/components/manual-registration-token-list.d.ts +8 -0
  30. package/dist/components/manual-registration-token-list.d.ts.map +1 -0
  31. package/dist/components/manual-registration-token-list.js +293 -0
  32. package/dist/components/manual-registration-token-list.js.map +1 -0
  33. package/dist/components/manual-registration-tokens-settings-section.d.ts +4 -0
  34. package/dist/components/manual-registration-tokens-settings-section.d.ts.map +1 -0
  35. package/dist/components/manual-registration-tokens-settings-section.js +109 -0
  36. package/dist/components/manual-registration-tokens-settings-section.js.map +1 -0
  37. package/dist/components/provisioner-token-errors.d.ts +2 -0
  38. package/dist/components/provisioner-token-errors.d.ts.map +1 -0
  39. package/dist/components/provisioner-token-errors.js +13 -0
  40. package/dist/components/provisioner-token-errors.js.map +1 -0
  41. package/dist/components/provisioner-token-form-errors.d.ts +6 -0
  42. package/dist/components/provisioner-token-form-errors.d.ts.map +1 -0
  43. package/dist/components/provisioner-token-form-errors.js +9 -0
  44. package/dist/components/provisioner-token-form-errors.js.map +1 -0
  45. package/dist/components/provisioner-token-format.d.ts +21 -0
  46. package/dist/components/provisioner-token-format.d.ts.map +1 -0
  47. package/dist/components/provisioner-token-format.js +36 -0
  48. package/dist/components/provisioner-token-format.js.map +1 -0
  49. package/dist/components/provisioner-token-list.d.ts +9 -0
  50. package/dist/components/provisioner-token-list.d.ts.map +1 -0
  51. package/dist/components/provisioner-token-list.js +344 -0
  52. package/dist/components/provisioner-token-list.js.map +1 -0
  53. package/dist/components/provisioner-tokens-settings-section.d.ts +4 -0
  54. package/dist/components/provisioner-tokens-settings-section.d.ts.map +1 -0
  55. package/dist/components/provisioner-tokens-settings-section.js +117 -0
  56. package/dist/components/provisioner-tokens-settings-section.js.map +1 -0
  57. package/dist/components/token-date.d.ts +6 -0
  58. package/dist/components/token-date.d.ts.map +1 -0
  59. package/dist/components/token-date.js +27 -0
  60. package/dist/components/token-date.js.map +1 -0
  61. package/dist/components/token-expiration-select.d.ts +11 -0
  62. package/dist/components/token-expiration-select.d.ts.map +1 -0
  63. package/dist/components/token-expiration-select.js +68 -0
  64. package/dist/components/token-expiration-select.js.map +1 -0
  65. package/dist/components/token-name.d.ts +4 -0
  66. package/dist/components/token-name.d.ts.map +1 -0
  67. package/dist/components/token-name.js +24 -0
  68. package/dist/components/token-name.js.map +1 -0
  69. package/dist/components/token-settings-sections.stories.d.ts +26 -0
  70. package/dist/components/token-settings-sections.stories.d.ts.map +1 -0
  71. package/dist/components/token-settings-sections.stories.js +370 -0
  72. package/dist/components/token-settings-sections.stories.js.map +1 -0
  73. package/dist/hooks/api/manual-registration-tokens.d.ts +83 -0
  74. package/dist/hooks/api/manual-registration-tokens.d.ts.map +1 -0
  75. package/dist/hooks/api/manual-registration-tokens.js +53 -0
  76. package/dist/hooks/api/manual-registration-tokens.js.map +1 -0
  77. package/dist/hooks/api/provisioner-tokens.d.ts +125 -0
  78. package/dist/hooks/api/provisioner-tokens.d.ts.map +1 -0
  79. package/dist/hooks/api/provisioner-tokens.js +81 -0
  80. package/dist/hooks/api/provisioner-tokens.js.map +1 -0
  81. package/dist/index.d.ts +5 -0
  82. package/dist/index.d.ts.map +1 -0
  83. package/dist/index.js +6 -0
  84. package/dist/index.js.map +1 -0
  85. package/dist/tsconfig.test.tsbuildinfo +1 -0
  86. package/package.json +88 -0
  87. package/src/components/create-manual-registration-token-form.tsx +197 -0
  88. package/src/components/create-provisioner-token-form.tsx +193 -0
  89. package/src/components/form-errors.test.ts +24 -0
  90. package/src/components/form-errors.ts +9 -0
  91. package/src/components/manual-registration-token-errors.ts +13 -0
  92. package/src/components/manual-registration-token-format.test.ts +48 -0
  93. package/src/components/manual-registration-token-format.ts +18 -0
  94. package/src/components/manual-registration-token-list.tsx +246 -0
  95. package/src/components/manual-registration-tokens-settings-section.test.tsx +251 -0
  96. package/src/components/manual-registration-tokens-settings-section.tsx +110 -0
  97. package/src/components/provisioner-token-errors.ts +12 -0
  98. package/src/components/provisioner-token-form-errors.test.ts +35 -0
  99. package/src/components/provisioner-token-form-errors.ts +9 -0
  100. package/src/components/provisioner-token-format.test.ts +96 -0
  101. package/src/components/provisioner-token-format.ts +40 -0
  102. package/src/components/provisioner-token-list.tsx +280 -0
  103. package/src/components/provisioner-tokens-settings-section.test.tsx +362 -0
  104. package/src/components/provisioner-tokens-settings-section.tsx +114 -0
  105. package/src/components/token-date.tsx +27 -0
  106. package/src/components/token-expiration-select.tsx +64 -0
  107. package/src/components/token-name.tsx +19 -0
  108. package/src/components/token-settings-sections.stories.tsx +360 -0
  109. package/src/hooks/api/manual-registration-tokens.test.ts +87 -0
  110. package/src/hooks/api/manual-registration-tokens.ts +71 -0
  111. package/src/hooks/api/provisioner-tokens.test.ts +127 -0
  112. package/src/hooks/api/provisioner-tokens.ts +102 -0
  113. package/src/index.ts +4 -0
  114. package/test/setup.ts +3 -0
  115. package/tsconfig.build.json +9 -0
  116. package/tsconfig.build.tsbuildinfo +1 -0
  117. package/tsconfig.json +3 -0
  118. package/tsconfig.test.json +8 -0
  119. package/vercel.json +8 -0
  120. package/vitest.config.ts +74 -0
@@ -0,0 +1,246 @@
1
+ import type {ManualRegistrationTokenDto} from '@shipfox/api-runners-dto';
2
+ import {Button, IconButton} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from '@shipfox/react-ui/dropdown-menu';
10
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
11
+ import {
12
+ Modal,
13
+ ModalBody,
14
+ ModalContent,
15
+ ModalFooter,
16
+ ModalHeader,
17
+ ModalTitle,
18
+ } from '@shipfox/react-ui/modal';
19
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
20
+ import {
21
+ Table,
22
+ TableBody,
23
+ TableCell,
24
+ TableHead,
25
+ TableHeader,
26
+ TableRow,
27
+ } from '@shipfox/react-ui/table';
28
+ import {Code, Text} from '@shipfox/react-ui/typography';
29
+ import {useQueryClient} from '@tanstack/react-query';
30
+ import {useState} from 'react';
31
+ import {
32
+ manualRegistrationTokenQueryKeys,
33
+ useRevokeManualRegistrationTokenMutation,
34
+ } from '#hooks/api/manual-registration-tokens.js';
35
+ import {manualRegistrationTokenErrorMessage} from './manual-registration-token-errors.js';
36
+ import {
37
+ formatManualRegistrationTokenDate,
38
+ formatManualRegistrationTokenTimestamp,
39
+ manualRegistrationTokenDisplayName,
40
+ } from './manual-registration-token-format.js';
41
+ import {TokenDate} from './token-date.js';
42
+ import {TokenName} from './token-name.js';
43
+
44
+ export function ManualRegistrationTokenList({
45
+ workspaceId,
46
+ tokens,
47
+ }: {
48
+ workspaceId: string;
49
+ tokens: ManualRegistrationTokenDto[];
50
+ }) {
51
+ return (
52
+ <>
53
+ <div className="max-[760px]:hidden">
54
+ <Table className="table-fixed">
55
+ <TableHeader>
56
+ <TableRow>
57
+ <TableHead className="w-[34%]">Name</TableHead>
58
+ <TableHead>Prefix</TableHead>
59
+ <TableHead className="w-128">Expires</TableHead>
60
+ <TableHead className="w-128">Created</TableHead>
61
+ <TableHead className="w-80 text-right">Actions</TableHead>
62
+ </TableRow>
63
+ </TableHeader>
64
+ <TableBody>
65
+ {tokens.map((token) => (
66
+ <TableRow key={token.id}>
67
+ <TableCell>
68
+ <TokenName name={manualRegistrationTokenDisplayName(token)} />
69
+ </TableCell>
70
+ <TableCell>
71
+ <Code variant="paragraph" className="block truncate">
72
+ {token.prefix}
73
+ </Code>
74
+ </TableCell>
75
+ <TableCell>
76
+ <ManualRegistrationTokenDate value={token.expires_at} />
77
+ </TableCell>
78
+ <TableCell>
79
+ <ManualRegistrationTokenDate value={token.created_at} />
80
+ </TableCell>
81
+ <TableCell className="text-right">
82
+ <RevokeManualRegistrationTokenButton workspaceId={workspaceId} token={token} />
83
+ </TableCell>
84
+ </TableRow>
85
+ ))}
86
+ </TableBody>
87
+ </Table>
88
+ </div>
89
+ <ul
90
+ className="hidden flex-col gap-10 max-[760px]:flex"
91
+ aria-label="Manual registration tokens"
92
+ >
93
+ {tokens.map((token) => (
94
+ <li
95
+ key={token.id}
96
+ className="rounded-8 border border-border-neutral-base bg-background-neutral-base p-14"
97
+ >
98
+ <div className="flex items-start justify-between gap-12">
99
+ <div className="min-w-0 flex-1">
100
+ <TokenName name={manualRegistrationTokenDisplayName(token)} />
101
+ <Code variant="paragraph" className="block truncate text-foreground-neutral-muted">
102
+ {token.prefix}
103
+ </Code>
104
+ </div>
105
+ <RevokeManualRegistrationTokenButton workspaceId={workspaceId} token={token} />
106
+ </div>
107
+ <dl className="mt-12 grid grid-cols-2 gap-10 text-sm">
108
+ <div>
109
+ <dt className="text-foreground-neutral-muted">Expires</dt>
110
+ <dd>
111
+ <ManualRegistrationTokenDate value={token.expires_at} />
112
+ </dd>
113
+ </div>
114
+ <div>
115
+ <dt className="text-foreground-neutral-muted">Created</dt>
116
+ <dd>
117
+ <ManualRegistrationTokenDate value={token.created_at} />
118
+ </dd>
119
+ </div>
120
+ </dl>
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ </>
125
+ );
126
+ }
127
+
128
+ function ManualRegistrationTokenDate({value}: {value: string | null}) {
129
+ return (
130
+ <TokenDate
131
+ value={value}
132
+ date={formatManualRegistrationTokenDate(value)}
133
+ timestamp={formatManualRegistrationTokenTimestamp(value)}
134
+ />
135
+ );
136
+ }
137
+
138
+ function RevokeManualRegistrationTokenButton({
139
+ workspaceId,
140
+ token,
141
+ }: {
142
+ workspaceId: string;
143
+ token: ManualRegistrationTokenDto;
144
+ }) {
145
+ const queryClient = useQueryClient();
146
+ const revokeToken = useRevokeManualRegistrationTokenMutation();
147
+ const [open, setOpen] = useState(false);
148
+ const tokenName = manualRegistrationTokenDisplayName(token);
149
+
150
+ async function handleRevoke() {
151
+ try {
152
+ await revokeToken.mutateAsync({workspaceId, tokenId: token.id});
153
+ await queryClient.invalidateQueries({
154
+ queryKey: manualRegistrationTokenQueryKeys.list(workspaceId),
155
+ });
156
+ setOpen(false);
157
+ } catch {
158
+ // React Query stores the error for the inline modal alert.
159
+ }
160
+ }
161
+
162
+ function handleOpenChange(nextOpen: boolean) {
163
+ setOpen(nextOpen);
164
+ if (nextOpen) {
165
+ revokeToken.reset();
166
+ }
167
+ }
168
+
169
+ function openRevokeConfirmation() {
170
+ revokeToken.reset();
171
+ setOpen(true);
172
+ }
173
+
174
+ return (
175
+ <>
176
+ <DropdownMenu>
177
+ <DropdownMenuTrigger asChild>
178
+ <IconButton
179
+ type="button"
180
+ size="sm"
181
+ variant="transparent"
182
+ icon="more2Line"
183
+ aria-label={`Open ${tokenName} registration token actions`}
184
+ />
185
+ </DropdownMenuTrigger>
186
+ <DropdownMenuContent align="end" size="sm">
187
+ <DropdownMenuItem onSelect={openRevokeConfirmation}>Revoke token</DropdownMenuItem>
188
+ </DropdownMenuContent>
189
+ </DropdownMenu>
190
+ <Modal open={open} onOpenChange={handleOpenChange}>
191
+ <ModalContent aria-describedby={undefined} className="max-w-[420px]">
192
+ <ModalTitle className="sr-only">Revoke token</ModalTitle>
193
+ <ModalHeader title="Revoke token?" />
194
+ <ModalBody className="gap-16">
195
+ <Text size="sm" className="text-foreground-neutral-muted">
196
+ {tokenName} will stop creating new runner sessions. Existing sessions and job leases
197
+ expire on their own.
198
+ </Text>
199
+ {revokeToken.isError ? (
200
+ <Callout role="alert" type="error">
201
+ <Text size="sm">{manualRegistrationTokenErrorMessage(revokeToken.error)}</Text>
202
+ </Callout>
203
+ ) : null}
204
+ </ModalBody>
205
+ <ModalFooter>
206
+ <Button size="sm" variant="secondary" onClick={() => setOpen(false)}>
207
+ Cancel
208
+ </Button>
209
+ <Button
210
+ size="sm"
211
+ variant="danger"
212
+ isLoading={revokeToken.isPending}
213
+ onClick={handleRevoke}
214
+ >
215
+ Revoke
216
+ </Button>
217
+ </ModalFooter>
218
+ </ModalContent>
219
+ </Modal>
220
+ </>
221
+ );
222
+ }
223
+
224
+ export function EmptyManualRegistrationTokens() {
225
+ return (
226
+ <EmptyState
227
+ icon="key2Line"
228
+ title="No usable manual registration tokens"
229
+ description="Create a token to connect a runner to this workspace."
230
+ />
231
+ );
232
+ }
233
+
234
+ export function ManualRegistrationTokenTableSkeleton() {
235
+ return (
236
+ <div
237
+ role="status"
238
+ aria-label="Loading manual registration tokens"
239
+ className="flex flex-col gap-8"
240
+ >
241
+ {[0, 1, 2].map((row) => (
242
+ <Skeleton key={row} className="h-44 w-full" />
243
+ ))}
244
+ </div>
245
+ );
246
+ }
@@ -0,0 +1,251 @@
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 {WorkspaceManualRegistrationTokensSettingsSection} from './manual-registration-tokens-settings-section.js';
9
+
10
+ const RUNNERS_TEST_WORKSPACE_ID = '11111111-1111-4111-8111-111111111111';
11
+ function jsonResponse(body: unknown, init: ResponseInit = {}) {
12
+ return new Response(JSON.stringify(body), {
13
+ status: 200,
14
+ headers: {'content-type': 'application/json'},
15
+ ...init,
16
+ });
17
+ }
18
+
19
+ function manualRegistrationToken(overrides: Partial<Record<string, string | null>> = {}) {
20
+ return {
21
+ id: '33333333-3333-4333-8333-333333333333',
22
+ workspace_id: RUNNERS_TEST_WORKSPACE_ID,
23
+ prefix: 'sf_mrt_abcde',
24
+ name: 'Deploy runner',
25
+ expires_at: '2026-05-09T00:00:00.000Z',
26
+ revoked_at: null,
27
+ created_at: '2026-05-08T00:00:00.000Z',
28
+ updated_at: '2026-05-08T00:00:00.000Z',
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function renderManualRegistrationTokens(element: ReactElement) {
34
+ const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
35
+
36
+ return render(
37
+ <QueryClientProvider client={queryClient}>
38
+ {element}
39
+ <Toaster />
40
+ </QueryClientProvider>,
41
+ );
42
+ }
43
+
44
+ function firstButton(name: string): HTMLElement {
45
+ const button = screen.getAllByRole('button', {name})[0];
46
+ if (!button) throw new Error(`Button not found: ${name}`);
47
+ return button;
48
+ }
49
+
50
+ function lastButton(name: string): HTMLElement {
51
+ const buttons = screen.getAllByRole('button', {name});
52
+ const button = buttons.at(-1);
53
+ if (!button) throw new Error(`Button not found: ${name}`);
54
+ return button;
55
+ }
56
+
57
+ async function chooseTokenAction(user: ReturnType<typeof userEvent.setup>, tokenName: string) {
58
+ await user.click(firstButton(`Open ${tokenName} registration token actions`));
59
+ const menuItem = await screen.findByRole('menuitem', {name: 'Revoke token'});
60
+ expect(menuItem.querySelector('svg')).not.toBeInTheDocument();
61
+
62
+ await user.click(menuItem);
63
+ expect(await screen.findByRole('dialog')).toBeVisible();
64
+ expect(screen.getByText('Revoke token?')).toBeVisible();
65
+ }
66
+
67
+ describe('WorkspaceManualRegistrationTokensSettingsSection', () => {
68
+ test('renders an empty usable-token state', async () => {
69
+ const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({manual_registration_tokens: []}));
70
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
71
+
72
+ renderManualRegistrationTokens(
73
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
74
+ );
75
+
76
+ expect(await screen.findByText('No usable manual registration tokens')).toBeVisible();
77
+ expect(screen.getByRole('button', {name: 'Create token'})).toBeVisible();
78
+ });
79
+
80
+ test('creates a token and reveals the raw token inline once', async () => {
81
+ const user = userEvent.setup();
82
+ const fetchImpl = vi
83
+ .fn()
84
+ .mockResolvedValueOnce(jsonResponse({manual_registration_tokens: []}))
85
+ .mockResolvedValueOnce(
86
+ jsonResponse(
87
+ {
88
+ id: '44444444-4444-4444-8444-444444444444',
89
+ raw_token: 'sf_mrt_raw-created-token',
90
+ prefix: 'sf_mrt_raw-c',
91
+ name: 'Local runner',
92
+ workspace_id: RUNNERS_TEST_WORKSPACE_ID,
93
+ expires_at: '2026-05-09T00:00:00.000Z',
94
+ created_at: '2026-05-08T00:00:00.000Z',
95
+ },
96
+ {status: 201},
97
+ ),
98
+ )
99
+ .mockResolvedValueOnce(
100
+ jsonResponse({
101
+ manual_registration_tokens: [manualRegistrationToken({name: 'Local runner'})],
102
+ }),
103
+ );
104
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
105
+
106
+ renderManualRegistrationTokens(
107
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
108
+ );
109
+ await screen.findByText('No usable manual registration tokens');
110
+ await user.click(screen.getByRole('button', {name: 'Create token'}));
111
+ fireEvent.change(await screen.findByLabelText('Token name'), {
112
+ target: {value: 'Local runner'},
113
+ });
114
+ await user.click(lastButton('Create token'));
115
+
116
+ expect(await screen.findByText('Token created')).toBeVisible();
117
+ expect(screen.getByText('sf_mrt_raw-created-token')).toBeVisible();
118
+ });
119
+
120
+ test('renders compact dates with exact timestamp tooltips', async () => {
121
+ const user = userEvent.setup();
122
+ const createdAt = '2026-05-08T00:00:00.000Z';
123
+ const expiresAt = '2026-05-09T00:00:00.000Z';
124
+ const fetchImpl = vi.fn().mockResolvedValueOnce(
125
+ jsonResponse({
126
+ manual_registration_tokens: [
127
+ manualRegistrationToken({created_at: createdAt, expires_at: expiresAt}),
128
+ ],
129
+ }),
130
+ );
131
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
132
+
133
+ renderManualRegistrationTokens(
134
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
135
+ );
136
+ const expiresDate = await screen.findAllByText(formatDate(expiresAt));
137
+ const createdDate = await screen.findAllByText(formatDate(createdAt));
138
+ const expiresDateTrigger = expiresDate[0];
139
+ const createdDateTrigger = createdDate[0];
140
+ if (!expiresDateTrigger || !createdDateTrigger) throw new Error('Token dates not rendered');
141
+
142
+ expect(screen.queryByText(formatTimestamp(expiresAt))).not.toBeInTheDocument();
143
+ await user.hover(expiresDateTrigger);
144
+
145
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(formatTimestamp(expiresAt));
146
+
147
+ await user.unhover(expiresDateTrigger);
148
+ await user.hover(createdDateTrigger);
149
+
150
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(formatTimestamp(createdAt));
151
+ });
152
+
153
+ test('truncates long token names and shows the full name in a tooltip', async () => {
154
+ const user = userEvent.setup();
155
+ const tokenName = 'self-hosted-runner-for-production-release-candidate-validation-on-metal';
156
+ const fetchImpl = vi.fn().mockResolvedValueOnce(
157
+ jsonResponse({
158
+ manual_registration_tokens: [manualRegistrationToken({name: tokenName})],
159
+ }),
160
+ );
161
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
162
+
163
+ renderManualRegistrationTokens(
164
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
165
+ );
166
+ const nameTrigger = await screen.findAllByRole('button', {name: tokenName});
167
+ const visibleNameTrigger = nameTrigger[0];
168
+ if (!visibleNameTrigger) throw new Error('Token name not rendered');
169
+
170
+ expect(screen.getByRole('table')).toHaveClass('table-fixed');
171
+ expect(visibleNameTrigger).toHaveClass('truncate');
172
+ await user.hover(visibleNameTrigger);
173
+
174
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(tokenName);
175
+ });
176
+
177
+ test('surfaces create errors without clearing the form', async () => {
178
+ const user = userEvent.setup();
179
+ const fetchImpl = vi
180
+ .fn()
181
+ .mockResolvedValueOnce(jsonResponse({manual_registration_tokens: []}))
182
+ .mockResolvedValueOnce(
183
+ jsonResponse({message: 'Token quota reached', code: 'quota-reached'}, {status: 422}),
184
+ );
185
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
186
+
187
+ renderManualRegistrationTokens(
188
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
189
+ );
190
+ await screen.findByText('No usable manual registration tokens');
191
+ await user.click(screen.getByRole('button', {name: 'Create token'}));
192
+ fireEvent.change(await screen.findByLabelText('Token name'), {
193
+ target: {value: 'Local runner'},
194
+ });
195
+ await user.click(lastButton('Create token'));
196
+
197
+ expect(await screen.findByText('Could not create token')).toBeVisible();
198
+ expect(screen.getByText('Token quota reached')).toBeVisible();
199
+ expect(screen.getByLabelText('Token name')).toHaveValue('Local runner');
200
+ });
201
+
202
+ test('revokes a token after row confirmation and removes it from the list', async () => {
203
+ const user = userEvent.setup();
204
+ const fetchImpl = vi
205
+ .fn()
206
+ .mockResolvedValueOnce(
207
+ jsonResponse({manual_registration_tokens: [manualRegistrationToken()]}),
208
+ )
209
+ .mockResolvedValueOnce(
210
+ jsonResponse(manualRegistrationToken({revoked_at: '2026-05-08T01:00:00.000Z'})),
211
+ )
212
+ .mockResolvedValueOnce(jsonResponse({manual_registration_tokens: []}));
213
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
214
+
215
+ renderManualRegistrationTokens(
216
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
217
+ );
218
+ expect((await screen.findAllByText('Deploy runner')).length).toBeGreaterThan(0);
219
+ await chooseTokenAction(user, 'Deploy runner');
220
+ await user.click(lastButton('Revoke'));
221
+
222
+ await waitFor(() => expect(screen.queryByText('Deploy runner')).not.toBeInTheDocument());
223
+ expect(screen.getByText('No usable manual registration tokens')).toBeVisible();
224
+ });
225
+
226
+ test('shows a recoverable revoke error', async () => {
227
+ const user = userEvent.setup();
228
+ const fetchImpl = vi
229
+ .fn()
230
+ .mockResolvedValueOnce(
231
+ jsonResponse({manual_registration_tokens: [manualRegistrationToken()]}),
232
+ )
233
+ .mockResolvedValueOnce(
234
+ jsonResponse(
235
+ {message: 'Manual registration token not found', code: 'not-found'},
236
+ {status: 404},
237
+ ),
238
+ );
239
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
240
+
241
+ renderManualRegistrationTokens(
242
+ <WorkspaceManualRegistrationTokensSettingsSection workspaceId={RUNNERS_TEST_WORKSPACE_ID} />,
243
+ );
244
+ expect((await screen.findAllByText('Deploy runner')).length).toBeGreaterThan(0);
245
+ await chooseTokenAction(user, 'Deploy runner');
246
+ await user.click(lastButton('Revoke'));
247
+
248
+ expect(await screen.findByText('Manual registration token not found')).toBeVisible();
249
+ expect(screen.getAllByText('Deploy runner').length).toBeGreaterThan(0);
250
+ });
251
+ });
@@ -0,0 +1,110 @@
1
+ import type {CreateManualRegistrationTokenResponseDto} 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 {Header, Text} from '@shipfox/react-ui/typography';
14
+ import {useState} from 'react';
15
+ import {useManualRegistrationTokensQuery} from '#hooks/api/manual-registration-tokens.js';
16
+ import {
17
+ CreatedManualRegistrationTokenPanel,
18
+ CreateManualRegistrationTokenForm,
19
+ } from './create-manual-registration-token-form.js';
20
+ import {
21
+ EmptyManualRegistrationTokens,
22
+ ManualRegistrationTokenList,
23
+ ManualRegistrationTokenTableSkeleton,
24
+ } from './manual-registration-token-list.js';
25
+
26
+ export function WorkspaceManualRegistrationTokensSettingsSection({
27
+ workspaceId,
28
+ }: {
29
+ workspaceId: string;
30
+ }) {
31
+ const tokensQuery = useManualRegistrationTokensQuery(workspaceId);
32
+ const [isModalOpen, setIsModalOpen] = useState(false);
33
+ const [createdToken, setCreatedToken] = useState<CreateManualRegistrationTokenResponseDto | null>(
34
+ null,
35
+ );
36
+ const tokens = tokensQuery.data?.manual_registration_tokens ?? [];
37
+
38
+ function handleOpenChange(nextOpen: boolean) {
39
+ setIsModalOpen(nextOpen);
40
+ if (!nextOpen) {
41
+ setCreatedToken(null);
42
+ }
43
+ }
44
+
45
+ return (
46
+ <div className="flex min-w-0 flex-col gap-32">
47
+ <section className="flex flex-col gap-16">
48
+ <div className="flex items-center justify-between gap-16 max-[640px]:items-start">
49
+ <div className="flex flex-col gap-4">
50
+ <Header variant="h3">Runner registration tokens</Header>
51
+ <Text size="sm" className="text-foreground-neutral-muted">
52
+ Register individual runner agents that run jobs directly in this workspace. Tokens are
53
+ reusable until revoked.
54
+ </Text>
55
+ </div>
56
+ <div className="flex items-center gap-12">
57
+ {tokensQuery.isFetching && !tokensQuery.isPending ? (
58
+ <Icon
59
+ name="loader4Line"
60
+ className="mt-2 size-18 text-foreground-neutral-muted"
61
+ aria-label="Refreshing tokens"
62
+ />
63
+ ) : null}
64
+ <Modal open={isModalOpen} onOpenChange={handleOpenChange}>
65
+ <ModalTrigger asChild>
66
+ <Button>Create token</Button>
67
+ </ModalTrigger>
68
+ <ModalContent aria-describedby={undefined}>
69
+ <ModalTitle className="sr-only">Create manual registration token</ModalTitle>
70
+ <ModalHeader>
71
+ <Text
72
+ size="lg"
73
+ aria-hidden="true"
74
+ className="overflow-ellipsis overflow-hidden whitespace-nowrap"
75
+ >
76
+ Create manual registration token
77
+ </Text>
78
+ </ModalHeader>
79
+ {createdToken ? (
80
+ <ModalBody className="gap-16">
81
+ <CreatedManualRegistrationTokenPanel token={createdToken} />
82
+ </ModalBody>
83
+ ) : (
84
+ <CreateManualRegistrationTokenForm
85
+ workspaceId={workspaceId}
86
+ onCreated={setCreatedToken}
87
+ />
88
+ )}
89
+ </ModalContent>
90
+ </Modal>
91
+ </div>
92
+ </div>
93
+
94
+ {tokensQuery.isPending ? <ManualRegistrationTokenTableSkeleton /> : null}
95
+
96
+ {tokensQuery.isError && tokensQuery.data === undefined ? (
97
+ <QueryLoadError query={tokensQuery} subject="manual registration tokens" />
98
+ ) : null}
99
+
100
+ {tokensQuery.data !== undefined && tokens.length === 0 ? (
101
+ <EmptyManualRegistrationTokens />
102
+ ) : null}
103
+
104
+ {tokens.length > 0 ? (
105
+ <ManualRegistrationTokenList workspaceId={workspaceId} tokens={tokens} />
106
+ ) : null}
107
+ </section>
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,12 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export function provisionerTokenErrorMessage(error: unknown): string {
4
+ if (error instanceof ApiError) {
5
+ if (error.code === 'network-error') {
6
+ return "We couldn't reach the server. Check your connection and try again.";
7
+ }
8
+ return error.message;
9
+ }
10
+ if (error instanceof Error) return error.message;
11
+ return 'Something went wrong. Try again.';
12
+ }
@@ -0,0 +1,35 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {provisionerTokenCreateErrorToFormError} from './provisioner-token-form-errors.js';
3
+
4
+ describe('provisionerTokenCreateErrorToFormError', () => {
5
+ test('routes ApiError to a form-level alert with the server message', () => {
6
+ const error = new ApiError({code: 'rate-limited', message: 'Try later', status: 429});
7
+
8
+ const result = provisionerTokenCreateErrorToFormError(error);
9
+
10
+ expect(result).toEqual({kind: 'form', message: 'Try later'});
11
+ });
12
+
13
+ test('routes network ApiError to a form-level alert with connection copy', () => {
14
+ const error = new ApiError({code: 'network-error', message: 'Network failed', status: 0});
15
+
16
+ const result = provisionerTokenCreateErrorToFormError(error);
17
+
18
+ expect(result).toEqual({
19
+ kind: 'form',
20
+ message: "We couldn't reach the server. Check your connection and try again.",
21
+ });
22
+ });
23
+
24
+ test('routes Error to a form-level alert with the error message', () => {
25
+ const result = provisionerTokenCreateErrorToFormError(new Error('boom'));
26
+
27
+ expect(result).toEqual({kind: 'form', message: 'boom'});
28
+ });
29
+
30
+ test('falls back to generic copy for non-Error throwables', () => {
31
+ const result = provisionerTokenCreateErrorToFormError('weird');
32
+
33
+ expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
34
+ });
35
+ });
@@ -0,0 +1,9 @@
1
+ import {provisionerTokenErrorMessage} from './provisioner-token-errors.js';
2
+
3
+ export type ProvisionerTokenCreateFormErrorMapping = {kind: 'form'; message: string};
4
+
5
+ export function provisionerTokenCreateErrorToFormError(
6
+ error: unknown,
7
+ ): ProvisionerTokenCreateFormErrorMapping {
8
+ return {kind: 'form', message: provisionerTokenErrorMessage(error)};
9
+ }