Skip to content

Commit bdbc02b

Browse files
benjaminleonarddavid-crespogithub-actions[bot]
authored
Add view/edit SSH key page (#2589)
* Add view/edit SSH key page * Tweak `expectVisible` to take container * Add copy button * Add copy in actions menu * Revert "Tweak `expectVisible` to take container" This reverts commit c7d3c56. * Update ssh-keys.e2e.ts * rename form to "View SSH key" * don't use stringy locators and expectVisible * Bot commit: format with prettier --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com> Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 7a8ee0a commit bdbc02b

File tree

10 files changed

+171
-29
lines changed

10 files changed

+171
-29
lines changed

app/forms/ssh-key-edit.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
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 { useForm } from 'react-hook-form'
9+
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
10+
11+
import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
12+
import { Key16Icon } from '@oxide/design-system/icons/react'
13+
14+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
15+
import { NameField } from '~/components/form/fields/NameField'
16+
import { TextField } from '~/components/form/fields/TextField'
17+
import { SideModalForm } from '~/components/form/SideModalForm'
18+
import { getSshKeySelector, useSshKeySelector } from '~/hooks/use-params'
19+
import { CopyToClipboard } from '~/ui/lib/CopyToClipboard'
20+
import { DateTime } from '~/ui/lib/DateTime'
21+
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
22+
import { ResourceLabel } from '~/ui/lib/SideModal'
23+
import { Truncate } from '~/ui/lib/Truncate'
24+
import { pb } from '~/util/path-builder'
25+
26+
EditSSHKeySideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
27+
const { sshKey } = getSshKeySelector(params)
28+
await apiQueryClient.prefetchQuery('currentUserSshKeyView', { path: { sshKey } })
29+
return null
30+
}
31+
32+
export function EditSSHKeySideModalForm() {
33+
const navigate = useNavigate()
34+
const { sshKey } = useSshKeySelector()
35+
36+
const { data } = usePrefetchedApiQuery('currentUserSshKeyView', {
37+
path: { sshKey },
38+
})
39+
40+
const form = useForm({ defaultValues: data })
41+
42+
return (
43+
<SideModalForm
44+
form={form}
45+
formType="edit"
46+
resourceName="SSH key"
47+
title="View SSH key"
48+
onDismiss={() => navigate(pb.sshKeys())}
49+
subtitle={
50+
<ResourceLabel>
51+
<Key16Icon /> {data.name}
52+
</ResourceLabel>
53+
}
54+
// TODO: pass actual error when this form is hooked up
55+
loading={false}
56+
submitError={null}
57+
>
58+
<PropertiesTable>
59+
<PropertiesTable.Row label="ID">
60+
<Truncate text={data.id} maxLength={32} hasCopyButton />
61+
</PropertiesTable.Row>
62+
<PropertiesTable.Row label="Created">
63+
<DateTime date={data.timeCreated} />
64+
</PropertiesTable.Row>
65+
<PropertiesTable.Row label="Updated">
66+
<DateTime date={data.timeModified} />
67+
</PropertiesTable.Row>
68+
</PropertiesTable>
69+
<NameField name="name" control={form.control} disabled />
70+
<DescriptionField name="description" control={form.control} disabled />
71+
<div className="relative">
72+
<CopyToClipboard className="!absolute right-0 top-0" text={data.publicKey} />
73+
<TextField
74+
as="textarea"
75+
name="publicKey"
76+
label="Public key"
77+
required
78+
rows={8}
79+
control={form.control}
80+
disabled
81+
/>
82+
</div>
83+
</SideModalForm>
84+
)
85+
}

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const getVpcRouterRouteSelector = requireParams('project', 'vpc', 'router
4242
export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet')
4343
export const getSiloSelector = requireParams('silo')
4444
export const getSiloImageSelector = requireParams('image')
45+
export const getSshKeySelector = requireParams('sshKey')
4546
export const getIdpSelector = requireParams('silo', 'provider')
4647
export const getProjectImageSelector = requireParams('project', 'image')
4748
export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
@@ -77,6 +78,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
7778
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
7879
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
7980
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)
81+
export const useSshKeySelector = () => useSelectedParams(getSshKeySelector)
8082
export const useProjectSnapshotSelector = () =>
8183
useSelectedParams(getProjectSnapshotSelector)
8284
export const useInstanceSelector = () => useSelectedParams(getInstanceSelector)

