Skip to content

Commit

Permalink
Add view/edit SSH key page (#2589)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
3 people authored Dec 12, 2024
1 parent 7a8ee0a commit bdbc02b
Show file tree
Hide file tree
Showing 10 changed files with 171 additions and 29 deletions.
85 changes: 85 additions & 0 deletions app/forms/ssh-key-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'

import { apiQueryClient, usePrefetchedApiQuery } from '@oxide/api'
import { Key16Icon } from '@oxide/design-system/icons/react'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { NameField } from '~/components/form/fields/NameField'
import { TextField } from '~/components/form/fields/TextField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { getSshKeySelector, useSshKeySelector } from '~/hooks/use-params'
import { CopyToClipboard } from '~/ui/lib/CopyToClipboard'
import { DateTime } from '~/ui/lib/DateTime'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { ResourceLabel } from '~/ui/lib/SideModal'
import { Truncate } from '~/ui/lib/Truncate'
import { pb } from '~/util/path-builder'

EditSSHKeySideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
const { sshKey } = getSshKeySelector(params)
await apiQueryClient.prefetchQuery('currentUserSshKeyView', { path: { sshKey } })
return null
}

export function EditSSHKeySideModalForm() {
const navigate = useNavigate()
const { sshKey } = useSshKeySelector()

const { data } = usePrefetchedApiQuery('currentUserSshKeyView', {
path: { sshKey },
})

const form = useForm({ defaultValues: data })

return (
<SideModalForm
form={form}
formType="edit"
resourceName="SSH key"
title="View SSH key"
onDismiss={() => navigate(pb.sshKeys())}
subtitle={
<ResourceLabel>
<Key16Icon /> {data.name}
</ResourceLabel>
}
// TODO: pass actual error when this form is hooked up
loading={false}
submitError={null}
>
<PropertiesTable>
<PropertiesTable.Row label="ID">
<Truncate text={data.id} maxLength={32} hasCopyButton />
</PropertiesTable.Row>
<PropertiesTable.Row label="Created">
<DateTime date={data.timeCreated} />
</PropertiesTable.Row>
<PropertiesTable.Row label="Updated">
<DateTime date={data.timeModified} />
</PropertiesTable.Row>
</PropertiesTable>
<NameField name="name" control={form.control} disabled />
<DescriptionField name="description" control={form.control} disabled />
<div className="relative">
<CopyToClipboard className="!absolute right-0 top-0" text={data.publicKey} />
<TextField
as="textarea"
name="publicKey"
label="Public key"
required
rows={8}
control={form.control}
disabled
/>
</div>
</SideModalForm>
)
}
2 changes: 2 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const getVpcRouterRouteSelector = requireParams('project', 'vpc', 'router
export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet')
export const getSiloSelector = requireParams('silo')
export const getSiloImageSelector = requireParams('image')
export const getSshKeySelector = requireParams('sshKey')
export const getIdpSelector = requireParams('silo', 'provider')
export const getProjectImageSelector = requireParams('project', 'image')
export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
Expand Down Expand Up @@ -77,6 +78,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)
export const useSshKeySelector = () => useSelectedParams(getSshKeySelector)
export const useProjectSnapshotSelector = () =>
useSelectedParams(getProjectSnapshotSelector)
export const useInstanceSelector = () => useSelectedParams(getInstanceSelector)
Expand Down
27 changes: 19 additions & 8 deletions app/pages/settings/SSHKeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* Copyright Oxide Computer Company
*/
import { createColumnHelper } from '@tanstack/react-table'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { Link, Outlet, useNavigate } from 'react-router-dom'

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

const colHelper = createColumnHelper<SshKey>()
const staticCols = [
colHelper.accessor('name', {}),
colHelper.accessor('description', Columns.description),
colHelper.accessor('timeModified', Columns.timeModified),
]

