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 }) => {