app/pages/settings/SSHKeysPage.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { createColumnHelper } from '@tanstack/react-table'
9-
import { useCallback } from 'react'
9+
import { useCallback, useMemo } from 'react'
1010
import { Link, Outlet, useNavigate } from 'react-router-dom'
1111

1212
import {
@@ -22,7 +22,8 @@ import { DocsPopover } from '~/components/DocsPopover'
2222
import { HL } from '~/components/HL'
2323
import { confirmDelete } from '~/stores/confirm-delete'
2424
import { addToast } from '~/stores/toast'
25-
import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
25+
import { makeLinkCell } from '~/table/cells/LinkCell'
26+
import { getActionsCol, type MenuAction } from '~/table/columns/action-col'
2627
import { Columns } from '~/table/columns/common'
2728
import { useQueryTable } from '~/table/QueryTable'
2829
import { buttonStyle } from '~/ui/lib/Button'
@@ -39,11 +40,6 @@ export async function loader() {
3940
}
4041

4142
const colHelper = createColumnHelper<SshKey>()
42-
const staticCols = [
43-
colHelper.accessor('name', {}),
44-
colHelper.accessor('description', Columns.description),
45-
colHelper.accessor('timeModified', Columns.timeModified),
46-
]
4743

4844
Component.displayName = 'SSHKeysPage'
4945
export function Component() {
@@ -60,6 +56,12 @@ export function Component() {
6056

6157
const makeActions = useCallback(
6258
(sshKey: SshKey): MenuAction[] => [
59+
{
60+
label: 'Copy public key',
61+
onActivate() {
62+
window.navigator.clipboard.writeText(sshKey.publicKey)
63+
},
64+
},
6365
{
6466
label: 'Delete',
6567
onActivate: confirmDelete({
@@ -71,6 +73,16 @@ export function Component() {
7173
[deleteSshKey]
7274
)
7375

76+
const columns = useMemo(() => {
77+
return [
78+
colHelper.accessor('name', {
79+
cell: makeLinkCell((sshKey) => pb.sshKeyEdit({ sshKey: sshKey })),
80+
}),
81+
colHelper.accessor('description', Columns.description),
82+
getActionsCol(makeActions),
83+
]
84+
}, [makeActions])
85+
7486
const emptyState = (
7587
<EmptyMessage
7688
icon={<Key16Icon />}
@@ -80,7 +92,6 @@ export function Component() {
8092
onClick={() => navigate(pb.sshKeysNew())}
8193
/>
8294
)
83-
const columns = useColsWithActions(staticCols, makeActions)
8495
const { table } = useQueryTable({ query: sshKeyList(), columns, emptyState })
8596

8697
return (

app/routes.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { EditProjectSideModalForm } from './forms/project-edit'
2828
import { CreateSiloSideModalForm } from './forms/silo-create'
2929
import * as SnapshotCreate from './forms/snapshot-create'
3030
import * as SSHKeyCreate from './forms/ssh-key-create'
31+
import { EditSSHKeySideModalForm } from './forms/ssh-key-edit'
3132
import { CreateSubnetForm } from './forms/subnet-create'
3233
import { EditSubnetForm } from './forms/subnet-edit'
3334
import { CreateVpcSideModalForm } from './forms/vpc-create'
@@ -118,7 +119,14 @@ export const routes = createRoutesFromElements(
118119
<Route index element={<Navigate to="profile" replace />} />
119120
<Route path="profile" element={<ProfilePage />} handle={{ crumb: 'Profile' }} />
120121
<Route {...SSHKeysPage} handle={makeCrumb('SSH Keys', pb.sshKeys)}>
121-
<Route path="ssh-keys" element={null} />
122+
<Route path="ssh-keys" element={null}>
123+
<Route
124+
path=":sshKey/edit"
125+
loader={EditSSHKeySideModalForm.loader}
126+
element={<EditSSHKeySideModalForm />}
127+
handle={titleCrumb('View SSH Key')}
128+
/>
129+
</Route>
122130
<Route path="ssh-keys-new" {...SSHKeyCreate} handle={titleCrumb('New SSH key')} />
123131
</Route>
124132
</Route>

app/util/__snapshots__/path-builder.spec.ts.snap

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,16 @@ exports[`breadcrumbs 2`] = `
551551
"path": "/projects/p/snapshots",
552552
},
553553
],
554+
"sshKeyEdit (/settings/ssh-keys/ss/edit)": [
555+
{
556+
"label": "Settings",
557+
"path": "/settings/profile",
558+
},
559+
{
560+
"label": "SSH Keys",
561+
"path": "/settings/ssh-keys",
562+
},
563+
],
554564
"sshKeys (/settings/ssh-keys)": [
555565
{
556566
"label": "Settings",

app/util/path-builder.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const params = {
2525
provider: 'pr',
2626
sledId: '5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0',
2727
image: 'im',
28+
sshKey: 'ss',
2829
snapshot: 'sn',
2930
pool: 'pl',
3031
rule: 'fr',
@@ -82,6 +83,7 @@ test('path builder', () => {
8283
"snapshotImagesNew": "/projects/p/snapshots/sn/images-new",
8384
"snapshots": "/projects/p/snapshots",
8485
"snapshotsNew": "/projects/p/snapshots-new",
86+
"sshKeyEdit": "/settings/ssh-keys/ss/edit",
8587
"sshKeys": "/settings/ssh-keys",
8688
"sshKeysNew": "/settings/ssh-keys-new",
8789
"systemUtilization": "/system/utilization",

app/util/path-builder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export const pb = {
112112
profile: () => '/settings/profile',
113113
sshKeys: () => '/settings/ssh-keys',
114114
sshKeysNew: () => '/settings/ssh-keys-new',
115+
sshKeyEdit: (params: PP.SshKey) => `/settings/ssh-keys/${params.sshKey}/edit`,
115116

116117
deviceSuccess: () => '/device/success',
117118
}

app/util/path-params.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export type FirewallRule = Required<Sel.FirewallRule>
2424
export type VpcRouter = Required<Sel.VpcRouter>
2525
export type VpcRouterRoute = Required<Sel.VpcRouterRoute>
2626
export type VpcSubnet = Required<Sel.VpcSubnet>
27+
export type SshKey = Required<Sel.SshKey>

mock-api/sshKeys.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const sshKeys: Json<SshKey>[] = [
1717
description: 'For use on personal projects',
1818
time_created: new Date().toISOString(),
1919
time_modified: new Date().toISOString(),
20-
public_key: 'aslsddlfkjsdlfkjsdlfkjsdlfkjsdflkjsdlfkjsdlfkjsd',
20+
public_key:
21+
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDU3w4FaSj/tEZYaoBtzijAFZanW9MakaPhSERtdC75opT6F/bs4ZXE8sjWgqDM1azoZbUKa42b4RWPPtCgqGQkbyYDZTzdssrml3/T1Avcy5GKlfTjACRHSI6PhC6r6bM1jxPUUstH7fBbw+DTHywUpdkvz7SHxTEOyZuP2sn38V9vBakYVsLFOu7C1W0+Jm4TYCRJlcsuC5LHVMVc4WbWzBcAZZlAznWx0XajMxmkyCB5tsyhTpykabfHbih4F3bwHYKXO613JZ6DurGcPz6CPkAVS5BWG6GrdBCkd+YK8Lw8k1oAAZLYIKQZbMnPJSNxirJ8+vr+iyIwP1DjBMnJ hannah@m1-macbook-pro.local',
2122
silo_user_id: user1.id,
2223
},
2324
{
@@ -26,7 +27,8 @@ export const sshKeys: Json<SshKey>[] = [
2627
description: '',
2728
time_created: new Date().toISOString(),
2829
time_modified: new Date().toISOString(),
29-
public_key: 'aslsddlfkjsdlfkjsdlfkjsdlfkjsdflkjsdlfkjsdlfkjsd',
30+
public_key:
31+
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDVav/u9wm2ALv4ks18twWQB0LHlJ1Q1y7HTi91SUuv4H95EEwqj6tVDSOtHQi08PG7xp6/8gaMVC9rs1jKl7o0cy32kuWp/rXtryn3d1bEaY9wOGwR6iokx0zjocHILhrjHpAmWnXP8oWvzx8TWOg3VPhBkZsyNdqzcdxYP2UsqccaNyz5kcuNhOYbGjIskNPAk1drsHnyKvqoEVix8UzVkLHC6vVbcVjQGTaeUif29xvUN3W5QMGb/E1L66RPN3ovaDyDylgA8az8q56vrn4jSY5Mx3ANQEvjxl//Hnq31dpoDFiEvHyB4bbq8bSpypa2TyvheobmLnsnIaXEMHFT hannah@mac-mini.local',
3032
silo_user_id: user1.id,
3133
},
3234
]

test/e2e/ssh-keys.e2e.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,51 +5,71 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { test } from '@playwright/test'
8+
import { expect, test } from '@playwright/test'
99

10-
import { clickRowAction, expectNotVisible, expectRowVisible, expectVisible } from './utils'
10+
import { clickRowAction, expectRowVisible } from './utils'
1111

1212
test('SSH keys', async ({ page }) => {
1313
await page.goto('/settings/ssh-keys')
1414

1515
// see table with the ssh key
16-
await expectVisible(page, [
17-
'role=heading[name*="SSH Keys"]',
18-
'role=cell[name="m1-macbook-pro"]',
19-
'role=cell[name="mac-mini"]',
20-
])
16+
await expect(page.getByRole('heading', { name: 'SSH Keys' })).toBeVisible()
17+
await expect(page.getByRole('cell', { name: 'm1-macbook-pro' })).toBeVisible()
18+
await expect(page.getByRole('cell', { name: 'mac-mini' })).toBeVisible()
19+
20+
// click name to open side modal
21+
await page.getByRole('link', { name: 'm1-macbook-pro' }).click()
22+
23+
// verify side modal content
24+
const modal = page.getByRole('dialog', { name: 'View SSH key' })
25+
await expect(modal).toBeVisible()
26+
await expect(modal.getByRole('heading', { name: 'm1-macbook-pro' })).toBeVisible()
27+
28+
const propertiesTable = modal.locator('.properties-table')
29+
await expect(propertiesTable.getByText('ID')).toBeVisible()
30+
await expect(propertiesTable.getByText('Created')).toBeVisible()
31+
await expect(propertiesTable.getByText('Updated')).toBeVisible()
32+
33+
// verify form fields are present and disabled
34+
await expect(modal.getByRole('textbox', { name: 'Name' })).toBeDisabled()
35+
await expect(modal.getByRole('textbox', { name: 'Description' })).toBeDisabled()
36+
await expect(modal.getByRole('textbox', { name: 'Public key' })).toBeDisabled()
37+
38+
// close modal
39+
await modal.getByRole('button', { name: 'Close' }).click()
40+
await expect(modal).toBeHidden()
2141

2242
// delete the two ssh keys
2343
await clickRowAction(page, 'm1-macbook-pro', 'Delete')
2444
await page.getByRole('button', { name: 'Confirm' }).click()
2545

26-
await expectNotVisible(page, ['role=cell[name="m1-macbook-pro"]'])
46+
await expect(page.getByRole('cell', { name: 'm1-macbook-pro' })).toBeHidden()
2747

2848
await clickRowAction(page, 'mac-mini', 'Delete')
2949
await page.getByRole('button', { name: 'Confirm' }).click()
3050

3151
// should show empty state
32-
await expectVisible(page, ['text="No SSH keys"'])
52+
await expect(page.getByText('No SSH keys')).toBeVisible()
3353

3454
// there are two of these, but it doesn't matter which one we click
35-
await page.click('role=button[name="Add SSH key"]')
55+
await page.getByRole('button', { name: 'Add SSH key' }).click()
3656

3757
// fill out form and submit
38-
await page.fill('role=textbox[name="Name"]', 'my-key')
39-
await page.fill('role=textbox[name="Description"]', 'definitely a key')
40-
await page.fill('role=textbox[name="Public key"]', 'key contents')
58+
await page.getByRole('textbox', { name: 'Name' }).fill('my-key')
59+
await page.getByRole('textbox', { name: 'Description' }).fill('definitely a key')
60+
await page.getByRole('textbox', { name: 'Public key' }).fill('key contents')
4161
await page.getByRole('dialog').getByRole('button', { name: 'Add SSH key' }).click()
4262

4363
// it's there in the table
44-
await expectNotVisible(page, ['text="No SSH keys"'])
64+
await expect(page.getByText('No SSH keys')).toBeHidden()
4565
const table = page.getByRole('table')
4666
await expectRowVisible(table, { name: 'my-key', description: 'definitely a key' })
4767

4868
// now delete it
49-
await page.click('role=button[name="Row actions"]')
50-
await page.click('role=menuitem[name="Delete"]')
69+
await page.getByRole('button', { name: 'Row actions' }).click()
70+
await page.getByRole('menuitem', { name: 'Delete' }).click()
5171
await page.getByRole('button', { name: 'Confirm' }).click()
5272

53-
await expectNotVisible(page, ['role=cell[name="my-key"]'])
54-
await expectVisible(page, ['text="No SSH keys"'])
73+
await expect(page.getByRole('cell', { name: 'my-key' })).toBeHidden()
74+
await expect(page.getByText('No SSH keys')).toBeVisible()
5575
})

0 commit comments

Comments
 (0)