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:
Bartosz Leper
2026-05-05 11:55:36 +00:00
committed by GitHub
parent ded85d55df
commit f7bc2ce5a3
11 changed files with 274 additions and 213 deletions
@@ -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}
@@ -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,
},
];
@@ -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) {