Component.displayName = 'SSHKeysPage'
export function Component() {
Expand All @@ -60,6 +56,12 @@ export function Component() {

const makeActions = useCallback(
(sshKey: SshKey): MenuAction[] => [
{
label: 'Copy public key',
onActivate() {
window.navigator.clipboard.writeText(sshKey.publicKey)
},
},
{
label: 'Delete',
onActivate: confirmDelete({
Expand All @@ -71,6 +73,16 @@ export function Component() {
[deleteSshKey]
)

const columns = useMemo(() => {
return [
colHelper.accessor('name', {
cell: makeLinkCell((sshKey) => pb.sshKeyEdit({ sshKey: sshKey })),
}),
colHelper.accessor('description', Columns.description),
getActionsCol(makeActions),
]
}, [makeActions])

const emptyState = (
<EmptyMessage
icon={<Key16Icon />}
Expand All @@ -80,7 +92,6 @@ export function Component() {
onClick={() => navigate(pb.sshKeysNew())}
/>
)
const columns = useColsWithActions(staticCols, makeActions)
const { table } = useQueryTable({ query: sshKeyList(), columns, emptyState })

return (
Expand Down
10 changes: 9 additions & 1 deletion app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { EditProjectSideModalForm } from './forms/project-edit'
import { CreateSiloSideModalForm } from './forms/silo-create'
import * as SnapshotCreate from './forms/snapshot-create'
import * as SSHKeyCreate from './forms/ssh-key-create'
import { EditSSHKeySideModalForm } from './forms/ssh-key-edit'
import { CreateSubnetForm } from './forms/subnet-create'
import { EditSubnetForm } from './forms/subnet-edit'
import { CreateVpcSideModalForm } from './forms/vpc-create'
Expand Down Expand Up @@ -118,7 +119,14 @@ export const routes = createRoutesFromElements(
<Route index element={<Navigate to="profile" replace />} />
<Route path="profile" element={<ProfilePage />} handle={{ crumb: 'Profile' }} />
<Route {...SSHKeysPage} handle={makeCrumb('SSH Keys', pb.sshKeys)}>
<Route path="ssh-keys" element={null} />
<Route path="ssh-keys" element={null}>
<Route
path=":sshKey/edit"
loader={EditSSHKeySideModalForm.loader}
element={<EditSSHKeySideModalForm />}
handle={titleCrumb('View SSH Key')}
/>
</Route>
<Route path="ssh-keys-new" {...SSHKeyCreate} handle={titleCrumb('New SSH key')} />
</Route>
</Route>
Expand Down
10 changes: 10 additions & 0 deletions app/util/__snapshots__/path-builder.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,16 @@ exports[`breadcrumbs 2`] = `
"path": "/projects/p/snapshots",
},
],
"sshKeyEdit (/settings/ssh-keys/ss/edit)": [
{
"label": "Settings",
"path": "/settings/profile",
},
{
"label": "SSH Keys",
"path": "/settings/ssh-keys",
},
],
"sshKeys (/settings/ssh-keys)": [
{
"label": "Settings",
Expand Down
2 changes: 2 additions & 0 deletions app/util/path-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const params = {
provider: 'pr',
sledId: '5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0',
image: 'im',
sshKey: 'ss',
snapshot: 'sn',
pool: 'pl',
rule: 'fr',
Expand Down Expand Up @@ -82,6 +83,7 @@ test('path builder', () => {
"snapshotImagesNew": "/projects/p/snapshots/sn/images-new",
"snapshots": "/projects/p/snapshots",
"snapshotsNew": "/projects/p/snapshots-new",
"sshKeyEdit": "/settings/ssh-keys/ss/edit",
"sshKeys": "/settings/ssh-keys",
"sshKeysNew": "/settings/ssh-keys-new",
"systemUtilization": "/system/utilization",
Expand Down
1 change: 1 addition & 0 deletions app/util/path-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const pb = {
profile: () => '/settings/profile',
sshKeys: () => '/settings/ssh-keys',
sshKeysNew: () => '/settings/ssh-keys-new',
sshKeyEdit: (params: PP.SshKey) => `/settings/ssh-keys/${params.sshKey}/edit`,

deviceSuccess: () => '/device/success',
}
Expand Down
1 change: 1 addition & 0 deletions app/util/path-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export type FirewallRule = Required<Sel.FirewallRule>
export type VpcRouter = Required<Sel.VpcRouter>
export type VpcRouterRoute = Required<Sel.VpcRouterRoute>
export type VpcSubnet = Required<Sel.VpcSubnet>
export type SshKey = Required<Sel.SshKey>
6 changes: 4 additions & 2 deletions mock-api/sshKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export const sshKeys: Json<SshKey>[] = [
description: 'For use on personal projects',
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
public_key: 'aslsddlfkjsdlfkjsdlfkjsdlfkjsdflkjsdlfkjsdlfkjsd',
public_key:
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDU3w4FaSj/tEZYaoBtzijAFZanW9MakaPhSERtdC75opT6F/bs4ZXE8sjWgqDM1azoZbUKa42b4RWPPtCgqGQkbyYDZTzdssrml3/T1Avcy5GKlfTjACRHSI6PhC6r6bM1jxPUUstH7fBbw+DTHywUpdkvz7SHxTEOyZuP2sn38V9vBakYVsLFOu7C1W0+Jm4TYCRJlcsuC5LHVMVc4WbWzBcAZZlAznWx0XajMxmkyCB5tsyhTpykabfHbih4F3bwHYKXO613JZ6DurGcPz6CPkAVS5BWG6GrdBCkd+YK8Lw8k1oAAZLYIKQZbMnPJSNxirJ8+vr+iyIwP1DjBMnJ [email protected]',
silo_user_id: user1.id,
},
{
Expand All @@ -26,7 +27,8 @@ export const sshKeys: Json<SshKey>[] = [
description: '',
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
public_key: 'aslsddlfkjsdlfkjsdlfkjsdlfkjsdflkjsdlfkjsdlfkjsd',
public_key:
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDVav/u9wm2ALv4ks18twWQB0LHlJ1Q1y7HTi91SUuv4H95EEwqj6tVDSOtHQi08PG7xp6/8gaMVC9rs1jKl7o0cy32kuWp/rXtryn3d1bEaY9wOGwR6iokx0zjocHILhrjHpAmWnXP8oWvzx8TWOg3VPhBkZsyNdqzcdxYP2UsqccaNyz5kcuNhOYbGjIskNPAk1drsHnyKvqoEVix8UzVkLHC6vVbcVjQGTaeUif29xvUN3W5QMGb/E1L66RPN3ovaDyDylgA8az8q56vrn4jSY5Mx3ANQEvjxl//Hnq31dpoDFiEvHyB4bbq8bSpypa2TyvheobmLnsnIaXEMHFT [email protected]',
silo_user_id: user1.id,
},
]
56 changes: 38 additions & 18 deletions test/e2e/ssh-keys.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,71 @@
*
* Copyright Oxide Computer Company
*/
import { test } from '@playwright/test'
import { expect, test } from '@playwright/test'

import { clickRowAction, expectNotVisible, expectRowVisible, expectVisible } from './utils'
import { clickRowAction, expectRowVisible } from './utils'

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

// see table with the ssh key
await expectVisible(page, [
'role=heading[name*="SSH Keys"]',
'role=cell[name="m1-macbook-pro"]',
'role=cell[name="mac-mini"]',
])
await expect(page.getByRole('heading', { name: 'SSH Keys' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'm1-macbook-pro' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'mac-mini' })).toBeVisible()

// click name to open side modal
await page.getByRole('link', { name: 'm1-macbook-pro' }).click()

// verify side modal content
const modal = page.getByRole('dialog', { name: 'View SSH key' })
await expect(modal).toBeVisible()
await expect(modal.getByRole('heading', { name: 'm1-macbook-pro' })).toBeVisible()

const propertiesTable = modal.locator('.properties-table')
await expect(propertiesTable.getByText('ID')).toBeVisible()
await expect(propertiesTable.getByText('Created')).toBeVisible()
await expect(propertiesTable.getByText('Updated')).toBeVisible()

// verify form fields are present and disabled
await expect(modal.getByRole('textbox', { name: 'Name' })).toBeDisabled()
await expect(modal.getByRole('textbox', { name: 'Description' })).toBeDisabled()
await expect(modal.getByRole('textbox', { name: 'Public key' })).toBeDisabled()

// close modal
await modal.getByRole('button', { name: 'Close' }).click()
await expect(modal).toBeHidden()

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

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

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

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

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

// fill out form and submit
await page.fill('role=textbox[name="Name"]', 'my-key')
await page.fill('role=textbox[name="Description"]', 'definitely a key')
await page.fill('role=textbox[name="Public key"]', 'key contents')
await page.getByRole('textbox', { name: 'Name' }).fill('my-key')
await page.getByRole('textbox', { name: 'Description' }).fill('definitely a key')
await page.getByRole('textbox', { name: 'Public key' }).fill('key contents')
await page.getByRole('dialog').getByRole('button', { name: 'Add SSH key' }).click()

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

// now delete it
await page.click('role=button[name="Row actions"]')
await page.click('role=menuitem[name="Delete"]')
await page.getByRole('button', { name: 'Row actions' }).click()
await page.getByRole('menuitem', { name: 'Delete' }).click()
await page.getByRole('button', { name: 'Confirm' }).click()

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

0 comments on commit bdbc02b

Please sign in to comment.