diff --git a/app/forms/ssh-key-edit.tsx b/app/forms/ssh-key-edit.tsx
new file mode 100644
index 000000000..609ec225c
--- /dev/null
+++ b/app/forms/ssh-key-edit.tsx
@@ -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 (
+ navigate(pb.sshKeys())}
+ subtitle={
+
+ {data.name}
+
+ }
+ // TODO: pass actual error when this form is hooked up
+ loading={false}
+ submitError={null}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts
index 348e48d3b..7bee58f44 100644
--- a/app/hooks/use-params.ts
+++ b/app/hooks/use-params.ts
@@ -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')
@@ -77,6 +78,7 @@ function useSelectedParams(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)
diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx
index 9e2bd58d5..f52b5499c 100644
--- a/app/pages/settings/SSHKeysPage.tsx
+++ b/app/pages/settings/SSHKeysPage.tsx
@@ -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 {
@@ -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'
@@ -39,11 +40,6 @@ export async function loader() {
}
const colHelper = createColumnHelper()
-const staticCols = [
- colHelper.accessor('name', {}),
- colHelper.accessor('description', Columns.description),
- colHelper.accessor('timeModified', Columns.timeModified),
-]
Component.displayName = 'SSHKeysPage'
export function Component() {
@@ -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({
@@ -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 = (
}
@@ -80,7 +92,6 @@ export function Component() {
onClick={() => navigate(pb.sshKeysNew())}
/>
)
- const columns = useColsWithActions(staticCols, makeActions)
const { table } = useQueryTable({ query: sshKeyList(), columns, emptyState })
return (
diff --git a/app/routes.tsx b/app/routes.tsx
index ccc13b854..19e8e671a 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -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'
@@ -118,7 +119,14 @@ export const routes = createRoutesFromElements(
} />
} handle={{ crumb: 'Profile' }} />
-
+
+ }
+ handle={titleCrumb('View SSH Key')}
+ />
+
diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap
index a4c74b445..debb608d8 100644
--- a/app/util/__snapshots__/path-builder.spec.ts.snap
+++ b/app/util/__snapshots__/path-builder.spec.ts.snap
@@ -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",
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index ff32e3e27..291447a1b 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -25,6 +25,7 @@ const params = {
provider: 'pr',
sledId: '5c56b522-c9b8-49e4-9f9a-8d52a89ec3e0',
image: 'im',
+ sshKey: 'ss',
snapshot: 'sn',
pool: 'pl',
rule: 'fr',
@@ -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",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index 55439bb06..f2ec29204 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -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',
}
diff --git a/app/util/path-params.ts b/app/util/path-params.ts
index 2a153512a..88e27f58b 100644
--- a/app/util/path-params.ts
+++ b/app/util/path-params.ts
@@ -24,3 +24,4 @@ export type FirewallRule = Required
export type VpcRouter = Required
export type VpcRouterRoute = Required
export type VpcSubnet = Required
+export type SshKey = Required
diff --git a/mock-api/sshKeys.ts b/mock-api/sshKeys.ts
index f5c72fef4..7cbf5771a 100644
--- a/mock-api/sshKeys.ts
+++ b/mock-api/sshKeys.ts
@@ -17,7 +17,8 @@ export const sshKeys: Json[] = [
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 hannah@m1-macbook-pro.local',
silo_user_id: user1.id,
},
{
@@ -26,7 +27,8 @@ export const sshKeys: Json[] = [
description: '',
time_created: new Date().toISOString(),
time_modified: new Date().toISOString(),
- public_key: 'aslsddlfkjsdlfkjsdlfkjsdlfkjsdflkjsdlfkjsdlfkjsd',
+ public_key:
+ 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDVav/u9wm2ALv4ks18twWQB0LHlJ1Q1y7HTi91SUuv4H95EEwqj6tVDSOtHQi08PG7xp6/8gaMVC9rs1jKl7o0cy32kuWp/rXtryn3d1bEaY9wOGwR6iokx0zjocHILhrjHpAmWnXP8oWvzx8TWOg3VPhBkZsyNdqzcdxYP2UsqccaNyz5kcuNhOYbGjIskNPAk1drsHnyKvqoEVix8UzVkLHC6vVbcVjQGTaeUif29xvUN3W5QMGb/E1L66RPN3ovaDyDylgA8az8q56vrn4jSY5Mx3ANQEvjxl//Hnq31dpoDFiEvHyB4bbq8bSpypa2TyvheobmLnsnIaXEMHFT hannah@mac-mini.local',
silo_user_id: user1.id,
},
]
diff --git a/test/e2e/ssh-keys.e2e.ts b/test/e2e/ssh-keys.e2e.ts
index fd6768bfb..0f45d7da6 100644
--- a/test/e2e/ssh-keys.e2e.ts
+++ b/test/e2e/ssh-keys.e2e.ts
@@ -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()
})