@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
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@shipfox/client-runners",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/runners"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ }
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "development": {
24
+ "types": "./src/index.ts",
25
+ "default": "./src/index.ts"
26
+ },
27
+ "default": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ }
32
+ },
33
+ "dependencies": {
34
+ "@swc/helpers": "^0.5.17",
35
+ "@tanstack/react-form": "^1.32.0",
36
+ "@shipfox/api-runners-dto": "2.0.0",
37
+ "@shipfox/client-api": "0.2.0",
38
+ "@shipfox/react-ui": "0.3.1",
39
+ "@shipfox/client-ui": "0.2.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@tanstack/react-query": "^5.101.0",
43
+ "react": "^19.0.0",
44
+ "react-dom": "^19.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@argos-ci/cli": "^5.0.0",
48
+ "@argos-ci/storybook": "^6.0.0",
49
+ "@storybook/addon-vitest": "^10.3.6",
50
+ "@storybook/react": "^10.0.0",
51
+ "@storybook/react-vite": "^10.0.0",
52
+ "@tailwindcss/vite": "^4.1.13",
53
+ "@tanstack/react-query": "^5.101.0",
54
+ "@testing-library/jest-dom": "^6.6.3",
55
+ "@testing-library/react": "^16.3.2",
56
+ "@testing-library/user-event": "^14.6.1",
57
+ "@types/react": "^19.1.11",
58
+ "@types/react-dom": "^19.1.7",
59
+ "@vitejs/plugin-react": "^6.0.0",
60
+ "@vitest/browser-playwright": "^4.1.5",
61
+ "jsdom": "^29.0.0",
62
+ "react": "^19.1.1",
63
+ "react-dom": "^19.1.1",
64
+ "storybook": "^10.0.0",
65
+ "storybook-addon-pseudo-states": "^10.0.0",
66
+ "tailwindcss": "^4.1.13",
67
+ "vite": "^8.0.3",
68
+ "vitest": "^4.1.5",
69
+ "@shipfox/biome": "1.8.1",
70
+ "@shipfox/client-test-setup": "0.0.2",
71
+ "@shipfox/swc": "1.2.5",
72
+ "@shipfox/typescript": "1.1.6",
73
+ "@shipfox/vite": "1.2.4",
74
+ "@shipfox/ts-config": "1.3.8",
75
+ "@shipfox/vitest": "1.2.2"
76
+ },
77
+ "scripts": {
78
+ "build": "shipfox-swc",
79
+ "check": "shipfox-biome-check",
80
+ "check:fix": "shipfox-biome-check --write",
81
+ "storybook": "storybook dev -p 6012",
82
+ "storybook:build": "storybook build -o storybook-static",
83
+ "test": "shipfox-vitest-run",
84
+ "test:watch": "shipfox-vitest-watch",
85
+ "type": "shipfox-tsc-check",
86
+ "type:emit": "shipfox-tsc-emit"
87
+ }
88
+ }
@@ -0,0 +1,197 @@
1
+ import type {CreateManualRegistrationTokenResponseDto} from '@shipfox/api-runners-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout, CalloutContent, CalloutDescription, CalloutTitle} from '@shipfox/react-ui/callout';
4
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
5
+ import {useCopyToClipboard} from '@shipfox/react-ui/hooks';
6
+ import {ModalBody, ModalFooter} from '@shipfox/react-ui/modal';
7
+ import {Code, Text} from '@shipfox/react-ui/typography';
8
+ import {useForm} from '@tanstack/react-form';
9
+ import {useQueryClient} from '@tanstack/react-query';
10
+ import {useState} from 'react';
11
+ import {
12
+ manualRegistrationTokenQueryKeys,
13
+ useCreateManualRegistrationTokenMutation,
14
+ } from '#hooks/api/manual-registration-tokens.js';
15
+ import {manualRegistrationTokenCreateErrorToFormError} from './form-errors.js';
16
+ import {
17
+ ExpirationSelect,
18
+ expirationHint,
19
+ type TokenExpirationOption,
20
+ } from './token-expiration-select.js';
21
+
22
+ export const CREATE_MANUAL_REGISTRATION_TOKEN_FORM_ID = 'create-manual-registration-token-form';
23
+
24
+ export function CreateManualRegistrationTokenForm({
25
+ workspaceId,
26
+ onCreated,
27
+ }: {
28
+ workspaceId: string;
29
+ onCreated: (token: CreateManualRegistrationTokenResponseDto) => void;
30
+ }) {
31
+ const queryClient = useQueryClient();
32
+ const createToken = useCreateManualRegistrationTokenMutation();
33
+ const [formError, setFormError] = useState<string | undefined>();
34
+
35
+ const form = useForm({
36
+ defaultValues: {name: '', expiration: '86400' as TokenExpirationOption},
37
+ onSubmit: async ({value}) => {
38
+ setFormError(undefined);
39
+ const trimmedName = value.name.trim();
40
+ const body = {
41
+ ...(trimmedName ? {name: trimmedName} : {}),
42
+ ...(value.expiration === 'never' ? {} : {ttl_seconds: Number(value.expiration)}),
43
+ };
44
+
45
+ try {
46
+ const token = await createToken.mutateAsync({workspaceId, body});
47
+ await queryClient.invalidateQueries({
48
+ queryKey: manualRegistrationTokenQueryKeys.list(workspaceId),
49
+ });
50
+ onCreated(token);
51
+ } catch (error) {
52
+ const mapped = manualRegistrationTokenCreateErrorToFormError(error);
53
+ setFormError(mapped.message);
54
+ }
55
+ },
56
+ });
57
+
58
+ return (
59
+ <>
60
+ <ModalBody className="gap-16">
61
+ <form
62
+ id={CREATE_MANUAL_REGISTRATION_TOKEN_FORM_ID}
63
+ className="flex w-full flex-col gap-8"
64
+ noValidate
65
+ onSubmit={(event) => {
66
+ event.preventDefault();
67
+ event.stopPropagation();
68
+ void form.handleSubmit();
69
+ }}
70
+ >
71
+ <div className="flex w-full items-end gap-16 max-[640px]:flex-col max-[640px]:items-stretch">
72
+ <form.Field
73
+ name="name"
74
+ validators={{
75
+ onBlur: ({value}) =>
76
+ value.length <= 80 ? undefined : 'Token name must be 80 characters or fewer.',
77
+ }}
78
+ >
79
+ {(field) => (
80
+ <FormField
81
+ className="flex-1"
82
+ label="Token name"
83
+ id="manual-registration-token-name"
84
+ error={fieldError(field)}
85
+ >
86
+ <FormFieldInput
87
+ placeholder="Local runner"
88
+ maxLength={80}
89
+ value={field.state.value}
90
+ onChange={(event) => field.handleChange(event.target.value)}
91
+ onBlur={field.handleBlur}
92
+ />
93
+ </FormField>
94
+ )}
95
+ </form.Field>
96
+ <form.Field name="expiration">
97
+ {(field) => (
98
+ <FormField
99
+ className="flex-1"
100
+ label="Expires"
101
+ id="manual-registration-token-expiration"
102
+ error={fieldError(field)}
103
+ >
104
+ <ExpirationSelect
105
+ value={field.state.value}
106
+ onValueChange={(next) => field.handleChange(next)}
107
+ />
108
+ </FormField>
109
+ )}
110
+ </form.Field>
111
+ </div>
112
+ <form.Subscribe selector={(state) => state.values.expiration}>
113
+ {(expiration) => (
114
+ <Text size="sm" className="text-foreground-neutral-muted">
115
+ {expirationHint(expiration)}
116
+ </Text>
117
+ )}
118
+ </form.Subscribe>
119
+ </form>
120
+ {formError ? (
121
+ <Callout role="alert" type="error">
122
+ <div className="flex flex-col gap-8">
123
+ <Text size="sm" bold>
124
+ Could not create token
125
+ </Text>
126
+ <Text size="sm">{formError}</Text>
127
+ </div>
128
+ </Callout>
129
+ ) : null}
130
+ </ModalBody>
131
+ <ModalFooter>
132
+ <Button
133
+ type="submit"
134
+ form={CREATE_MANUAL_REGISTRATION_TOKEN_FORM_ID}
135
+ isLoading={createToken.isPending}
136
+ >
137
+ Create token
138
+ </Button>
139
+ </ModalFooter>
140
+ </>
141
+ );
142
+ }
143
+
144
+ export function CreatedManualRegistrationTokenPanel({
145
+ token,
146
+ }: {
147
+ token: CreateManualRegistrationTokenResponseDto;
148
+ }) {
149
+ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
150
+ const {copy} = useCopyToClipboard({
151
+ text: token.raw_token,
152
+ onCopy: () => {
153
+ setCopyState('copied');
154
+ window.setTimeout(() => setCopyState('idle'), 1500);
155
+ },
156
+ });
157
+
158
+ async function handleCopy() {
159
+ try {
160
+ await copy();
161
+ } catch {
162
+ setCopyState('failed');
163
+ window.setTimeout(() => setCopyState('idle'), 2500);
164
+ }
165
+ }
166
+
167
+ return (
168
+ <Callout type="success" variant="secondary" icon={null}>
169
+ <CalloutContent className="flex flex-col gap-12">
170
+ <div className="flex flex-col gap-2">
171
+ <CalloutTitle className="mb-0">Token created</CalloutTitle>
172
+ <CalloutDescription>
173
+ Copy this registration token now. It will not be shown again.
174
+ </CalloutDescription>
175
+ </div>
176
+ <div className="flex items-center gap-8 max-[640px]:flex-col max-[640px]:items-stretch">
177
+ <Code variant="paragraph" className="min-w-0 flex-1 break-all">
178
+ {token.raw_token}
179
+ </Code>
180
+ <Button
181
+ size="sm"
182
+ variant="secondary"
183
+ iconLeft="fileCopyLine"
184
+ onClick={() => void handleCopy()}
185
+ >
186
+ {copyState === 'copied' ? 'Copied' : 'Copy'}
187
+ </Button>
188
+ </div>
189
+ {copyState === 'failed' ? (
190
+ <Text size="sm" className="text-foreground-neutral-muted">
191
+ Copy failed: select and copy manually.
192
+ </Text>
193
+ ) : null}
194
+ </CalloutContent>
195
+ </Callout>
196
+ );
197
+ }
@@ -0,0 +1,193 @@
1
+ import type {CreateProvisionerTokenResponseDto} from '@shipfox/api-runners-dto';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout, CalloutContent, CalloutDescription, CalloutTitle} from '@shipfox/react-ui/callout';
4
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
5
+ import {useCopyToClipboard} from '@shipfox/react-ui/hooks';
6
+ import {ModalBody, ModalFooter} from '@shipfox/react-ui/modal';
7
+ import {Code, Text} from '@shipfox/react-ui/typography';
8
+ import {useForm} from '@tanstack/react-form';
9
+ import {useQueryClient} from '@tanstack/react-query';
10
+ import {useState} from 'react';
11
+ import {
12
+ provisionerTokenQueryKeys,
13
+ useCreateProvisionerTokenMutation,
14
+ } from '#hooks/api/provisioner-tokens.js';
15
+ import {provisionerTokenCreateErrorToFormError} from './provisioner-token-form-errors.js';
16
+ import {
17
+ ExpirationSelect,
18
+ expirationHint,
19
+ type TokenExpirationOption,
20
+ } from './token-expiration-select.js';
21
+
22
+ export const CREATE_PROVISIONER_TOKEN_FORM_ID = 'create-provisioner-token-form';
23
+
24
+ export function CreateProvisionerTokenForm({
25
+ workspaceId,
26
+ onCreated,
27
+ }: {
28
+ workspaceId: string;
29
+ onCreated: (token: CreateProvisionerTokenResponseDto) => void;
30
+ }) {
31
+ const queryClient = useQueryClient();
32
+ const createToken = useCreateProvisionerTokenMutation();
33
+ const [formError, setFormError] = useState<string | undefined>();
34
+
35
+ const form = useForm({
36
+ defaultValues: {name: '', expiration: '86400' as TokenExpirationOption},
37
+ onSubmit: async ({value}) => {
38
+ setFormError(undefined);
39
+ const trimmedName = value.name.trim();
40
+ const body = {
41
+ ...(trimmedName ? {name: trimmedName} : {}),
42
+ ...(value.expiration === 'never' ? {} : {ttl_seconds: Number(value.expiration)}),
43
+ };
44
+
45
+ try {
46
+ const token = await createToken.mutateAsync({workspaceId, body});
47
+ await queryClient.invalidateQueries({
48
+ queryKey: provisionerTokenQueryKeys.list(workspaceId),
49
+ });
50
+ onCreated(token);
51
+ } catch (error) {
52
+ const mapped = provisionerTokenCreateErrorToFormError(error);
53
+ setFormError(mapped.message);
54
+ }
55
+ },
56
+ });
57
+
58
+ return (
59
+ <>
60
+ <ModalBody className="gap-16">
61
+ <form
62
+ id={CREATE_PROVISIONER_TOKEN_FORM_ID}
63
+ className="flex w-full flex-col gap-8"
64
+ noValidate
65
+ onSubmit={(event) => {
66
+ event.preventDefault();
67
+ event.stopPropagation();
68
+ void form.handleSubmit();
69
+ }}
70
+ >
71
+ <div className="flex w-full items-end gap-16 max-[640px]:flex-col max-[640px]:items-stretch">
72
+ <form.Field
73
+ name="name"
74
+ validators={{
75
+ onBlur: ({value}) =>
76
+ value.length <= 80 ? undefined : 'Token name must be 80 characters or fewer.',
77
+ }}
78
+ >
79
+ {(field) => (
80
+ <FormField
81
+ className="flex-1"
82
+ label="Token name"
83
+ id="provisioner-token-name"
84
+ error={fieldError(field)}
85
+ >
86
+ <FormFieldInput
87
+ placeholder="Docker provisioner"
88
+ maxLength={80}
89
+ value={field.state.value}
90
+ onChange={(event) => field.handleChange(event.target.value)}
91
+ onBlur={field.handleBlur}
92
+ />
93
+ </FormField>
94
+ )}
95
+ </form.Field>
96
+ <form.Field name="expiration">
97
+ {(field) => (
98
+ <FormField
99
+ className="flex-1"
100
+ label="Expires"
101
+ id="provisioner-token-expiration"
102
+ error={fieldError(field)}
103
+ >
104
+ <ExpirationSelect
105
+ value={field.state.value}
106
+ onValueChange={(next) => field.handleChange(next)}
107
+ />
108
+ </FormField>
109
+ )}
110
+ </form.Field>
111
+ </div>
112
+ <form.Subscribe selector={(state) => state.values.expiration}>
113
+ {(expiration) => (
114
+ <Text size="sm" className="text-foreground-neutral-muted">
115
+ {expirationHint(expiration)}
116
+ </Text>
117
+ )}
118
+ </form.Subscribe>
119
+ </form>
120
+ {formError ? (
121
+ <Callout role="alert" type="error">
122
+ <div className="flex flex-col gap-8">
123
+ <Text size="sm" bold>
124
+ Could not create token
125
+ </Text>
126
+ <Text size="sm">{formError}</Text>
127
+ </div>
128
+ </Callout>
129
+ ) : null}
130
+ </ModalBody>
131
+ <ModalFooter>
132
+ <Button
133
+ type="submit"
134
+ form={CREATE_PROVISIONER_TOKEN_FORM_ID}
135
+ isLoading={createToken.isPending}
136
+ >
137
+ Create token
138
+ </Button>
139
+ </ModalFooter>
140
+ </>
141
+ );
142
+ }
143
+
144
+ export function CreatedProvisionerTokenPanel({token}: {token: CreateProvisionerTokenResponseDto}) {
145
+ const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle');
146
+ const {copy} = useCopyToClipboard({
147
+ text: token.raw_token,
148
+ onCopy: () => {
149
+ setCopyState('copied');
150
+ window.setTimeout(() => setCopyState('idle'), 1500);
151
+ },
152
+ });
153
+
154
+ async function handleCopy() {
155
+ try {
156
+ await copy();
157
+ } catch {
158
+ setCopyState('failed');
159
+ window.setTimeout(() => setCopyState('idle'), 2500);
160
+ }
161
+ }
162
+
163
+ return (
164
+ <Callout type="success" variant="secondary" icon={null}>
165
+ <CalloutContent className="flex flex-col gap-12">
166
+ <div className="flex flex-col gap-2">
167
+ <CalloutTitle className="mb-0">Token created</CalloutTitle>
168
+ <CalloutDescription>
169
+ Copy this provisioner token now. It will not be shown again.
170
+ </CalloutDescription>
171
+ </div>
172
+ <div className="flex items-center gap-8 max-[640px]:flex-col max-[640px]:items-stretch">
173
+ <Code variant="paragraph" className="min-w-0 flex-1 break-all">
174
+ {token.raw_token}
175
+ </Code>
176
+ <Button
177
+ size="sm"
178
+ variant="secondary"
179
+ iconLeft="fileCopyLine"
180
+ onClick={() => void handleCopy()}
181
+ >
182
+ {copyState === 'copied' ? 'Copied' : 'Copy'}
183
+ </Button>
184
+ </div>
185
+ {copyState === 'failed' ? (
186
+ <Text size="sm" className="text-foreground-neutral-muted">
187
+ Copy failed: select and copy manually.
188
+ </Text>
189
+ ) : null}
190
+ </CalloutContent>
191
+ </Callout>
192
+ );
193
+ }
@@ -0,0 +1,24 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+ import {manualRegistrationTokenCreateErrorToFormError} from './form-errors.js';
3
+
4
+ describe('manualRegistrationTokenCreateErrorToFormError', () => {
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 = manualRegistrationTokenCreateErrorToFormError(error);
9
+
10
+ expect(result).toEqual({kind: 'form', message: 'Try later'});
11
+ });
12
+
13
+ test('routes Error to a form-level alert with the error message', () => {
14
+ const result = manualRegistrationTokenCreateErrorToFormError(new Error('boom'));
15
+
16
+ expect(result).toEqual({kind: 'form', message: 'boom'});
17
+ });
18
+
19
+ test('falls back to generic copy for non-Error throwables', () => {
20
+ const result = manualRegistrationTokenCreateErrorToFormError('weird');
21
+
22
+ expect(result).toEqual({kind: 'form', message: 'Something went wrong. Try again.'});
23
+ });
24
+ });
@@ -0,0 +1,9 @@
1
+ import {manualRegistrationTokenErrorMessage} from './manual-registration-token-errors.js';
2
+
3
+ export type ManualRegistrationTokenCreateFormErrorMapping = {kind: 'form'; message: string};
4
+
5
+ export function manualRegistrationTokenCreateErrorToFormError(
6
+ error: unknown,
7
+ ): ManualRegistrationTokenCreateFormErrorMapping {
8
+ return {kind: 'form', message: manualRegistrationTokenErrorMessage(error)};
9
+ }
@@ -0,0 +1,13 @@
1
+ import {ApiError} from '@shipfox/client-api';
2
+
3
+ export function manualRegistrationTokenErrorMessage(error: unknown): string {
4
+ if (error instanceof ApiError) {
5
+ // `network-error` carries the raw request URL in its message; never surface it.
6
+ if (error.code === 'network-error') {
7
+ return "We couldn't reach the server. Check your connection and try again.";
8
+ }
9
+ return error.message;
10
+ }
11
+ if (error instanceof Error) return error.message;
12
+ return 'Something went wrong. Try again.';
13
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ formatManualRegistrationTokenDate,
3
+ formatManualRegistrationTokenTimestamp,
4
+ manualRegistrationTokenDisplayName,
5
+ } from './manual-registration-token-format.js';
6
+
7
+ describe('manualRegistrationTokenDisplayName', () => {
8
+ test('uses the token name when present', () => {
9
+ const result = manualRegistrationTokenDisplayName({name: 'Deploy runner'});
10
+
11
+ expect(result).toBe('Deploy runner');
12
+ });
13
+
14
+ test('falls back for unnamed tokens', () => {
15
+ const result = manualRegistrationTokenDisplayName({name: null});
16
+
17
+ expect(result).toBe('Unnamed token');
18
+ });
19
+ });
20
+
21
+ describe('formatManualRegistrationTokenDate', () => {
22
+ test('formats timestamps as dates', () => {
23
+ const result = formatManualRegistrationTokenDate('2026-05-08T00:00:00.000Z');
24
+
25
+ expect(result).not.toBe('Never');
26
+ expect(result).not.toContain(':');
27
+ });
28
+
29
+ test('formats null as never', () => {
30
+ const result = formatManualRegistrationTokenDate(null);
31
+
32
+ expect(result).toBe('Never');
33
+ });
34
+ });
35
+
36
+ describe('formatManualRegistrationTokenTimestamp', () => {
37
+ test('formats timestamps with time', () => {
38
+ const result = formatManualRegistrationTokenTimestamp('2026-05-08T00:00:00.000Z');
39
+
40
+ expect(result).toContain(':');
41
+ });
42
+
43
+ test('formats null as undefined', () => {
44
+ const result = formatManualRegistrationTokenTimestamp(null);
45
+
46
+ expect(result).toBeUndefined();
47
+ });
48
+ });
@@ -0,0 +1,18 @@
1
+ import type {ManualRegistrationTokenDto} from '@shipfox/api-runners-dto';
2
+ import {formatDate, formatTimestamp} from '@shipfox/react-ui/utils';
3
+
4
+ export function formatManualRegistrationTokenDate(value: string | null): string {
5
+ if (!value) return 'Never';
6
+ return formatDate(value);
7
+ }
8
+
9
+ export function formatManualRegistrationTokenTimestamp(value: string | null): string | undefined {
10
+ if (!value) return undefined;
11
+ return formatTimestamp(value);
12
+ }
13
+
14
+ export function manualRegistrationTokenDisplayName(
15
+ token: Pick<ManualRegistrationTokenDto, 'name'>,
16
+ ): string {
17
+ return token.name || 'Unnamed token';
18
+ }