Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 hannah@m1-macbook-pro.local',
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 hannah@mac-mini.local',
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()
})