mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 11:00:37 +08:00
Combine passkeys and MFA devices into one list (#66035)
* Combine passkeys and MFA devices into one list * Add additional clues for passwordless disabled * Review
This commit is contained in:
@@ -69,7 +69,6 @@ export const IconTooltip: React.FC<
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
role="icon"
|
||||
aria-owns={open ? 'mouse-over-popover' : undefined}
|
||||
{...(trigger === 'hover' && triggerOnHoverProps)}
|
||||
{...(trigger === 'click' && triggerOnClickProps)}
|
||||
@@ -117,11 +116,32 @@ const ToolTipIcon = ({
|
||||
}) => {
|
||||
switch (kind) {
|
||||
case 'info':
|
||||
return <InfoIcon $muteIconColor={muteIconColor} size="medium" />;
|
||||
return (
|
||||
<InfoIcon
|
||||
role="graphics-symbol"
|
||||
aria-label="Information"
|
||||
$muteIconColor={muteIconColor}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
case 'warning':
|
||||
return <WarningIcon $muteIconColor={muteIconColor} size="medium" />;
|
||||
return (
|
||||
<WarningIcon
|
||||
role="graphics-symbol"
|
||||
aria-label="Warning"
|
||||
$muteIconColor={muteIconColor}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
case 'error':
|
||||
return <ErrorIcon $muteIconColor={muteIconColor} size="medium" />;
|
||||
return (
|
||||
<ErrorIcon
|
||||
role="graphics-symbol"
|
||||
aria-label="Error"
|
||||
$muteIconColor={muteIconColor}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export function Account({
|
||||
</Danger>
|
||||
)}
|
||||
<Flex flexDirection="row" gap={4} maxWidth={'1440px'} margin={'0 auto'}>
|
||||
<Flex flexDirection="column" gap={1} width="16rem">
|
||||
<Flex flexDirection="column" gap={1} width="25rem">
|
||||
<SideNav
|
||||
recoveryEnabled={EnterpriseComponent !== undefined}
|
||||
trustedDevicesEnabled={TrustedDeviceListComponent !== undefined}
|
||||
|
||||
+50
-38
@@ -19,12 +19,12 @@
|
||||
import * as Icon from 'design/Icon';
|
||||
|
||||
import { ActionButtonSecondary, Header } from 'teleport/Account/Header';
|
||||
import { MfaDevice } from 'teleport/services/mfa';
|
||||
import makeMfaDevice from 'teleport/services/mfa/makeMfaDevice';
|
||||
|
||||
import { AuthDeviceList } from './AuthDeviceList';
|
||||
|
||||
export default {
|
||||
title: 'Teleport/Account/Manage Devices/Device List',
|
||||
title: 'Teleport/Account/Manage Devices/Auth Device List',
|
||||
};
|
||||
|
||||
export function EmptyList() {
|
||||
@@ -43,13 +43,18 @@ export function EmptyList() {
|
||||
}
|
||||
/>
|
||||
}
|
||||
deviceTypeColumnName="Passkey Type"
|
||||
devices={[]}
|
||||
attempt={{ status: 'success' }}
|
||||
passkeysEnabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListWithDevices() {
|
||||
export function ListWithDevices({
|
||||
isPasswordlessEnabled,
|
||||
}: {
|
||||
isPasswordlessEnabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AuthDeviceList
|
||||
header={
|
||||
@@ -65,56 +70,63 @@ export function ListWithDevices() {
|
||||
}
|
||||
/>
|
||||
}
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
+42
-11
@@ -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(
|
||||
<AuthDeviceList
|
||||
header="Header"
|
||||
deviceTypeColumnName="Passkey Type"
|
||||
devices={devices}
|
||||
attempt={{ status: 'success' }}
|
||||
passkeysEnabled
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<AuthDeviceList
|
||||
header="Header"
|
||||
devices={devices}
|
||||
attempt={{ status: 'success' }}
|
||||
passkeysEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<AuthDeviceList
|
||||
header="Header"
|
||||
deviceTypeColumnName="Passkey Type"
|
||||
devices={ssoDevice}
|
||||
attempt={{ status: 'success' }}
|
||||
passkeysEnabled
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<AuthDeviceList
|
||||
deviceTypeColumnName="Passkey Type"
|
||||
header="Header"
|
||||
devices={[]}
|
||||
attempt={{ status: 'success' }}
|
||||
passkeysEnabled
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Header')).toBeInTheDocument();
|
||||
|
||||
@@ -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 (
|
||||
<MultiRowBox>
|
||||
<Row>{header}</Row>
|
||||
{attempt.status == 'processing' && (
|
||||
<Row data-testid="device-list-loading">
|
||||
<Flex justifyContent="center">
|
||||
<Indicator size={40} delay="none" />
|
||||
</Flex>
|
||||
</Row>
|
||||
)}
|
||||
{devices.length > 0 && (
|
||||
<Row>
|
||||
<StyledTable
|
||||
columns={[
|
||||
{
|
||||
key: 'description',
|
||||
headerText: deviceTypeColumnName,
|
||||
headerText: 'Device Type',
|
||||
isSortable: true,
|
||||
render: device => {
|
||||
switch (device.usage) {
|
||||
case 'mfa':
|
||||
return <Cell>{device.description}</Cell>;
|
||||
case 'passwordless':
|
||||
return (
|
||||
<Cell>
|
||||
{passkeysEnabled ? (
|
||||
device.description
|
||||
) : (
|
||||
<Flex alignItems="center" gap={1}>
|
||||
{device.description}
|
||||
<IconTooltip>
|
||||
This device can be a passkey, but passwordless
|
||||
authentication is disabled
|
||||
</IconTooltip>
|
||||
</Flex>
|
||||
)}
|
||||
</Cell>
|
||||
);
|
||||
default:
|
||||
return device.usage;
|
||||
}
|
||||
},
|
||||
},
|
||||
{ key: 'name', headerText: 'Nickname', isSortable: true },
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
</ContextProvider>
|
||||
);
|
||||
|
||||
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',
|
||||
})
|
||||
);
|
||||
|
||||
@@ -16,9 +16,8 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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<Attempt['status']>('');
|
||||
@@ -138,20 +144,51 @@ export function SecuritySettings({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Box data-testid="passkey-list" id="passkey">
|
||||
<Box id="auth-devices" data-testid="device-list">
|
||||
<AuthDeviceList
|
||||
header={
|
||||
<PasskeysHeader
|
||||
empty={passkeys.length === 0}
|
||||
passkeysEnabled={canAddPasskeys}
|
||||
disableAddPasskey={disableAddPasskey}
|
||||
fetchDevicesAttempt={fetchDevicesAttempt}
|
||||
onAddDevice={onAddDevice}
|
||||
/>
|
||||
<Flex flexDirection="column" gap={3}>
|
||||
<PasskeysHeader
|
||||
empty={!hasPasskeys}
|
||||
state={passkeysPillState}
|
||||
disableAddPasskey={disableAddPasskey}
|
||||
onAddDevice={onAddDevice}
|
||||
/>
|
||||
<Header
|
||||
title={
|
||||
<Flex gap={2} alignItems="center">
|
||||
Multi-factor Authentication
|
||||
<StatePill
|
||||
data-testid="mfa-state-pill"
|
||||
state={mfaPillState}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
description="Provide secondary authentication when signing in
|
||||
with a password. Unlike passkeys, multi-factor methods do
|
||||
not enable passwordless sign-in."
|
||||
icon={<Icon.ShieldCheck />}
|
||||
actions={
|
||||
<ActionButtonSecondary
|
||||
disabled={disableAddMfa}
|
||||
title={
|
||||
disableAddMfa
|
||||
? 'Multi-factor authentication is disabled'
|
||||
: ''
|
||||
}
|
||||
onClick={() => onAddDevice('mfa')}
|
||||
>
|
||||
<Icon.Add size={20} />
|
||||
Add MFA
|
||||
</ActionButtonSecondary>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
deviceTypeColumnName="Passkey Type"
|
||||
devices={passkeys}
|
||||
devices={devices}
|
||||
onRemove={onRemoveDevice}
|
||||
attempt={fetchDevicesAttempt}
|
||||
passkeysEnabled={canAddPasskeys}
|
||||
/>
|
||||
</Box>
|
||||
{!isSso && (
|
||||
@@ -163,45 +200,6 @@ export function SecuritySettings({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Box data-testid="mfa-list" id="mfa">
|
||||
<AuthDeviceList
|
||||
header={
|
||||
<Header
|
||||
title={
|
||||
<Flex gap={2} alignItems="center">
|
||||
Multi-factor Authentication
|
||||
<StatePill
|
||||
data-testid="mfa-state-pill"
|
||||
state={mfaPillState}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
description="Provide secondary authentication when signing in
|
||||
with a password. Unlike passkeys, multi-factor methods do
|
||||
not enable passwordless sign-in."
|
||||
icon={<Icon.ShieldCheck />}
|
||||
showIndicator={fetchDevicesAttempt.status === 'processing'}
|
||||
actions={
|
||||
<ActionButtonSecondary
|
||||
disabled={disableAddMfa}
|
||||
title={
|
||||
disableAddMfa
|
||||
? 'Multi-factor authentication is disabled'
|
||||
: ''
|
||||
}
|
||||
onClick={() => onAddDevice('mfa')}
|
||||
>
|
||||
<Icon.Add size={20} />
|
||||
Add MFA
|
||||
</ActionButtonSecondary>
|
||||
}
|
||||
/>
|
||||
}
|
||||
deviceTypeColumnName="MFA Type"
|
||||
devices={mfaDevices}
|
||||
onRemove={onRemoveDevice}
|
||||
/>
|
||||
</Box>
|
||||
{EnterpriseComponent && (
|
||||
<div id="recovery-code">
|
||||
<EnterpriseComponent addNotification={toastNotification.add} />
|
||||
@@ -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 = (
|
||||
<ActionButton
|
||||
@@ -264,68 +258,23 @@ function PasskeysHeader({
|
||||
</ActionButton>
|
||||
);
|
||||
|
||||
if (empty) {
|
||||
return (
|
||||
<Flex flexDirection="column" alignItems="center">
|
||||
<Box
|
||||
bg={theme.colors.interactive.tonal.neutral[0]}
|
||||
lineHeight={0}
|
||||
p={2}
|
||||
borderRadius={3}
|
||||
mb={3}
|
||||
>
|
||||
<Icon.Key />
|
||||
</Box>
|
||||
<H2 mb={1}>Passwordless sign-in using Passkeys</H2>
|
||||
<Subtitle2
|
||||
color={theme.colors.text.slightlyMuted}
|
||||
textAlign="center"
|
||||
mb={3}
|
||||
>
|
||||
Passkeys are a password replacement that validates your identity using
|
||||
touch, facial recognition, a device password, or a PIN.
|
||||
</Subtitle2>
|
||||
<RelativeBox>
|
||||
{fetchDevicesAttempt.status === 'processing' && (
|
||||
// This trick allows us to maintain center alignment of the button
|
||||
// and display it along with the indicator.
|
||||
<BoxToTheRight mr={3} data-testid="indicator-wrapper">
|
||||
<Indicator size={40} delay="none" />
|
||||
</BoxToTheRight>
|
||||
)}
|
||||
{button}
|
||||
</RelativeBox>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Header
|
||||
title={
|
||||
<Flex gap={2} alignItems="center">
|
||||
Passkeys
|
||||
<StatePill
|
||||
data-testid="passwordless-state-pill"
|
||||
state={passkeysEnabled ? 'active' : 'inactive'}
|
||||
/>
|
||||
{title}
|
||||
<StatePill data-testid="passwordless-state-pill" state={state} />
|
||||
</Flex>
|
||||
}
|
||||
description="Enable secure passwordless sign-in using
|
||||
fingerprint or facial recognition, a one-time code, or
|
||||
a device password."
|
||||
description={description}
|
||||
icon={<Icon.Key />}
|
||||
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%;
|
||||
`;
|
||||
|
||||
@@ -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<typeof css> {
|
||||
color: ${props => props.theme.colors.success.main};
|
||||
`;
|
||||
case 'inactive':
|
||||
case 'disabled':
|
||||
return css`
|
||||
background-color: ${props =>
|
||||
props.theme.colors.interactive.tonal.neutral[0]};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -30,9 +30,10 @@ import {
|
||||
|
||||
class MfaService {
|
||||
fetchDevicesWithToken(tokenId: string): Promise<MfaDevice[]> {
|
||||
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<MfaDevice[]> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user