Skip to content

Commit efa3978

Browse files
Instance custom SSH key select (#1867)
1 parent 4bfadc0 commit efa3978

File tree

14 files changed

+283
-81
lines changed

14 files changed

+283
-81
lines changed

OMICRON_VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
e261a960cb365ad92f103a35b262713118ea6441
1+
6491841457ad74ac340072365a12661cc460c343
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useState } from 'react'
9+
import { useController, type Control } from 'react-hook-form'
10+
11+
import { usePrefetchedApiQuery } from '@oxide/api'
12+
import {
13+
Button,
14+
Checkbox,
15+
Divider,
16+
EmptyMessage,
17+
FieldLabel,
18+
Key16Icon,
19+
Message,
20+
TextInputHint,
21+
} from '@oxide/ui'
22+
23+
import type { InstanceCreateInput } from 'app/forms/instance-create'
24+
import { CreateSSHKeySideModalForm } from 'app/forms/ssh-key-create'
25+
26+
import { CheckboxField } from './CheckboxField'
27+
import { ErrorMessage } from './ErrorMessage'
28+
29+
// todo: keep this in sync with the limit set in the control plane
30+
const MAX_KEYS_PER_INSTANCE = 100
31+
32+
const CloudInitMessage = () => (
33+
<Message
34+
variant="notice"
35+
className="mt-4"
36+
content={
37+
<>
38+
If your image supports the cidata volume and{' '}
39+
<a
40+
target="_blank"
41+
href="https://cloudinit.readthedocs.io/en/latest/"
42+
rel="noreferrer"
43+
>
44+
cloud-init
45+
</a>
46+
, the keys above will be added to your instance. Keys are added when the instance is
47+
created and are not updated after instance launch.
48+
</>
49+
}
50+
/>
51+
)
52+
53+
export function SshKeysField({ control }: { control: Control<InstanceCreateInput> }) {
54+
const keys = usePrefetchedApiQuery('currentUserSshKeyList', {}).data?.items || []
55+
const [showAddSshKey, setShowAddSshKey] = useState(false)
56+
57+
const {
58+
field: { value, onChange },
59+
fieldState: { error },
60+
} = useController({
61+
control,
62+
name: 'sshPublicKeys',
63+
rules: {
64+
validate(keys) {
65+
if (keys.length > MAX_KEYS_PER_INSTANCE) {
66+
return `An instance supports a maximum of ${MAX_KEYS_PER_INSTANCE} SSH keys`
67+
}
68+
},
69+
},
70+
})
71+
72+
return (
73+
<div className="max-w-lg">
74+
<div className="mb-2">
75+
<FieldLabel id="ssh-keys-label">SSH keys</FieldLabel>
76+
<TextInputHint id="ssh-keys-help-text">
77+
SSH keys can be added and removed in your user settings
78+
</TextInputHint>
79+
</div>
80+
{keys.length > 0 ? (
81+
<>
82+
<div className="space-y-2">
83+
<div className="flex flex-col space-y-2">
84+
{keys.map((key) => (
85+
<CheckboxField
86+
name="sshPublicKeys"
87+
control={control}
88+
value={key.id}
89+
key={key.id}
90+
>
91+
{key.name}
92+
</CheckboxField>
93+
))}
94+
</div>
95+
96+
<ErrorMessage error={error} label="SSH keys" />
97+
98+
<Divider />
99+
<Checkbox
100+
checked={value.length === keys.length}
101+
indeterminate={value.length > 0 && value.length < keys.length}
102+
// if fewer than all are checked, check all. if all are checked, check none
103+
onChange={() =>
104+
onChange(value.length < keys.length ? keys.map((key) => key.id) : [])
105+
}
106+
>
107+
<span className="select-none">Select all</span>
108+
</Checkbox>
109+
110+
<div className="space-x-3">
111+
<Button variant="ghost" size="sm" onClick={() => setShowAddSshKey(true)}>
112+
Add SSH Key
113+
</Button>
114+
</div>
115+
</div>
116+
<CloudInitMessage />
117+
</>
118+
) : (
119+
<div className="mt-4 flex max-w-lg items-center justify-center rounded-lg border p-6 border-default">
120+
<EmptyMessage
121+
icon={<Key16Icon />}
122+
title="No SSH keys"
123+
body="You need to add a SSH key to be able to see it here"
124+
/>
125+
</div>
126+
)}
127+
{showAddSshKey && (
128+
<CreateSSHKeySideModalForm
129+
onDismiss={() => setShowAddSshKey(false)}
130+
message={
131+
<Message
132+
variant="info"
133+
content="SSH keys added here are permanently associated with your profile, and will be available for future use"
134+
/>
135+
}
136+
/>
137+
)}
138+
</div>
139+
)
140+
}

