From 48693a22f4759cfda416ba1d1686ae95145c403d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 7 Nov 2024 20:04:40 -0800 Subject: [PATCH 01/74] Automatic ACS URL in IdP forms w/ copy button (#2510) * Add copyable prop to input; set up automatic ACS URL * unnecessary comment * Add tooltip when value is too long to see * No need for useEffect * Revert to useEffect; add tests for getSubdomain * Update design of copyable input * Remove right padding for copyable text * full-height button * slightly wider * Combobox border tweaks * slightly taller Listbox to match other inputs * shuffle files around, make helper not depend on window * constrain TextInput value to be a string * test IdP create and edit * Add checkbox to allow user to use custom ACS URL * Update test to handle unchecking/rechecking standard ACS URL * Update microcopy * use regular useState instead of form for the checkbox --------- Co-authored-by: David Crespo --- app/forms/idp/create.tsx | 51 +++++++++++++++++++++++++++----- app/forms/idp/edit.tsx | 1 + app/forms/idp/util.spec.ts | 29 +++++++++++++++++++ app/forms/idp/util.ts | 17 +++++++++++ app/ui/lib/Combobox.tsx | 6 ++-- app/ui/lib/Listbox.tsx | 2 +- app/ui/lib/TextInput.tsx | 37 ++++++++++++++++++------ test/e2e/silos.e2e.ts | 59 ++++++++++++++++++++++++++++++++------ 8 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 app/forms/idp/util.spec.ts create mode 100644 app/forms/idp/util.ts diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 1d97d53ca6..95f9d13d7f 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router-dom' @@ -18,12 +19,14 @@ import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { Checkbox } from '~/ui/lib/Checkbox' import { FormDivider } from '~/ui/lib/Divider' import { SideModal } from '~/ui/lib/SideModal' import { readBlobAsBase64 } from '~/util/file' import { pb } from '~/util/path-builder' import { MetadataSourceField, type IdpCreateFormValues } from './shared' +import { getDelegatedDomain } from './util' const defaultValues: IdpCreateFormValues = { type: 'saml', @@ -62,6 +65,23 @@ export function CreateIdpSideModalForm() { }) const form = useForm({ defaultValues }) + const name = form.watch('name') + + const [generateUrl, setGenerateUrl] = useState(true) + + useEffect(() => { + // When creating a SAML identity provider connection, the ACS URL that the user enters + // should always be of the form: http(s)://.sys./login//saml/ + // where is the Silo name, is the delegated domain assigned to the rack, + // and is the name of the IdP connection + // The user can override this by unchecking the "Automatically generate ACS URL" checkbox + // and entering a custom ACS URL, though if they check the box again, we will regenerate + // the ACS URL. + const suffix = getDelegatedDomain(window.location) + if (generateUrl) { + form.setValue('acsUrl', `https://${silo}.sys.${suffix}/login/${silo}/saml/${name}`) + } + }, [form, name, silo, generateUrl]) return ( - +
+ + + Oxide endpoint for the identity provider to send the SAML response.{' '} + + + URL is generated from the current hostname, silo name, and provider name + according to a standard format. + +
+ } + required + control={form.control} + disabled={generateUrl} + copyable + /> + setGenerateUrl(e.target.checked)}> + Use standard ACS URL + + diff --git a/app/forms/idp/util.spec.ts b/app/forms/idp/util.spec.ts new file mode 100644 index 0000000000..71c88a81c1 --- /dev/null +++ b/app/forms/idp/util.spec.ts @@ -0,0 +1,29 @@ +/* + * 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 { describe, expect, it } from 'vitest' + +import { getDelegatedDomain } from './util' + +describe('getDomainSuffix', () => { + it('handles arbitrary URLs by falling back to placeholder', () => { + expect(getDelegatedDomain({ hostname: 'localhost' })).toBe('placeholder') + expect(getDelegatedDomain({ hostname: 'console-preview.oxide.computer' })).toBe( + 'placeholder' + ) + }) + + it('handles 1 subdomain after sys', () => { + const location = { hostname: 'oxide.sys.r3.oxide-preview.com' } + expect(getDelegatedDomain(location)).toBe('r3.oxide-preview.com') + }) + + it('handles 2 subdomains after sys', () => { + const location = { hostname: 'oxide.sys.rack2.eng.oxide.computer' } + expect(getDelegatedDomain(location)).toBe('rack2.eng.oxide.computer') + }) +}) diff --git a/app/forms/idp/util.ts b/app/forms/idp/util.ts new file mode 100644 index 0000000000..9d18f059ec --- /dev/null +++ b/app/forms/idp/util.ts @@ -0,0 +1,17 @@ +/* + * 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 + */ + +// note: this lives in its own file for fast refresh reasons + +/** + * When given a full URL hostname for an Oxide silo, return the domain + * (everything after `.sys.`). Placeholder logic should only apply + * in local dev or Vercel previews. + */ +export const getDelegatedDomain = (location: { hostname: string }) => + location.hostname.split('.sys.')[1] || 'placeholder' diff --git a/app/ui/lib/Combobox.tsx b/app/ui/lib/Combobox.tsx index f9cbc7cf45..f90c903fb4 100644 --- a/app/ui/lib/Combobox.tsx +++ b/app/ui/lib/Combobox.tsx @@ -199,7 +199,7 @@ export const Combobox = ({ placeholder={placeholder} disabled={disabled || isLoading} className={cn( - `h-10 w-full rounded !border-none px-3 py-[0.5rem] !outline-none text-sans-md text-default placeholder:text-quaternary`, + `h-10 w-full rounded !border-none px-3 py-2 !outline-none text-sans-md text-default placeholder:text-quaternary`, disabled ? 'cursor-not-allowed text-disabled bg-disabled !border-default' : 'bg-default', @@ -208,7 +208,7 @@ export const Combobox = ({ /> {items.length > 0 && ( @@ -218,7 +218,7 @@ export const Combobox = ({ {(items.length > 0 || allowArbitraryValues) && ( diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index 6055e15fe4..978f2548fc 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -99,7 +99,7 @@ export const Listbox = ({ id={id} name={name} className={cn( - `flex h-10 w-full items-center justify-between rounded border text-sans-md`, + `flex h-11 w-full items-center justify-between rounded border text-sans-md`, hasError ? 'focus-error border-error-secondary hover:border-error' : 'border-default hover:border-hover', diff --git a/app/ui/lib/TextInput.tsx b/app/ui/lib/TextInput.tsx index 965433d857..81614c881d 100644 --- a/app/ui/lib/TextInput.tsx +++ b/app/ui/lib/TextInput.tsx @@ -8,6 +8,9 @@ import { announce } from '@react-aria/live-announcer' import cn from 'classnames' import React, { useEffect } from 'react' +import type { Merge } from 'type-fest' + +import { CopyToClipboard } from './CopyToClipboard' /** * This is a little complicated. We only want to allow the `rows` prop if @@ -32,13 +35,19 @@ export type TextAreaProps = // it makes a bunch of props required that should be optional. Instead we simply // take the props of an input field (which are part of the Field props) and // manually tack on validate. -export type TextInputBaseProps = React.ComponentPropsWithRef<'input'> & { - // error is used to style the wrapper, also to put aria-invalid on the input - error?: boolean - disabled?: boolean - className?: string - fieldClassName?: string -} +export type TextInputBaseProps = Merge< + React.ComponentPropsWithRef<'input'>, + { + // error is used to style the wrapper, also to put aria-invalid on the input + error?: boolean + disabled?: boolean + className?: string + fieldClassName?: string + copyable?: boolean + // by default, number and string[] are allowed, but we want to be simple + value?: string + } +> export const TextInput = React.forwardRef< HTMLInputElement, @@ -47,10 +56,12 @@ export const TextInput = React.forwardRef< ( { type = 'text', + value, error, className, disabled, fieldClassName, + copyable, as: asProp, ...fieldProps }, @@ -60,7 +71,7 @@ export const TextInput = React.forwardRef< return (
+ {copyable && ( + + )}
) } diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 3be8297cf1..7f0e3ba0fc 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -10,6 +10,7 @@ import { expect, test } from '@playwright/test' import { chooseFile, clickRowAction, + closeToast, expectNotVisible, expectRowVisible, expectVisible, @@ -170,7 +171,6 @@ test('Default silo', async ({ page }) => { page.getByText('Silo viewerFleet viewer'), ]) }) - test('Identity providers', async ({ page }) => { await page.goto('/system/silos/maze-war') @@ -178,20 +178,61 @@ test('Identity providers', async ({ page }) => { await page.getByRole('link', { name: 'mock-idp' }).click() - await expectVisible(page, [ - 'role=dialog[name="Identity provider"]', - 'role=heading[name="mock-idp"]', - // random stuff that's not in the table - 'text="Entity ID"', - 'text="Single Logout (SLO) URL"', - ]) + const dialog = page.getByRole('dialog', { name: 'Identity provider' }) + + await expect(dialog).toBeVisible() + await expect(page.getByRole('heading', { name: 'mock-idp' })).toBeVisible() + // random stuff that's not in the table + await expect(page.getByText('Entity ID')).toBeVisible() + await expect(page.getByText('Single Logout (SLO) URL')).toBeVisible() await expect(page.getByRole('textbox', { name: 'Group attribute name' })).toHaveValue( 'groups' ) await page.getByRole('button', { name: 'Cancel' }).click() - await expectNotVisible(page, ['role=dialog[name="Identity provider"]']) + + await expect(dialog).toBeHidden() + + // test creating identity provider + await page.getByRole('link', { name: 'New provider' }).click() + + await expect(dialog).toBeVisible() + + const nameField = dialog.getByLabel('Name', { exact: true }) + const acsUrlField = dialog.getByLabel('ACS URL', { exact: true }) + + await nameField.fill('test-provider') + // ACS URL should be populated with generated value + const acsUrl = 'https://maze-war.sys.placeholder/login/maze-war/saml/test-provider' + await expect(acsUrlField).toHaveValue(acsUrl) + + // uncheck the box and change the value + await dialog.getByRole('checkbox', { name: 'Use standard ACS URL' }).click() + await acsUrlField.fill('https://example.com') + await expect(acsUrlField).toHaveValue('https://example.com') + + // re-check the box and verify that the value is regenerated + await dialog.getByRole('checkbox', { name: 'Use standard ACS URL' }).click() + await expect(acsUrlField).toHaveValue(acsUrl) + + await page.getByRole('button', { name: 'Create provider' }).click() + + await closeToast(page) + await expect(dialog).toBeHidden() + + // new provider should appear in table + await expectRowVisible(page.getByRole('table'), { + name: 'test-provider', + Type: 'saml', + description: '—', + }) + + await page.getByRole('link', { name: 'test-provider' }).click() + await expect(nameField).toHaveValue('test-provider') + await expect(nameField).toBeDisabled() + await expect(acsUrlField).toHaveValue(acsUrl) + await expect(acsUrlField).toBeDisabled() }) test('Silo IP pools', async ({ page }) => { From 23beefea4bb8b80d3a31fccbe947ab13da77be7a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 7 Nov 2024 22:32:38 -0600 Subject: [PATCH 02/74] Add help text + request signing section header to IdP form (#2537) * Add request signing section header to IdP form * help text in the identity provider form? in this economy? * review feedback, test login path preview --- app/forms/idp/create.tsx | 37 +++++++++++++++++++++++++++++++++++-- app/ui/lib/Message.tsx | 2 +- test/e2e/silos.e2e.ts | 20 ++++++++++++++++---- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 95f9d13d7f..c7c1e1578b 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -21,8 +21,10 @@ import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Checkbox } from '~/ui/lib/Checkbox' import { FormDivider } from '~/ui/lib/Divider' +import { Message } from '~/ui/lib/Message' import { SideModal } from '~/ui/lib/SideModal' import { readBlobAsBase64 } from '~/util/file' +import { links } from '~/util/links' import { pb } from '~/util/path-builder' import { MetadataSourceField, type IdpCreateFormValues } from './shared' @@ -128,7 +130,35 @@ export function CreateIdpSideModalForm() { submitError={createIdp.error} submitLabel="Create provider" > - + + Read the{' '} + + Rack Configuration + {' '} + guide to learn more about setting up an identity provider. + + } + /> + + A short name for the provider in our system. Users will see it in the path to + the login page:{' '} + + /login/{silo}/saml/{name.trim() || 'idp-name'} + + + } + /> + + + Request signing {/* We don't bother validating that you have both of these or neither even though the API requires that because we are going to change the API to always require both, at which point these become simple `required` fields */} @@ -186,7 +219,7 @@ export function CreateIdpSideModalForm() { id="public-cert-file-input" name="signingKeypair.publicCert" description="DER-encoded X.509 certificate" - label="Public cert" + label="Public certificate" control={form.control} /> { diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 7f0e3ba0fc..4dccf3656a 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -171,6 +171,7 @@ test('Default silo', async ({ page }) => { page.getByText('Silo viewerFleet viewer'), ]) }) + test('Identity providers', async ({ page }) => { await page.goto('/system/silos/maze-war') @@ -199,21 +200,32 @@ test('Identity providers', async ({ page }) => { await expect(dialog).toBeVisible() - const nameField = dialog.getByLabel('Name', { exact: true }) - const acsUrlField = dialog.getByLabel('ACS URL', { exact: true }) + // test login URL preview in name field description + await expect(page.getByText('login page: /login/maze-war/saml/idp-name')).toBeVisible() + const nameField = dialog.getByLabel('Name', { exact: true }) await nameField.fill('test-provider') + + // preview updates as you type + await expect( + page.getByText('login page: /login/maze-war/saml/test-provider') + ).toBeVisible() + // ACS URL should be populated with generated value + const acsUrlField = dialog.getByLabel('ACS URL', { exact: true }) const acsUrl = 'https://maze-war.sys.placeholder/login/maze-war/saml/test-provider' await expect(acsUrlField).toHaveValue(acsUrl) + const acsUrlCheckbox = dialog.getByRole('checkbox', { name: 'Use standard ACS URL' }) + await expect(acsUrlCheckbox).toBeChecked() + // uncheck the box and change the value - await dialog.getByRole('checkbox', { name: 'Use standard ACS URL' }).click() + await acsUrlCheckbox.click() await acsUrlField.fill('https://example.com') await expect(acsUrlField).toHaveValue('https://example.com') // re-check the box and verify that the value is regenerated - await dialog.getByRole('checkbox', { name: 'Use standard ACS URL' }).click() + await acsUrlCheckbox.click() await expect(acsUrlField).toHaveValue(acsUrl) await page.getByRole('button', { name: 'Create provider' }).click() From 9ef82bada91ce3d4c1188a5acc303cd5c952feb8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 10 Nov 2024 13:14:14 -0800 Subject: [PATCH 03/74] Bring back topbar breadcrumbs (#2529) * Most of the work to get breadcrumbs in place of top bar pickers * Bot commit: format with prettier * Refactor; add System page breadcrumbs * proper arrow; spacing tweaks * Tighten top bar by a few pixels * No back arrow on root pages; no white link when only one item on page * Update tests * Style tweaks * Fix current selected item icon alignment * Cleanup * Breadcrumbs powered by `useMatches()` (#2531) * first pass at matches-based breadcrumbs. route config changes required * kinda fix things in the route config * use-title.ts -> use-crumbs.ts * Update import --------- Co-authored-by: Charlie Park * Update expected strings in e2e tests * Fix multiple locator match issue, though we might change text on button * use main to select connect button instead of connect breadcrumb * slight refactor on system/silos crumb * move Breadcrumbs into TopBar * prop name systemOrSilo * adjust main pane / footer height on serial console * add titleOnly concept for form crumbs, apply to all routes * --top-bar-height CSS var * fix ssh keys and floating IP edit crumbs * fix z-index on modal dialog overlay so it covers topbar * Bot commit: format with prettier * fix crumbs of pageless route nodes, very zany snapshot test for the crumbs * use helpers to make everything a little cleaner * write a test, find a bug! funny how that works --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Benjamin Leonard Co-authored-by: David Crespo Co-authored-by: David Crespo --- app/components/Breadcrumbs.tsx | 47 + app/components/Sidebar.tsx | 2 +- app/components/TopBar.tsx | 14 +- app/components/TopBarPicker.tsx | 181 +--- app/hooks/use-crumbs.ts | 71 ++ app/hooks/use-title.ts | 50 - app/layouts/ProjectLayout.tsx | 23 +- app/layouts/RootLayout.tsx | 13 +- app/layouts/SettingsLayout.tsx | 5 +- app/layouts/SiloLayout.tsx | 6 +- app/layouts/SystemLayout.tsx | 20 +- app/layouts/helpers.tsx | 2 +- .../instances/instance/SerialConsolePage.tsx | 3 +- app/routes.tsx | 541 +++++----- app/ui/lib/DialogOverlay.tsx | 6 +- app/ui/styles/index.css | 3 +- .../__snapshots__/path-builder.spec.ts.snap | 951 ++++++++++++++++++ app/util/path-builder.spec.ts | 49 +- app/util/path-builder.ts | 15 +- tailwind.config.js | 5 +- test/e2e/breadcrumbs.e2e.ts | 82 ++ test/e2e/instance-serial.e2e.ts | 2 +- test/e2e/instance.e2e.ts | 6 +- test/e2e/ip-pools.e2e.ts | 12 +- test/e2e/networking.e2e.ts | 3 +- test/e2e/vpcs.e2e.ts | 5 +- 26 files changed, 1570 insertions(+), 547 deletions(-) create mode 100644 app/components/Breadcrumbs.tsx create mode 100644 app/hooks/use-crumbs.ts delete mode 100644 app/hooks/use-title.ts create mode 100644 app/util/__snapshots__/path-builder.spec.ts.snap create mode 100644 test/e2e/breadcrumbs.e2e.ts diff --git a/app/components/Breadcrumbs.tsx b/app/components/Breadcrumbs.tsx new file mode 100644 index 0000000000..13760b55d9 --- /dev/null +++ b/app/components/Breadcrumbs.tsx @@ -0,0 +1,47 @@ +/* + * 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 cn from 'classnames' +import { Link } from 'react-router-dom' + +import { PrevArrow12Icon } from '@oxide/design-system/icons/react' + +import { useCrumbs } from '~/hooks/use-crumbs' +import { Slash } from '~/ui/lib/Slash' +import { intersperse } from '~/util/array' + +export function Breadcrumbs() { + const crumbs = useCrumbs().filter((c) => !c.titleOnly) + const isTopLevel = crumbs.length <= 1 + return ( + + ) +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 0cc380ca40..5061d3d942 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -77,7 +77,7 @@ Sidebar.Nav = ({ children, heading }: SidebarNav) => ( )} -