diff --git a/web/packages/design/src/Tooltip/IconTooltip.tsx b/web/packages/design/src/Tooltip/IconTooltip.tsx index 358a227f12f..43b7e95b0bc 100644 --- a/web/packages/design/src/Tooltip/IconTooltip.tsx +++ b/web/packages/design/src/Tooltip/IconTooltip.tsx @@ -69,7 +69,6 @@ export const IconTooltip: React.FC< return ( <> { switch (kind) { case 'info': - return ; + return ( + + ); case 'warning': - return ; + return ( + + ); case 'error': - return ; + return ( + + ); } }; diff --git a/web/packages/teleport/src/Account/Account.tsx b/web/packages/teleport/src/Account/Account.tsx index 245a1be0520..53b70aba60a 100644 --- a/web/packages/teleport/src/Account/Account.tsx +++ b/web/packages/teleport/src/Account/Account.tsx @@ -141,7 +141,7 @@ export function Account({ )} - + } - deviceTypeColumnName="Passkey Type" devices={[]} + attempt={{ status: 'success' }} + passkeysEnabled /> ); } -export function ListWithDevices() { +export function ListWithDevices({ + isPasswordlessEnabled, +}: { + isPasswordlessEnabled: boolean; +}) { return ( } - deviceTypeColumnName="Passkey Type" - devices={devices} + devices={devicesJson.map(d => + makeMfaDevice(d, { isPasswordlessEnabled }) + )} + attempt={{ status: 'success' }} + passkeysEnabled={isPasswordlessEnabled} /> ); } -const devices: MfaDevice[] = [ +ListWithDevices.argTypes = { + isPasswordlessEnabled: { + control: 'boolean', + defaultValue: true, + }, +}; + +ListWithDevices.args = { isPasswordlessEnabled: true }; + +const devicesJson: any[] = [ { - id: '1', - description: 'Hardware Key', + id: 'c62fcf79-a1ce-4774-9098-c13702bd7895', name: 'touch_id', - registeredDate: new Date(1628799417000), - lastUsedDate: new Date(1628799417000), - type: 'webauthn', - usage: 'passwordless', + addedAt: '2021-08-12T20:16:57.000Z', + lastUsed: '2021-08-12T20:16:57.000Z', + type: 'WebAuthn', + residentKey: true, }, { - id: '2', - description: 'Hardware Key', + id: '44860895-b008-4b58-b283-978429d9980a', name: 'solokey', - registeredDate: new Date(1623722252000), - lastUsedDate: new Date(1623981452000), - type: 'webauthn', - usage: 'passwordless', + addedAt: '2021-06-15T01:57:32.000Z', + lastUsed: '2021-06-18T01:57:32.000Z', + type: 'WebAuthn', + residentKey: true, }, { - id: '3', - description: 'Hardware Key', - name: 'backup yubikey', - registeredDate: new Date(1618711052000), - lastUsedDate: new Date(1626472652000), - type: 'webauthn', - usage: 'passwordless', + id: 'ac7f91f7-a616-4e91-9d6d-652d35423377', + name: 'authy', + addedAt: '2021-04-18T01:57:32.000Z', + lastUsed: '2021-07-16T21:57:32.000Z', + type: 'TOTP', + residentKey: false, }, { - id: '4', - description: 'Hardware Key', + id: '50d90d05-67ee-4fdd-be54-2f5182fea9a5', name: 'yubikey', - registeredDate: new Date(1612493852000), - lastUsedDate: new Date(1614481052000), - type: 'webauthn', - usage: 'passwordless', + addedAt: '2021-02-05T02:57:32.000Z', + lastUsed: '2021-02-28T02:57:32.000Z', + type: 'WebAuthn', + residentKey: false, }, { - id: '5', - description: 'sso provider', + id: '5cc6a573-e9d2-4d8d-a34e-566c91b51233', name: 'okta', - registeredDate: new Date(1612493852000), - lastUsedDate: new Date(1614481052000), - type: 'sso', - usage: 'mfa', + addedAt: '2021-02-05T02:57:32.000Z', + lastUsed: '2021-02-28T02:57:32.000Z', + type: 'SSO', + residentKey: false, }, ]; diff --git a/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.test.tsx b/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.test.tsx index caffe75a48b..403fdff503f 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.test.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.test.tsx @@ -18,7 +18,7 @@ import { within } from '@testing-library/react'; -import { render, screen } from 'design/utils/testing'; +import { render, screen, userEvent } from 'design/utils/testing'; import { MfaDevice } from 'teleport/services/mfa'; @@ -27,7 +27,7 @@ import { AuthDeviceList } from './AuthDeviceList'; const devices: MfaDevice[] = [ { id: '1', - description: 'Hardware Key', + description: 'Passkey', name: 'touch_id', registeredDate: new Date(1628799417000), lastUsedDate: new Date(1628799417000), @@ -41,7 +41,7 @@ const devices: MfaDevice[] = [ registeredDate: new Date(1623722252000), lastUsedDate: new Date(1623981452000), type: 'webauthn', - usage: 'passwordless', + usage: 'mfa', }, ]; @@ -62,11 +62,11 @@ function getTableCellContents() { return { header: within(header) .getAllByRole('columnheader') - .map(cell => cell.textContent), + .map(cell => cell.textContent.trim()), rows: rows.map(row => within(row) .getAllByRole('cell') - .map(cell => cell.textContent) + .map(cell => cell.textContent.trim()) ), }; } @@ -75,15 +75,16 @@ test('renders devices', () => { render( ); expect(screen.getByText('Header')).toBeInTheDocument(); expect(getTableCellContents()).toEqual({ - header: ['Passkey Type', 'Nickname', 'Added', 'Last Used', 'Actions'], + header: ['Device Type', 'Nickname', 'Added', 'Last Used', 'Actions'], rows: [ - ['Hardware Key', 'touch_id', '2021-08-12', '2021-08-12', ''], + ['Passkey', 'touch_id', '2021-08-12', '2021-08-12', ''], ['Hardware Key', 'yubikey', '2021-06-15', '2021-06-18', ''], ], }); @@ -94,19 +95,48 @@ test('renders devices', () => { buttons.forEach(button => { expect(button).toBeEnabled(); }); + // No additional info icons expected + expect( + screen.queryAllByRole('graphics-symbol', { name: 'information' }) + ).toHaveLength(0); +}); + +test('renders devices with passkeys disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + const infoIcons = screen.getAllByRole('graphics-symbol', { + name: 'Information', + }); + expect(infoIcons).toHaveLength(1); + await user.hover(infoIcons[0]); + expect( + screen.getByText( + 'This device can be a passkey, but passwordless authentication is disabled' + ) + ).toBeVisible(); }); test('delete button is disabled for sso devices', () => { render( ); expect(screen.getByText('Header')).toBeInTheDocument(); expect(getTableCellContents()).toEqual({ - header: ['Passkey Type', 'Nickname', 'Added', 'Last Used', 'Actions'], + header: ['Device Type', 'Nickname', 'Added', 'Last Used', 'Actions'], rows: [['SSO Provider', 'okta', '2021-08-12', '2021-08-12', '']], }); @@ -118,9 +148,10 @@ test('delete button is disabled for sso devices', () => { test('renders no devices', () => { render( ); expect(screen.getByText('Header')).toBeInTheDocument(); diff --git a/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.tsx b/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.tsx index 1d451f51890..8d3cad586aa 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AuthDeviceList/AuthDeviceList.tsx @@ -19,19 +19,23 @@ import React from 'react'; import styled from 'styled-components'; +import { Flex, Indicator } from 'design'; import { ButtonWarningBorder } from 'design/Button/Button'; import { Cell, DateCell } from 'design/DataTable'; import Table from 'design/DataTable/Table'; import * as Icon from 'design/Icon'; import { MultiRowBox, Row } from 'design/MultiRowBox'; +import { IconTooltip } from 'design/Tooltip'; +import { Attempt } from 'shared/hooks/useAttemptNext'; import { MfaDevice } from 'teleport/services/mfa'; export interface AuthDeviceListProps { header: React.ReactNode; - deviceTypeColumnName: string; devices: MfaDevice[]; onRemove?: (device: MfaDevice) => void; + attempt: Attempt; + passkeysEnabled: boolean; } /** @@ -40,21 +44,53 @@ export interface AuthDeviceListProps { */ export function AuthDeviceList({ devices, + attempt, header, - deviceTypeColumnName, onRemove, + passkeysEnabled, }: AuthDeviceListProps) { return ( {header} + {attempt.status == 'processing' && ( + + + + + + )} {devices.length > 0 && ( { + switch (device.usage) { + case 'mfa': + return {device.description}; + case 'passwordless': + return ( + + {passkeysEnabled ? ( + device.description + ) : ( + + {device.description} + + This device can be a passkey, but passwordless + authentication is disabled + + + )} + + ); + default: + return device.usage; + } + }, }, { key: 'name', headerText: 'Nickname', isSortable: true }, { diff --git a/web/packages/teleport/src/Account/SecuritySettings.test.tsx b/web/packages/teleport/src/Account/SecuritySettings.test.tsx index a7cc49cff40..c0944064c5b 100644 --- a/web/packages/teleport/src/Account/SecuritySettings.test.tsx +++ b/web/packages/teleport/src/Account/SecuritySettings.test.tsx @@ -151,9 +151,9 @@ test.each` test.each` pwdless | passkeys | state ${true} | ${[testPasskey]} | ${/^active$/} - ${true} | ${[]} | ${null} - ${false} | ${[testPasskey]} | ${/^inactive$/} - ${false} | ${[]} | ${null} + ${true} | ${[]} | ${/^inactive$/} + ${false} | ${[testPasskey]} | ${/^disabled/} + ${false} | ${[]} | ${/^disabled/} `( "Passkey state pill: passwordless=$pwdless, $passkeys.length passkey(s) => state='$state'", async ({ pwdless, passkeys, state }) => { @@ -265,17 +265,10 @@ test('loading state', async () => { ); - expect( - within(screen.getByTestId('passkey-list')).getByTestId('indicator-wrapper') - ).toBeVisible(); - expect( - within(screen.getByTestId('mfa-list')).getByTestId('indicator-wrapper') - ).toBeVisible(); + expect(screen.getByTestId('device-list-loading')).toBeVisible(); expect(screen.getByText(/add a passkey/i)).toBeVisible(); expect(screen.getByText(/add mfa/i)).toBeVisible(); - expect( - screen.queryByTestId('passwordless-state-pill') - ).not.toBeInTheDocument(); + expect(screen.queryByTestId('passwordless-state-pill')).toBeEmptyDOMElement(); expect(screen.getByTestId('mfa-state-pill')).toBeEmptyDOMElement(); }); @@ -402,7 +395,7 @@ test('removing an MFA method', async () => { await renderComponent(ctx); await user.click( - within(screen.getByTestId('mfa-list')).getByRole('button', { + within(screen.getByTestId('device-list')).getByRole('button', { name: 'Delete', }) ); diff --git a/web/packages/teleport/src/Account/SecuritySettings.tsx b/web/packages/teleport/src/Account/SecuritySettings.tsx index 3183b65362c..12449a03bda 100644 --- a/web/packages/teleport/src/Account/SecuritySettings.tsx +++ b/web/packages/teleport/src/Account/SecuritySettings.tsx @@ -16,9 +16,8 @@ * along with this program. If not, see . */ import { useState } from 'react'; -import styled, { useTheme } from 'styled-components'; -import { Box, Flex, H2, Indicator, Subtitle2 } from 'design'; +import { Box, Flex } from 'design'; import * as Icon from 'design/Icon'; import { useToastNotifications } from 'shared/components/ToastNotification'; import { Attempt } from 'shared/hooks/useAttemptNext'; @@ -37,7 +36,7 @@ import { } from './ManageDevices/wizards'; import { PasswordBox } from './PasswordBox'; import { Headings } from './SideNav'; -import { StatePill } from './StatePill'; +import { AuthMethodState, StatePill } from './StatePill'; export interface SecuritySettingsProps extends AccountProps {} @@ -50,14 +49,14 @@ export function securityHeadings(): Headings { const storeUser = useStore(ctx.storeUser); const isSso = storeUser.isSso(); - let headings = [{ name: 'Passkey', id: 'passkey' }] as Headings; + let headings = [ + { name: 'Passkeys and Multi-factor Authentication', id: 'auth-devices' }, + ] as Headings; if (!isSso) { headings.push({ name: 'Password', id: 'password' }); } - headings.push({ name: 'Multi-factor Authentication', id: 'mfa' }); - return headings; } @@ -83,14 +82,21 @@ export function SecuritySettings({ }: SecuritySettingsProps) { const toastNotification = useToastNotifications(); - const passkeys = devices.filter(d => d.usage === 'passwordless'); - const mfaDevices = devices.filter(d => d.usage === 'mfa'); + const hasPasskeys = devices.some(d => d.usage === 'passwordless'); + const hasMfaDevices = devices.some(d => d.usage === 'mfa'); const disableAddPasskey = !canAddPasskeys; const disableAddMfa = !canAddMfa; let mfaPillState = undefined; if (fetchDevicesAttempt.status !== 'processing') { - mfaPillState = canAddMfa && mfaDevices.length > 0 ? 'active' : 'inactive'; + mfaPillState = canAddMfa && hasMfaDevices ? 'active' : 'inactive'; + } + + let passkeysPillState: AuthMethodState | undefined = undefined; + if (!canAddPasskeys) { + passkeysPillState = 'disabled'; + } else if (fetchDevicesAttempt.status !== 'processing') { + passkeysPillState = hasPasskeys ? 'active' : 'inactive'; } const [prevFetchStatus, setPrevFetchStatus] = useState(''); @@ -138,20 +144,51 @@ export function SecuritySettings({ } return ( <> - + + + +
+ Multi-factor Authentication + + + } + description="Provide secondary authentication when signing in + with a password. Unlike passkeys, multi-factor methods do + not enable passwordless sign-in." + icon={} + actions={ + onAddDevice('mfa')} + > + + Add MFA + + } + /> + } - deviceTypeColumnName="Passkey Type" - devices={passkeys} + devices={devices} onRemove={onRemoveDevice} + attempt={fetchDevicesAttempt} + passkeysEnabled={canAddPasskeys} /> {!isSso && ( @@ -163,45 +200,6 @@ export function SecuritySettings({ /> )} - - - Multi-factor Authentication - - - } - description="Provide secondary authentication when signing in - with a password. Unlike passkeys, multi-factor methods do - not enable passwordless sign-in." - icon={} - showIndicator={fetchDevicesAttempt.status === 'processing'} - actions={ - onAddDevice('mfa')} - > - - Add MFA - - } - /> - } - deviceTypeColumnName="MFA Type" - devices={mfaDevices} - onRemove={onRemoveDevice} - /> - {EnterpriseComponent && (
@@ -239,19 +237,15 @@ export function SecuritySettings({ */ function PasskeysHeader({ empty, - fetchDevicesAttempt, - passkeysEnabled, + state, disableAddPasskey, onAddDevice, }: { empty: boolean; - fetchDevicesAttempt: Attempt; - passkeysEnabled: boolean; + state: AuthMethodState | undefined; disableAddPasskey: boolean; onAddDevice: (usage: DeviceUsage) => void; }) { - const theme = useTheme(); - const ActionButton = empty ? ActionButtonPrimary : ActionButtonSecondary; const button = ( ); - if (empty) { - return ( - - - - -

Passwordless sign-in using Passkeys

- - Passkeys are a password replacement that validates your identity using - touch, facial recognition, a device password, or a PIN. - - - {fetchDevicesAttempt.status === 'processing' && ( - // This trick allows us to maintain center alignment of the button - // and display it along with the indicator. - - - - )} - {button} - -
- ); - } + const title = empty ? 'Passwordless sign-in using Passkeys' : 'Passkeys'; + + const description = empty + ? 'Passkeys are a password replacement that validates your identity using touch, facial recognition, a device password, or a PIN.' + : 'Enable secure passwordless sign-in using fingerprint or facial recognition, a one-time code, or a device password.'; return (
- Passkeys - + {title} + } - description="Enable secure passwordless sign-in using - fingerprint or facial recognition, a one-time code, or - a device password." + description={description} icon={} - showIndicator={fetchDevicesAttempt.status === 'processing'} actions={button} /> ); } - -const RelativeBox = styled(Box)` - position: relative; -`; - -/** A box that is displayed to the right where it normally would be. */ -const BoxToTheRight = styled(Box)` - position: absolute; - right: 100%; -`; diff --git a/web/packages/teleport/src/Account/StatePill.tsx b/web/packages/teleport/src/Account/StatePill.tsx index 126d155779f..eafbf1a200c 100644 --- a/web/packages/teleport/src/Account/StatePill.tsx +++ b/web/packages/teleport/src/Account/StatePill.tsx @@ -19,7 +19,7 @@ import styled, { css } from 'styled-components'; /** State of an authentication method (password, MFA method, or passkey). */ -export type AuthMethodState = 'active' | 'inactive'; +export type AuthMethodState = 'active' | 'inactive' | 'disabled'; interface StatePillProps { state: AuthMethodState | undefined; @@ -62,6 +62,7 @@ function statePillStyles({ state }: StatePillProps): ReturnType { color: ${props => props.theme.colors.success.main}; `; case 'inactive': + case 'disabled': return css` background-color: ${props => props.theme.colors.interactive.tonal.neutral[0]}; diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx index a6ce7a58906..74466f132d6 100644 --- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx +++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor/Resources.test.tsx @@ -390,18 +390,18 @@ describe('AppAccessSection', () => { }; const awsRoleArns = () => - screen.getByRole('group', { name: 'AWS Role ARNs' }); + screen.getByRole('group', { name: /^AWS Role ARNs/ }); const awsRoleArnTextBoxes = () => within(awsRoleArns()).getAllByRole('textbox'); const azureIdentities = () => - screen.getByRole('group', { name: 'Azure Identities' }); + screen.getByRole('group', { name: /^Azure Identities/ }); const azureIdentityTextBoxes = () => within(azureIdentities()).getAllByRole('textbox'); const gcpServiceAccounts = () => - screen.getByRole('group', { name: 'GCP Service Accounts' }); + screen.getByRole('group', { name: /^GCP Service Accounts/ }); const gcpServiceAccountTextBoxes = () => within(gcpServiceAccounts()).getAllByRole('textbox'); - const mcpTools = () => screen.getByRole('group', { name: 'MCP Tools' }); + const mcpTools = () => screen.getByRole('group', { name: /^MCP Tools/ }); const mcpToolsTextBoxes = () => within(mcpTools()).getAllByRole('textbox'); test('editing', async () => { @@ -570,7 +570,7 @@ describe('DatabaseAccessSection', () => { }); const dbServiceLabels = within( - screen.getByRole('group', { name: 'Database Service Labels' }) + screen.getByRole('group', { name: /^Database Service Labels/ }) ); await user.type(dbServiceLabels.getByPlaceholderText('label key'), 'foo'); await user.type(dbServiceLabels.getByPlaceholderText('label value'), 'bar'); @@ -600,7 +600,7 @@ describe('DatabaseAccessSection', () => { const labels = within(screen.getByRole('group', { name: 'Labels' })); await user.type(labels.getByPlaceholderText('label value'), 'some-value'); const dbServiceLabelsGroup = within( - screen.getByRole('group', { name: 'Database Service Labels' }) + screen.getByRole('group', { name: /^Database Service Labels/ }) ); await user.type( dbServiceLabelsGroup.getByPlaceholderText('label value'), @@ -649,7 +649,7 @@ describe('DatabaseAccessSection', () => { expect(screen.getByPlaceholderText('label key')).toBeInTheDocument(); expect(screen.getByPlaceholderText('label value')).toBeInTheDocument(); expect( - screen.queryByLabelText('Database Service Labels') + screen.queryByLabelText(/^Database Service Labels/) ).not.toBeInTheDocument(); expect(screen.queryByLabelText(/database names/i)).not.toBeInTheDocument(); expect(screen.getByLabelText(/database users/i)).toBeInTheDocument(); diff --git a/web/packages/teleport/src/services/mfa/makeMfaDevice.ts b/web/packages/teleport/src/services/mfa/makeMfaDevice.ts index 2547f4d720c..8c25c6bd6b3 100644 --- a/web/packages/teleport/src/services/mfa/makeMfaDevice.ts +++ b/web/packages/teleport/src/services/mfa/makeMfaDevice.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { DeviceType, MfaDevice } from './types'; +import { DeviceType, DeviceUsage, MfaDevice } from './types'; function getType(deviceTypeFromJsonResponse: string): DeviceType { if (deviceTypeFromJsonResponse === 'TOTP') { @@ -28,30 +28,47 @@ function getType(deviceTypeFromJsonResponse: string): DeviceType { return 'webauthn'; } -export default function makeMfaDevice(json): MfaDevice { +export type MakeMfaDeviceOptions = { + isPasswordlessEnabled?: boolean; +}; + +export default function makeMfaDevice( + json: any, + opts: MakeMfaDeviceOptions +): MfaDevice { const { id, name, lastUsed, addedAt, residentKey } = json; - - let description = ''; - if (json.type === 'TOTP') { - description = 'Authenticator App'; - } else if (json.type === 'U2F' || json.type === 'WebAuthn') { - description = 'Hardware Key'; - } else if (json.type === 'SSO') { - description = 'SSO Provider'; - } else { - description = 'unknown device'; - } - - const type = getType(json.type); const usage = residentKey ? 'passwordless' : 'mfa'; + const type = getType(json.type); return { id, name, - description, + description: description(json.type, usage, opts), registeredDate: new Date(addedAt), lastUsedDate: new Date(lastUsed), type, usage, }; } + +function description( + type: string, + usage: DeviceUsage, + opts: MakeMfaDeviceOptions +): string { + const { isPasswordlessEnabled = false } = opts; + if (usage === 'passwordless' && isPasswordlessEnabled) { + return 'Passkey'; + } + + if (type === 'TOTP') { + return 'Authenticator App'; + } + if (type === 'U2F' || type === 'WebAuthn') { + return 'Hardware Key'; + } + if (type === 'SSO') { + return 'SSO Provider'; + } + return 'unknown device'; +} diff --git a/web/packages/teleport/src/services/mfa/mfa.ts b/web/packages/teleport/src/services/mfa/mfa.ts index 20a911232ae..a75b79dd143 100644 --- a/web/packages/teleport/src/services/mfa/mfa.ts +++ b/web/packages/teleport/src/services/mfa/mfa.ts @@ -30,9 +30,10 @@ import { class MfaService { fetchDevicesWithToken(tokenId: string): Promise { + const opts = { isPasswordlessEnabled: cfg.isPasswordlessEnabled() }; return api .get(cfg.getMfaDevicesWithTokenUrl(tokenId)) - .then(devices => devices.map(makeMfaDevice)); + .then((devices: any[]) => devices.map(d => makeMfaDevice(d, opts))); } removeDevice(tokenId: string, deviceName: string) { @@ -40,9 +41,10 @@ class MfaService { } fetchDevices(): Promise { + const opts = { isPasswordlessEnabled: cfg.isPasswordlessEnabled() }; return api .get(cfg.api.mfaDevicesPath) - .then(devices => devices.map(makeMfaDevice)); + .then((devices: any[]) => devices.map(d => makeMfaDevice(d, opts))); } addNewTotpDevice(req: AddNewTotpDeviceRequest) {