app/components/form/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@ export * from './fields/DateTimeRangePicker'
1515
export * from './fields/DescriptionField'
1616
export * from './fields/DiskSizeField'
1717
export * from './fields/DisksTableField'
18+
export * from './fields/FileField'
1819
export * from './fields/ImageSelectField'
1920
export * from './fields/ListboxField'
2021
export * from './fields/NameField'
2122
export * from './fields/NetworkInterfaceField'
2223
export * from './fields/NumberField'
2324
export * from './fields/RadioField'
25+
export * from './fields/SshKeysField'
2426
export * from './fields/SubnetListbox'
2527
export * from './fields/TextField'
2628
export * from './fields/TlsCertsField'
27-
export * from './fields/FileField'

app/forms/instance-create.tsx

Lines changed: 13 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import * as Accordion from '@radix-ui/react-accordion'
99
import cn from 'classnames'
10-
import { useEffect, useRef, useState } from 'react'
10+
import { useEffect, useMemo, useRef, useState } from 'react'
1111
import { useWatch, type Control } from 'react-hook-form'
1212
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1313
import type { SetRequired } from 'type-fest'
@@ -25,19 +25,15 @@ import {
2525
import {
2626
DirectionRightIcon,
2727
EmptyMessage,
28-
FieldLabel,
2928
FormDivider,
3029
Images16Icon,
3130
Instances24Icon,
32-
Key16Icon,
3331
Message,
3432
RadioCard,
35-
Table,
3633
Tabs,
3734
TextInputHint,
38-
Truncate,
3935
} from '@oxide/ui'
40-
import { formatDateTime, GiB, invariant } from '@oxide/util'
36+
import { GiB, invariant } from '@oxide/util'
4137

4238
import {
4339
CheckboxField,
@@ -51,6 +47,7 @@ import {
5147
NameField,
5248
NetworkInterfaceField,
5349
RadioFieldDyn,
50+
SshKeysField,
5451
TextField,
5552
type DiskTableItem,
5653
} from 'app/components/form'
@@ -69,6 +66,8 @@ export type InstanceCreateInput = Assign<
6966
bootDiskSize: number
7067
image: string
7168
userData: File | null
69+
// ssh keys are always specified. we do not need the undefined case
70+
sshPublicKeys: NonNullable<InstanceCreate['sshPublicKeys']>
7271
}
7372
>
7473

@@ -91,6 +90,8 @@ const baseDefaultValues: InstanceCreateInput = {
9190
disks: [],
9291
networkInterfaces: { type: 'default' },
9392

93+
sshPublicKeys: [],
94+
9495
start: true,
9596

9697
userData: null,
@@ -135,9 +136,13 @@ export function CreateInstanceForm() {
135136

136137
const defaultImage = allImages[0]
137138

139+
const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
140+
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])
141+
138142
const defaultValues: InstanceCreateInput = {
139143
...baseDefaultValues,
140144
image: defaultImage?.id || '',
145+
sshPublicKeys: allKeys,
141146
// Use 2x the image size as the default boot disk size
142147
bootDiskSize: Math.ceil(defaultImage?.size / GiB) * 2 || 10,
143148
}
@@ -210,6 +215,7 @@ export function CreateInstanceForm() {
210215
externalIps: [{ type: 'ephemeral' }],
211216
start: values.start,
212217
networkInterfaces: values.networkInterfaces,
218+
sshPublicKeys: values.sshPublicKeys,
213219
userData,
214220
},
215221
})
@@ -417,7 +423,7 @@ export function CreateInstanceForm() {
417423
<FormDivider />
418424
<Form.Heading id="authentication">Authentication</Form.Heading>
419425

420-
<SshKeysTable />
426+
<SshKeysField control={control} />
421427

422428
<FormDivider />
423429
<Form.Heading id="advanced">Advanced</Form.Heading>
@@ -517,70 +523,6 @@ function AccordionItem({ value, label, children, isOpen }: AccordionItemProps) {
517523
)
518524
}
519525

