@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
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
|
+
}
|