Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add view/edit SSH key page #2589

Merged
merged 10 commits into from
Dec 12, 2024
75 changes: 75 additions & 0 deletions app/forms/ssh-key-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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 { 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 onDismiss = () => navigate(pb.sshKeys())

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

const form = useForm({ defaultValues: data })

return (
<SideModalForm
form={form}
formType="edit"
resourceName="SSH key"
onDismiss={onDismiss}
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 />
<TextField
as="textarea"
name="publicKey"
label="Public key"
required
rows={8}
control={form.control}
disabled
/>
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
</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
21 changes: 13 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 Down Expand Up @@ -71,6 +67,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 +86,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('Edit 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
2 changes: 2 additions & 0 deletions app/util/path-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type FirewallRule = Required<PP.FirewallRule>
type VpcRouter = Required<PP.VpcRouter>
type VpcRouterRoute = Required<PP.VpcRouterRoute>
type VpcSubnet = Required<PP.VpcSubnet>
type SshKey = Required<PP.SshKey>

// these are used as the basis for many routes but are not themselves routes we
// ever want to link to. so we use this to build the routes but pb.project() is
Expand Down Expand Up @@ -129,6 +130,7 @@ export const pb = {
profile: () => '/settings/profile',
sshKeys: () => '/settings/ssh-keys',
sshKeysNew: () => '/settings/ssh-keys-new',
sshKeyEdit: (params: SshKey) => `/settings/ssh-keys/${params.sshKey}/edit`,

deviceSuccess: () => '/device/success',
}
Expand Down
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,
},
]
25 changes: 24 additions & 1 deletion test/e2e/ssh-keys.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Copyright Oxide Computer Company
*/
import { test } from '@playwright/test'
import { expect, test } from '@playwright/test'

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

Expand All @@ -19,6 +19,29 @@ test('SSH keys', async ({ page }) => {
'role=cell[name="mac-mini"]',
])

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

// verify side modal content
const modal = page.getByRole('dialog')
await expect(modal).toBeVisible()
await expectVisible(page, [
'role=heading[name="m1-macbook-pro"]',
'role=heading[name="Edit SSH key"]',
])

const propertiesTable = modal.locator('.properties-table')
await expectVisible(propertiesTable, ['text="ID"', 'text="Created"', 'text="Updated"'])

// 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()
Expand Down
14 changes: 7 additions & 7 deletions test/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,18 @@ export async function map<T>(

type Selector = string | Locator

const toLocator = (page: Page, selector: Selector): Locator =>
typeof selector === 'string' ? page.locator(selector) : selector

export async function expectVisible(page: Page, selectors: Selector[]) {
// note if `Locator` is used instead of string, the container is ignored
export async function expectVisible(container: Page | Locator, selectors: Selector[]) {
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
for (const selector of selectors) {
await expect(toLocator(page, selector)).toBeVisible()
const locator = typeof selector === 'string' ? container.locator(selector) : selector
await expect(locator).toBeVisible()
}
}

export async function expectNotVisible(page: Page, selectors: Selector[]) {
export async function expectNotVisible(container: Page | Locator, selectors: Selector[]) {
for (const selector of selectors) {
await expect(toLocator(page, selector)).toBeHidden()
const locator = typeof selector === 'string' ? container.locator(selector) : selector
await expect(locator).toBeHidden()
}
}

Expand Down
Loading