520-
const SshKeysTable = () => {
521-
const keys = usePrefetchedApiQuery('currentUserSshKeyList', {}).data?.items || []
522-
523-
return (
524-
<div className="max-w-lg">
525-
<div className="mb-2">
526-
<FieldLabel id="ssh-keys-label">SSH keys</FieldLabel>
527-
<TextInputHint id="ssh-keys-label-help-text">
528-
SSH keys can be added and removed in your user settings
529-
</TextInputHint>
530-
</div>
531-
532-
{keys.length > 0 ? (
533-
<Table className="w-full">
534-
<Table.Header>
535-
<Table.HeaderRow>
536-
<Table.HeadCell>Name</Table.HeadCell>
537-
<Table.HeadCell>Created</Table.HeadCell>
538-
</Table.HeaderRow>
539-
</Table.Header>
540-
<Table.Body>
541-
{keys.map((key) => (
542-
<Table.Row key={key.id}>
543-
<Table.Cell height="auto">
544-
<Truncate text={key.name} maxLength={28} />
545-
</Table.Cell>
546-
<Table.Cell height="auto" className="text-secondary">
547-
{formatDateTime(key.timeCreated)}
548-
</Table.Cell>
549-
</Table.Row>
550-
))}
551-
</Table.Body>
552-
</Table>
553-
) : (
554-
<div className="mb-4 flex max-w-lg items-center justify-center rounded-lg border p-6 border-default">
555-
<EmptyMessage
556-
icon={<Key16Icon />}
557-
title="No SSH keys"
558-
body="You need to add a SSH key to be able to see it here"
559-
/>
560-
</div>
561-
)}
562-
563-
<Message
564-
variant="notice"
565-
content={
566-
<>
567-
If your image supports the cidata volume and{' '}
568-
<a
569-
target="_blank"
570-
href="https://cloudinit.readthedocs.io/en/latest/"
571-
rel="noreferrer"
572-
>
573-
cloud-init
574-
</a>
575-
, the keys above will be added to your instance. Keys are added when the
576-
instance is created and are not updated after instance launch.
577-
</>
578-
}
579-
/>
580-
</div>
581-
)
582-
}
583-
584526
const renderLargeRadioCards = (category: string) => {
585527
return PRESETS.filter((option) => option.category === category).map((option) => (
586528
<RadioCard key={option.id} value={option.id}>

app/forms/ssh-key-create.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,22 @@ const defaultValues: SshKeyCreate = {
1919
publicKey: '',
2020
}
2121

22-
export function CreateSSHKeySideModalForm() {
22+
export function CreateSSHKeySideModalForm({
23+
onDismiss,
24+
message,
25+
}: {
26+
onDismiss?: () => void
27+
message?: React.ReactNode
28+
}) {
2329
const queryClient = useApiQueryClient()
2430
const navigate = useNavigate()
2531

26-
const onDismiss = () => navigate(pb.sshKeys())
32+
const handleDismiss = onDismiss ? onDismiss : () => navigate(pb.sshKeys())
2733

2834
const createSshKey = useApiMutation('currentUserSshKeyCreate', {
2935
onSuccess() {
3036
queryClient.invalidateQueries('currentUserSshKeyList')
31-
onDismiss()
37+
handleDismiss()
3238
},
3339
})
3440
const form = useForm({ defaultValues })
@@ -38,7 +44,7 @@ export function CreateSSHKeySideModalForm() {
3844
id="create-ssh-key-form"
3945
title="Add SSH key"
4046
form={form}
41-
onDismiss={onDismiss}
47+
onDismiss={handleDismiss}
4248
onSubmit={(body) => createSshKey.mutate({ body })}
4349
loading={createSshKey.isPending}
4450
submitError={createSshKey.error}
@@ -53,6 +59,7 @@ export function CreateSSHKeySideModalForm() {
5359
rows={8}
5460
control={form.control}
5561
/>
62+
{message}
5663
</SideModalForm>
5764
)
5865
}

app/test/e2e/instance-create.e2e.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,31 @@ test('with disk name already taken', async ({ page }) => {
189189
await page.getByRole('button', { name: 'Create instance' }).click()
190190
await expectVisible(page, ['text=Disk name already exists'])
191191
})
192+
193+
test('add ssh key from instance create form', async ({ page }) => {
194+
await page.goto('/projects/mock-project/instances-new')
195+
196+
await expect(page.getByRole('checkbox', { name: 'm1-macbook-pro' })).toBeChecked()
197+
await expect(page.getByRole('checkbox', { name: 'mac-mini' })).toBeChecked()
198+
199+
const newKey = 'new-key'
200+
const newCheckbox = page.getByRole('checkbox', { name: newKey })
201+
await expect(newCheckbox).toBeHidden()
202+
203+
// open model, fill form, and submit
204+
const dialog = page.getByRole('dialog')
205+
await page.getByRole('button', { name: 'Add SSH Key' }).click()
206+
await dialog.getByRole('textbox', { name: 'Name' }).fill(newKey)
207+
await dialog.getByRole('textbox', { name: 'Description' }).fill('hi')
208+
await dialog.getByRole('textbox', { name: 'Public key' }).fill('some stuff, whatever')
209+
await dialog.getByRole('button', { name: 'Add SSH Key' }).click()
210+
211+
await expect(newCheckbox).toBeVisible()
212+
await expect(newCheckbox).not.toBeChecked()
213+
214+
// pop over to the real SSH keys page and see it there, why not
215+
await page.getByLabel('User menu').click()
216+
await page.getByRole('menuitem', { name: 'Settings' }).click()
217+
await page.getByRole('link', { name: 'SSH Keys' }).click()
218+
await expectRowVisible(page.getByRole('table'), { Name: newKey, Description: 'hi' })
219+
})

0 commit comments

Comments
 (0)