From bdbc02b7fdc9911691b514774895d5324b0a8141 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Thu, 12 Dec 2024 00:06:30 +0000 Subject: [PATCH] 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 c7d3c561632704c9f3bba162730471b9bc451a21. * 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 Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- app/forms/ssh-key-edit.tsx | 85 +++++++++++++++++++ app/hooks/use-params.ts | 2 + app/pages/settings/SSHKeysPage.tsx | 27 ++++-- app/routes.tsx | 10 ++- .../__snapshots__/path-builder.spec.ts.snap | 10 +++ app/util/path-builder.spec.ts | 2 + app/util/path-builder.ts | 1 + app/util/path-params.ts | 1 + mock-api/sshKeys.ts | 6 +- test/e2e/ssh-keys.e2e.ts | 56 ++++++++---- 10 files changed, 171 insertions(+), 29 deletions(-) create mode 100644 app/forms/ssh-key-edit.tsx diff --git a/app/forms/ssh-key-edit.tsx b/app/forms/ssh-key-edit.tsx new file mode 100644 index 0000000000..609ec225ce --- /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 348e48d3b1..7bee58f446 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 9e2bd58d5a..f52b5499c6 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 ccc13b8543..19e8e671a1 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 a4c74b445e..debb608d86 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 ff32e3e277..291447a1ba 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 55439bb067..f2ec29204a 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 2a153512ad..88e27f58bf 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 f5c72fef4c..7cbf5771a4 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 fd6768bfb2..0f45d7da65 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() })