From c84f4fd8880bac17b9596b8a8b26b6ecc7ce24b8 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Thu, 8 Feb 2024 11:18:19 +0100 Subject: [PATCH 1/5] Add an option to enable a new Add Device dialog design (#37747) * Add an option to enable a new Add Device dialog design Also deduplicate code from storageService functions that parse JSON. * lint --- .../storageService/storageService.test.ts | 9 +++ .../services/storageService/storageService.ts | 65 +++++++------------ .../src/services/storageService/types.ts | 4 ++ 3 files changed, 36 insertions(+), 42 deletions(-) diff --git a/web/packages/teleport/src/services/storageService/storageService.test.ts b/web/packages/teleport/src/services/storageService/storageService.test.ts index 918d7ab8f5d91..51a7a8c8bf414 100644 --- a/web/packages/teleport/src/services/storageService/storageService.test.ts +++ b/web/packages/teleport/src/services/storageService/storageService.test.ts @@ -57,4 +57,13 @@ describe('localStorage', () => { ls.clear(); expect(localStorage).toHaveLength(0); }); + + test('parses JSON configuration values', () => { + localStorage.setItem('key1', '{"foo": "bar"}'); + localStorage.setItem('key2', 'true'); + + expect(ls.getParsedJSONValue('key1', null)).toEqual({ foo: 'bar' }); + expect(ls.getParsedJSONValue('key2', null)).toBe(true); + expect(ls.getParsedJSONValue('not-a-key', 'default')).toBe('default'); + }); }); diff --git a/web/packages/teleport/src/services/storageService/storageService.ts b/web/packages/teleport/src/services/storageService/storageService.ts index 090441b1ba6eb..dc920b20ceb70 100644 --- a/web/packages/teleport/src/services/storageService/storageService.ts +++ b/web/packages/teleport/src/services/storageService/storageService.ts @@ -43,6 +43,7 @@ const KEEP_LOCALSTORAGE_KEYS_ON_LOGOUT = [ KeysEnum.SHOW_ASSIST_POPUP, KeysEnum.USER_PREFERENCES, KeysEnum.RECOMMEND_FEATURE, + KeysEnum.NEW_ADD_AUTH_DEVICE_DIALOG, ]; export const storageService = { @@ -62,17 +63,20 @@ export const storageService = { window.removeEventListener('storage', fn); }, + getParsedJSONValue(key: string, defaultValue: T): T { + const item = window.localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } + return defaultValue; + }, + setBearerToken(token: BearerToken) { window.localStorage.setItem(KeysEnum.TOKEN, JSON.stringify(token)); }, getBearerToken(): BearerToken { - const item = window.localStorage.getItem(KeysEnum.TOKEN); - if (item) { - return JSON.parse(item); - } - - return null; + return this.getParsedJSONValue(KeysEnum.TOKEN, null); }, getAccessToken() { @@ -103,11 +107,7 @@ export const storageService = { }, getOnboardDiscover(): OnboardDiscover { - const item = window.localStorage.getItem(KeysEnum.DISCOVER); - if (item) { - return JSON.parse(item); - } - return null; + return this.getParsedJSONValue(KeysEnum.DISCOVER, null); }, getUserPreferences(): UserPreferences { @@ -142,11 +142,7 @@ export const storageService = { }, getOnboardSurvey(): LocalStorageSurvey { - const survey = window.localStorage.getItem(KeysEnum.ONBOARD_SURVEY); - if (survey) { - return JSON.parse(survey); - } - return null; + return this.getParsedJSONValue(KeysEnum.ONBOARD_SURVEY, null); }, setOnboardSurvey(survey: LocalStorageSurvey) { @@ -160,11 +156,7 @@ export const storageService = { }, getCloudUserInvites(): CloudUserInvites { - const invites = window.localStorage.getItem(KeysEnum.CLOUD_USER_INVITES); - if (invites) { - return JSON.parse(invites); - } - return null; + return this.getParsedJSONValue(KeysEnum.CLOUD_USER_INVITES, null); }, setCloudUserInvites(invites: CloudUserInvites) { @@ -238,37 +230,22 @@ export const storageService = { }, getFeatureRecommendationStatus(): RecommendFeature { - const item = window.localStorage.getItem(KeysEnum.RECOMMEND_FEATURE); - if (item) { - return JSON.parse(item); - } - return null; + return this.getParsedJSONValue(KeysEnum.RECOMMEND_FEATURE, null); }, getAccessGraphEnabled(): boolean { - const item = window.localStorage.getItem(KeysEnum.ACCESS_GRAPH_ENABLED); - if (item) { - return JSON.parse(item); - } - return false; + return this.getParsedJSONValue(KeysEnum.ACCESS_GRAPH_ENABLED, false); }, getAccessGraphSQLEnabled(): boolean { - const item = window.localStorage.getItem(KeysEnum.ACCESS_GRAPH_SQL_ENABLED); - if (item) { - return JSON.parse(item); - } - return false; + return this.getParsedJSONValue(KeysEnum.ACCESS_GRAPH_SQL_ENABLED, false); }, getExternalAuditStorageCtaDisabled(): boolean { - const item = window.localStorage.getItem( - KeysEnum.EXTERNAL_AUDIT_STORAGE_CTA_DISABLED + return this.getParsedJSONValue( + KeysEnum.EXTERNAL_AUDIT_STORAGE_CTA_DISABLED, + false ); - if (item) { - return JSON.parse(item); - } - return false; }, disableExternalAuditStorageCta(): void { @@ -277,4 +254,8 @@ export const storageService = { JSON.stringify(true) ); }, + + isNewAddAuthDeviceDialogEnabled(): boolean { + return this.getParsedJSONValue(KeysEnum.NEW_ADD_AUTH_DEVICE_DIALOG, false); + }, }; diff --git a/web/packages/teleport/src/services/storageService/types.ts b/web/packages/teleport/src/services/storageService/types.ts index 1faf82b6e66d9..8260c51e0ace9 100644 --- a/web/packages/teleport/src/services/storageService/types.ts +++ b/web/packages/teleport/src/services/storageService/types.ts @@ -35,6 +35,10 @@ export const KeysEnum = { ACCESS_GRAPH_SQL_ENABLED: 'grv_teleport_access_graph_sql_enabled', EXTERNAL_AUDIT_STORAGE_CTA_DISABLED: 'grv_teleport_external_audit_storage_disabled', + + // TODO(bl-nero): Remove this option once + // https://github.com/gravitational/teleport/issues/37616 is resolved. + NEW_ADD_AUTH_DEVICE_DIALOG: 'grv_new_add_auth_device_dialog', }; // SurveyRequest is the request for sending data to the back end From 378a4d110744da8855802eed2f7042811da5f70a Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Tue, 13 Feb 2024 17:06:26 +0100 Subject: [PATCH 2/5] A wizard for adding authentication devices (#38100) * A wizard for adding authentication devices * Hide changes between localStorage flag * Review --- lib/auth/password.go | 3 + web/packages/design/src/Alert/Alert.jsx | 12 + .../design/src/StepSlider/StepSlider.tsx | 4 + .../teleport/src/Account/Account.story.tsx | 5 + web/packages/teleport/src/Account/Account.tsx | 29 +- .../AddAuthDeviceWizard.test.tsx | 190 ++++++++++ .../AddAuthDeviceWizard.tsx | 331 ++++++++++++++++++ .../AddAuthDeviceWizard/index.ts | 19 + .../Account/ManageDevices/useManageDevices.ts | 30 ++ .../NewCredentials/NewCredentials.story.tsx | 2 + .../ReAuthenticate/useReAuthenticate.ts | 2 +- web/packages/teleport/src/services/mfa/mfa.ts | 29 +- .../teleport/src/services/mfa/types.ts | 12 +- 13 files changed, 655 insertions(+), 13 deletions(-) create mode 100644 web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx create mode 100644 web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx create mode 100644 web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/index.ts diff --git a/lib/auth/password.go b/lib/auth/password.go index 4ed50ac40451c..cd71bf064b6c7 100644 --- a/lib/auth/password.go +++ b/lib/auth/password.go @@ -244,6 +244,9 @@ func (a *Server) checkOTP(user string, otpToken string) (*types.MFADevice, error } return dev, nil } + // This message is relied upon by the Web UI in + // web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx/RequthenticateStep(). + // Please keep these in sync. return nil, trace.AccessDenied("invalid totp token") } diff --git a/web/packages/design/src/Alert/Alert.jsx b/web/packages/design/src/Alert/Alert.jsx index bf4824c5d4474..09682267e8f87 100644 --- a/web/packages/design/src/Alert/Alert.jsx +++ b/web/packages/design/src/Alert/Alert.jsx @@ -46,6 +46,14 @@ const kind = props => { background: theme.colors.success.main, color: theme.colors.text.primaryInverse, }; + case 'outline-danger': + return { + background: fade(theme.colors.error.main, 0.1), + border: `${theme.radii[1]}px solid ${theme.colors.error.main}`, + borderRadius: `${theme.radii[3]}px`, + boxShadow: 'none', + justifyContent: 'normal', + }; case 'outline-info': return { background: fade(theme.colors.link, 0.1), @@ -90,6 +98,7 @@ Alert.propTypes = { 'info', 'warning', 'success', + 'outline-danger', 'outline-info', ]), ...color.propTypes, @@ -108,4 +117,7 @@ export const Danger = props => ; export const Info = props => ; export const Warning = props => ; export const Success = props => ; +export const OutlineDanger = props => ( + +); export const OutlineInfo = props => ; diff --git a/web/packages/design/src/StepSlider/StepSlider.tsx b/web/packages/design/src/StepSlider/StepSlider.tsx index 22b98c2ea34a9..f3ced8bbada72 100644 --- a/web/packages/design/src/StepSlider/StepSlider.tsx +++ b/web/packages/design/src/StepSlider/StepSlider.tsx @@ -161,6 +161,8 @@ export function StepSlider(props: Props) { rootRef.current.style.height = `${height}px`; }} hasTransitionEnded={hasTransitionEnded} + stepIndex={step} + flowLength={flows[currFlow].length} {...stepProps} /> ); @@ -329,6 +331,8 @@ export type StepComponentProps = { // prev goes back a step in the flow. prev(): void; hasTransitionEnded: boolean; + stepIndex: number; + flowLength: number; }; // NewFlow defines fields for a new flow. diff --git a/web/packages/teleport/src/Account/Account.story.tsx b/web/packages/teleport/src/Account/Account.story.tsx index 96dc6ff71cdd1..fa9ccb5d17439 100644 --- a/web/packages/teleport/src/Account/Account.story.tsx +++ b/web/packages/teleport/src/Account/Account.story.tsx @@ -161,4 +161,9 @@ const props: AccountProps = { residentKey: false, }, ], + onAddPasskey: () => {}, + onPasskeyAdded: () => {}, + isReauthenticationRequired: false, + passkeyWizardVisible: false, + closePasskeyWizard: () => {}, }; diff --git a/web/packages/teleport/src/Account/Account.tsx b/web/packages/teleport/src/Account/Account.tsx index 3c6df633c4148..a34acdea6cd77 100644 --- a/web/packages/teleport/src/Account/Account.tsx +++ b/web/packages/teleport/src/Account/Account.tsx @@ -34,6 +34,8 @@ import { MfaChallengeScope } from 'teleport/services/auth/auth'; import cfg from 'teleport/config'; +import { storageService } from 'teleport/services/storageService'; + import { AuthDeviceList } from './ManageDevices/AuthDeviceList/AuthDeviceList'; import useManageDevices, { State as ManageDevicesState, @@ -41,6 +43,10 @@ import useManageDevices, { import AddDevice from './ManageDevices/AddDevice'; import { ActionButton, Header } from './Header'; import { PasswordBox } from './PasswordBox'; +import { AddAuthDeviceWizard } from './ManageDevices/AddAuthDeviceWizard'; + +const useNewAddAuthDeviceDialog = + storageService.isNewAddAuthDeviceDialogEnabled(); export interface EnterpriseComponentProps { // TODO(bl-nero): Consider moving the notifications to its own store and @@ -104,6 +110,8 @@ export function Account({ setToken, onAddDevice, onRemoveDevice, + onAddPasskey, + onPasskeyAdded, deviceToRemove, fetchDevices, removeDevice, @@ -112,9 +120,11 @@ export function Account({ isReAuthenticateVisible, isAddDeviceVisible, isRemoveDeviceVisible, + passkeyWizardVisible, hideReAuthenticate, hideAddDevice, hideRemoveDevice, + closePasskeyWizard, isSso, canAddMFA, canAddPasskeys, @@ -170,6 +180,11 @@ export function Account({ addNotification('info', 'Your password has been changed.'); } + function onAddPasskeySuccess() { + addNotification('info', 'Passkey successfully saved.'); + onPasskeyAdded(); + } + return ( @@ -191,7 +206,11 @@ export function Account({ ? 'Passwordless authentication is disabled' : '' } - onClick={() => onAddDevice('passwordless')} + onClick={() => + useNewAddAuthDeviceDialog + ? onAddPasskey() + : onAddDevice('passwordless') + } > Add a Passkey @@ -272,6 +291,14 @@ export function Account({ /> )} + {passkeyWizardVisible && ( + + )} + {/* Note: Although notifications appear on top, we deliberately place the container on the bottom to avoid manipulating z-index. The stacking context from one of the buttons appears on top otherwise. diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx new file mode 100644 index 0000000000000..03114b5efef7a --- /dev/null +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx @@ -0,0 +1,190 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { render, screen, userEvent } from 'design/utils/testing'; +import React from 'react'; + +import { within } from '@testing-library/react'; + +import TeleportContext from 'teleport/teleportContext'; +import { ContextProvider } from 'teleport'; +import MfaService from 'teleport/services/mfa'; +import cfg from 'teleport/config'; +import auth from 'teleport/services/auth/auth'; + +import { AddAuthDeviceWizard } from '.'; + +const dummyCredential: Credential = { id: 'cred-id', type: 'public-key' }; + +beforeEach(() => { + jest.replaceProperty(cfg.auth, 'second_factor', 'optional'); + jest + .spyOn(MfaService.prototype, 'createNewWebAuthnDevice') + .mockResolvedValueOnce(dummyCredential); + jest + .spyOn(MfaService.prototype, 'saveNewWebAuthnDevice') + .mockResolvedValueOnce('some-credential'); + jest + .spyOn(auth, 'createPrivilegeTokenWithWebauthn') + .mockResolvedValueOnce('webauthn-privilege-token'); + jest + .spyOn(auth, 'createPrivilegeTokenWithTotp') + .mockImplementationOnce(token => + Promise.resolve(`totp-privilege-token-${token}`) + ); +}); + +afterEach(jest.resetAllMocks); + +function TestWizard({ + ctx, + privilegeToken, + onSuccess, +}: { + ctx: TeleportContext; + privilegeToken?: string; + onSuccess(): void; +}) { + return ( + + {}} + onSuccess={onSuccess} + /> + + ); +} + +describe('flow without reauthentication', () => { + test('adds a passkey', async () => { + const ctx = new TeleportContext(); + const user = userEvent.setup(); + const onSuccess = jest.fn(); + render( + + ); + + const createStep = within(screen.getByTestId('create-step')); + await user.click( + createStep.getByRole('button', { name: 'Create a passkey' }) + ); + expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({ + tokenId: 'privilege-token', + deviceUsage: 'passwordless', + }); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('Passkey Nickname'), 'new-passkey'); + await user.click( + saveStep.getByRole('button', { name: 'Save the Passkey' }) + ); + expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({ + credential: dummyCredential, + addRequest: { + deviceName: 'new-passkey', + deviceUsage: 'passwordless', + tokenId: 'privilege-token', + }, + }); + expect(onSuccess).toHaveBeenCalled(); + }); +}); + +describe('flow with reauthentication', () => { + test('adds a passkey with WebAuthn reauthentication', async () => { + const ctx = new TeleportContext(); + const user = userEvent.setup(); + const onSuccess = jest.fn(); + render(); + + const reauthenticateStep = within( + screen.getByTestId('reauthenticate-step') + ); + await user.click(reauthenticateStep.getByText('Verify my identity')); + + const createStep = within(screen.getByTestId('create-step')); + await user.click( + createStep.getByRole('button', { name: 'Create a passkey' }) + ); + expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({ + tokenId: 'webauthn-privilege-token', + deviceUsage: 'passwordless', + }); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('Passkey Nickname'), 'new-passkey'); + await user.click( + saveStep.getByRole('button', { name: 'Save the Passkey' }) + ); + expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({ + credential: dummyCredential, + addRequest: { + deviceName: 'new-passkey', + deviceUsage: 'passwordless', + tokenId: 'webauthn-privilege-token', + }, + }); + expect(onSuccess).toHaveBeenCalled(); + }); + + test('adds a passkey with OTP reauthentication', async () => { + const ctx = new TeleportContext(); + const user = userEvent.setup(); + const onSuccess = jest.fn(); + render(); + + const reauthenticateStep = within( + screen.getByTestId('reauthenticate-step') + ); + await user.click(reauthenticateStep.getByText('Authenticator App')); + await user.type( + reauthenticateStep.getByLabelText('Authenticator Code'), + '654987' + ); + await user.click(reauthenticateStep.getByText('Verify my identity')); + + const createStep = within(screen.getByTestId('create-step')); + await user.click( + createStep.getByRole('button', { name: 'Create a passkey' }) + ); + expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({ + tokenId: 'totp-privilege-token-654987', + deviceUsage: 'passwordless', + }); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('Passkey Nickname'), 'new-passkey'); + await user.click( + saveStep.getByRole('button', { name: 'Save the Passkey' }) + ); + expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({ + credential: dummyCredential, + addRequest: { + deviceName: 'new-passkey', + deviceUsage: 'passwordless', + tokenId: 'totp-privilege-token-654987', + }, + }); + expect(onSuccess).toHaveBeenCalled(); + }); +}); diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx new file mode 100644 index 0000000000000..aa426ac0e8cad --- /dev/null +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx @@ -0,0 +1,331 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { OutlineDanger } from 'design/Alert/Alert'; +import { ButtonPrimary, ButtonSecondary } from 'design/Button'; +import Dialog from 'design/Dialog'; +import { SingleRowBox } from 'design/MultiRowBox'; +import { RadioGroup } from 'design/RadioGroup'; +import { StepComponentProps, StepSlider } from 'design/StepSlider'; +import Text from 'design/Text'; +import React, { useState, FormEvent } from 'react'; +import FieldInput from 'shared/components/FieldInput'; +import Validation, { Validator } from 'shared/components/Validation'; +import { requiredField } from 'shared/components/Validation/rules'; +import useAttempt from 'shared/hooks/useAttemptNext'; +import { Auth2faType } from 'shared/services'; +import createMfaOptions from 'shared/utils/createMfaOptions'; + +import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate'; +import { MfaChallengeScope } from 'teleport/services/auth/auth'; +import useTeleport from 'teleport/useTeleport'; + +interface AddAuthDeviceWizardProps { + privilegeToken?: string; + onClose(): void; + onSuccess(): void; +} + +/** + * A wizard for adding MFA and passkey devices. Currently only supports + * passkeys. + */ +export function AddAuthDeviceWizard({ + privilegeToken: privilegeTokenProp = '', + onClose, + onSuccess, +}: AddAuthDeviceWizardProps) { + const reauthRequired = !privilegeTokenProp; + const [privilegeToken, setPrivilegeToken] = useState(privilegeTokenProp); + const [credential, setCredential] = useState(null); + + return ( + ({ width: '650px' })} + onClose={onClose} + > + + + ); +} + +const wizardFlows = { + withReauthentication: [ + ReauthenticateStep, + CreatePasskeyStep, + SavePasskeyStep, + ], + withoutReauthentication: [CreatePasskeyStep, SavePasskeyStep], +}; + +interface ReauthenticateStepProps extends StepComponentProps { + onAuthenticated(privilegeToken: string): void; + onClose(): void; +} + +function ReauthenticateStep({ + next, + refCallback, + stepIndex, + flowLength, + onClose, + onAuthenticated: onAuthenticatedProp, +}: ReauthenticateStepProps) { + const challengeScope = MfaChallengeScope.MANAGE_DEVICES; + const onAuthenticated = (privilegeToken: string) => { + onAuthenticatedProp(privilegeToken); + next(); + }; + const { + attempt, + auth2faType, + preferredMfaType, + clearAttempt, + submitWithTotp, + submitWithWebauthn, + } = useReAuthenticate({ + onAuthenticated, + challengeScope, + }); + const mfaOptions = createMfaOptions({ + auth2faType, + preferredType: preferredMfaType, + required: true, + }); + + const [mfaOption, setMfaOption] = useState(mfaOptions[0].value); + const [authCode, setAuthCode] = useState(''); + + const onAuthCodeChanged = (e: React.ChangeEvent) => { + setAuthCode(e.target.value); + }; + + const onReauthenticate = ( + e: FormEvent, + validator: Validator + ) => { + e.preventDefault(); + if (!validator.validate()) return; + if (mfaOption === 'webauthn') { + submitWithWebauthn(challengeScope); + } + if (mfaOption === 'otp') { + submitWithTotp(authCode); + } + }; + + // This message relies on the status message produced by the auth server in + // lib/auth/Server.checkOTP function. Please keep these in sync. + const errorMessage = + attempt.statusText === 'invalid totp token' + ? 'Invalid authenticator code' + : attempt.statusText; + + return ( +
+ + Step {stepIndex + 1} of {flowLength} + + Verify Identity + {attempt.status === 'failed' && ( + {errorMessage} + )} + Multi-factor type + + {({ validator }) => ( +
onReauthenticate(e, validator)}> + { + setMfaOption(o as Auth2faType); + clearAttempt(); + }} + /> + {mfaOption === 'otp' && ( + + )} + Verify my identity + Cancel + + )} +
+
+ ); +} + +interface CreatePasskeyStepProps extends StepComponentProps { + privilegeToken: string; + onClose(): void; + onPasskeyCreated(c: Credential): void; +} + +function CreatePasskeyStep({ + prev, + next, + refCallback, + stepIndex, + flowLength, + privilegeToken, + onClose, + onPasskeyCreated, +}: CreatePasskeyStepProps) { + const ctx = useTeleport(); + const createPasskeyAttempt = useAttempt(); + + const onCreate = () => { + createPasskeyAttempt.run(async () => { + const credential = await ctx.mfaService.createNewWebAuthnDevice({ + tokenId: privilegeToken, + deviceUsage: 'passwordless', + }); + onPasskeyCreated(credential); + next(); + }); + }; + + return ( +
+ + Step {stepIndex + 1} of {flowLength} + + Create a Passkey + {createPasskeyAttempt.attempt.status === 'failed' && ( + {createPasskeyAttempt.attempt.statusText} + )} + + Create a passkey + {stepIndex === 0 ? ( + Cancel + ) : ( + Back + )} +
+ ); +} + +interface SaveKeyStepProps extends StepComponentProps { + privilegeToken: string; + credential: Credential; + onSuccess(): void; +} + +function SavePasskeyStep({ + refCallback, + prev, + stepIndex, + flowLength, + privilegeToken, + credential, + onSuccess, +}: SaveKeyStepProps) { + const ctx = useTeleport(); + const saveAttempt = useAttempt(); + const [deviceName, setDeviceName] = useState(''); + + const onSave = (e: FormEvent, validator: Validator) => { + e.preventDefault(); + if (!validator.validate()) return; + saveAttempt.run(async () => { + await ctx.mfaService.saveNewWebAuthnDevice({ + addRequest: { + tokenId: privilegeToken, + deviceUsage: 'passwordless', + deviceName, + }, + credential, + }); + onSuccess(); + }); + }; + + const onNameChange = (e: React.ChangeEvent) => { + setDeviceName(e.target.value); + }; + + return ( +
+ + Step {stepIndex + 1} of {flowLength} + + Save the Passkey + {saveAttempt.attempt.status === 'failed' && ( + {saveAttempt.attempt.statusText} + )} + + {({ validator }) => ( +
onSave(e, validator)}> + + Save the Passkey + Back + + )} +
+
+ ); +} + +function PasskeyBlurb() { + return ( + +

+ Teleport supports passkeys, a password replacement that validates your + identity using touch, facial recognition, a device password, or a PIN. +

+

+ Passkeys can be used to sign in as a simple and secure alternative to + your password and multi-factor credentials. +

+
+ ); +} diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/index.ts b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/index.ts new file mode 100644 index 0000000000000..e050d0a84f476 --- /dev/null +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export { AddAuthDeviceWizard } from './AddAuthDeviceWizard'; diff --git a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts index 1194385cfeb08..e743caf2280a6 100644 --- a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts +++ b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts @@ -33,6 +33,7 @@ export default function useManageDevices(ctx: Ctx) { const [restrictNewDeviceUsage, setRestrictNewDeviceUsage] = useState< DeviceUsage | undefined >(undefined); + const [passkeyWizardVisible, setPasskeyWizardVisible] = useState(false); // This is a restricted privilege token that can only be used to add a device, in case // the user has no devices yet and thus can't authenticate using the ReAuthenticate dialog @@ -41,6 +42,7 @@ export default function useManageDevices(ctx: Ctx) { const isReAuthenticateVisible = !token && isDialogVisible; const isRemoveDeviceVisible = token && deviceToRemove && isDialogVisible; const isAddDeviceVisible = token && !deviceToRemove && isDialogVisible; + const isReauthenticationRequired = !token; function fetchDevices() { fetchDevicesAttempt.run(() => @@ -69,6 +71,25 @@ export default function useManageDevices(ctx: Ctx) { } } + function onAddPasskey() { + if (devices.length === 0) { + createRestrictedTokenAttempt.run(() => + auth.createRestrictedPrivilegeToken().then(token => { + setToken(token); + setPasskeyWizardVisible(true); + }) + ); + } else { + setPasskeyWizardVisible(true); + } + } + + function onPasskeyAdded() { + fetchDevices(); + setPasskeyWizardVisible(false); + setToken(null); + } + function hideAddDevice() { setIsDialogVisible(false); setToken(null); @@ -89,6 +110,10 @@ export default function useManageDevices(ctx: Ctx) { setIsDialogVisible(false); } + function closePasskeyWizard() { + setPasskeyWizardVisible(false); + } + useEffect(() => fetchDevices(), []); return { @@ -97,6 +122,8 @@ export default function useManageDevices(ctx: Ctx) { setToken, onAddDevice, onRemoveDevice, + onAddPasskey, + onPasskeyAdded, deviceToRemove, fetchDevices, removeDevice, @@ -105,9 +132,12 @@ export default function useManageDevices(ctx: Ctx) { isReAuthenticateVisible, isAddDeviceVisible, isRemoveDeviceVisible, + isReauthenticationRequired, + passkeyWizardVisible, hideReAuthenticate, hideAddDevice, hideRemoveDevice, + closePasskeyWizard, mfaDisabled: cfg.getAuth2faType() === 'off', restrictNewDeviceUsage, }; diff --git a/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.story.tsx b/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.story.tsx index efee063e654bc..3eadeddf4a3f0 100644 --- a/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.story.tsx +++ b/web/packages/teleport/src/Welcome/NewCredentials/NewCredentials.story.tsx @@ -165,6 +165,8 @@ const makeNewMfaDeviceProps = ( hasTransitionEnded: true, password: '', updatePassword: () => null, + stepIndex: 0, + flowLength: 1, }, overrides ); diff --git a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts index 71d13841fcba2..bc995de22352a 100644 --- a/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts +++ b/web/packages/teleport/src/components/ReAuthenticate/useReAuthenticate.ts @@ -105,7 +105,7 @@ export default function useReAuthenticate(props: Props) { const defaultActionText = 'performing this action'; type BaseProps = { - onClose: () => void; + onClose?: () => void; /** * The text that will be appended to the text in the re-authentication dialog. * diff --git a/web/packages/teleport/src/services/mfa/mfa.ts b/web/packages/teleport/src/services/mfa/mfa.ts index b3012f7aacb59..fbe430b4d4b52 100644 --- a/web/packages/teleport/src/services/mfa/mfa.ts +++ b/web/packages/teleport/src/services/mfa/mfa.ts @@ -24,6 +24,8 @@ import { MfaDevice, AddNewTotpDeviceRequest, AddNewHardwareDeviceRequest, + CreateNewHardwareDeviceRequest, + SaveNewHardwareDeviceRequest, } from './types'; import makeMfaDevice from './makeMfaDevice'; @@ -48,7 +50,7 @@ class MfaService { return api.post(cfg.api.mfaDevicesPath, req); } - addNewWebauthnDevice(req: AddNewHardwareDeviceRequest) { + createNewWebAuthnDevice(req: CreateNewHardwareDeviceRequest) { return auth .checkWebauthnSupport() .then(() => @@ -62,15 +64,24 @@ class MfaService { navigator.credentials.create({ publicKey: res.webauthnPublicKey, }) - ) - .then(res => { - const request = { - ...req, - webauthnRegisterResponse: makeWebauthnCreationResponse(res), - }; + ); + } + + saveNewWebAuthnDevice(req: SaveNewHardwareDeviceRequest) { + return auth.checkWebauthnSupport().then(() => { + const request = { + ...req.addRequest, + webauthnRegisterResponse: makeWebauthnCreationResponse(req.credential), + }; - return api.post(cfg.api.mfaDevicesPath, request); - }); + return api.post(cfg.api.mfaDevicesPath, request); + }); + } + + addNewWebauthnDevice(req: AddNewHardwareDeviceRequest) { + return this.createNewWebAuthnDevice(req).then(credential => { + this.saveNewWebAuthnDevice({ addRequest: req, credential }); + }); } } diff --git a/web/packages/teleport/src/services/mfa/types.ts b/web/packages/teleport/src/services/mfa/types.ts index f009559a072dd..99b20d383b99b 100644 --- a/web/packages/teleport/src/services/mfa/types.ts +++ b/web/packages/teleport/src/services/mfa/types.ts @@ -33,12 +33,20 @@ export type AddNewTotpDeviceRequest = { secondFactorToken: string; }; -export type AddNewHardwareDeviceRequest = { +export type CreateNewHardwareDeviceRequest = { tokenId: string; - deviceName: string; deviceUsage?: DeviceUsage; }; +export type AddNewHardwareDeviceRequest = CreateNewHardwareDeviceRequest & { + deviceName: string; +}; + +export type SaveNewHardwareDeviceRequest = { + addRequest: AddNewHardwareDeviceRequest; + credential: Credential; +}; + export type DeviceType = 'totp' | 'webauthn'; // DeviceUsage is the intended usage of the device (MFA, Passwordless, etc). From 700a7863ca5d120429a3d962588df1ced706b0d3 Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Mon, 19 Feb 2024 10:40:13 +0100 Subject: [PATCH 3/5] Add capability of adding MFA to the new auth device wizard (#38260) * Add capability of adding MFA to the new auth device wizard * Review * Review * License --- .../teleport/src/Account/Account.story.tsx | 1 - web/packages/teleport/src/Account/Account.tsx | 14 +- .../AddAuthDeviceWizard.story.tsx | 154 ++++++++++ .../AddAuthDeviceWizard.test.tsx | 106 +++++-- .../AddAuthDeviceWizard.tsx | 285 ++++++++++++++---- .../Account/ManageDevices/useManageDevices.ts | 25 +- 6 files changed, 476 insertions(+), 109 deletions(-) create mode 100644 web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.story.tsx diff --git a/web/packages/teleport/src/Account/Account.story.tsx b/web/packages/teleport/src/Account/Account.story.tsx index fa9ccb5d17439..8a5a3372a6a3e 100644 --- a/web/packages/teleport/src/Account/Account.story.tsx +++ b/web/packages/teleport/src/Account/Account.story.tsx @@ -161,7 +161,6 @@ const props: AccountProps = { residentKey: false, }, ], - onAddPasskey: () => {}, onPasskeyAdded: () => {}, isReauthenticationRequired: false, passkeyWizardVisible: false, diff --git a/web/packages/teleport/src/Account/Account.tsx b/web/packages/teleport/src/Account/Account.tsx index a34acdea6cd77..3ebadd6aca1b6 100644 --- a/web/packages/teleport/src/Account/Account.tsx +++ b/web/packages/teleport/src/Account/Account.tsx @@ -34,8 +34,6 @@ import { MfaChallengeScope } from 'teleport/services/auth/auth'; import cfg from 'teleport/config'; -import { storageService } from 'teleport/services/storageService'; - import { AuthDeviceList } from './ManageDevices/AuthDeviceList/AuthDeviceList'; import useManageDevices, { State as ManageDevicesState, @@ -45,9 +43,6 @@ import { ActionButton, Header } from './Header'; import { PasswordBox } from './PasswordBox'; import { AddAuthDeviceWizard } from './ManageDevices/AddAuthDeviceWizard'; -const useNewAddAuthDeviceDialog = - storageService.isNewAddAuthDeviceDialogEnabled(); - export interface EnterpriseComponentProps { // TODO(bl-nero): Consider moving the notifications to its own store and // unifying them between this screen and the unified resources screen. @@ -110,7 +105,6 @@ export function Account({ setToken, onAddDevice, onRemoveDevice, - onAddPasskey, onPasskeyAdded, deviceToRemove, fetchDevices, @@ -206,11 +200,7 @@ export function Account({ ? 'Passwordless authentication is disabled' : '' } - onClick={() => - useNewAddAuthDeviceDialog - ? onAddPasskey() - : onAddDevice('passwordless') - } + onClick={() => onAddDevice('passwordless')} > Add a Passkey @@ -293,6 +283,8 @@ export function Account({ {passkeyWizardVisible && ( . + */ + +import React from 'react'; + +import { Auth2faType } from 'shared/services'; + +import Dialog from 'design/Dialog'; + +import { initialize, mswLoader } from 'msw-storybook-addon'; + +import { rest } from 'msw'; + +import { DeviceUsage } from 'teleport/services/mfa'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { ContextProvider } from 'teleport/index'; + +import cfg from 'teleport/config'; + +import { + CreateDeviceStep, + ReauthenticateStep, + SaveDeviceStep, +} from './AddAuthDeviceWizard'; + +export default { + title: 'teleport/Account/Manage Devices/Add Device Wizard', + loaders: [mswLoader], + decorators: [ + Story => { + const ctx = createTeleportContext(); + return ( + + ({ width: '650px' })}> + + + + ); + }, + ], +}; + +initialize(); + +export function Reauthenticate() { + return ; +} + +export function CreatePasskey() { + return ; +} + +export function CreateMfaHardwareDevice() { + return ( + + ); +} + +export function CreateMfaAppQrCodeLoading() { + return ; +} +CreateMfaAppQrCodeLoading.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getMfaCreateRegistrationChallengeUrl('privilege-token'), + (req, res, ctx) => res(ctx.delay('infinite')) + ), + ], + }, +}; + +export function CreateMfaAppQrCodeFailed() { + return ; +} +CreateMfaAppQrCodeFailed.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getMfaCreateRegistrationChallengeUrl('privilege-token'), + (req, res, ctx) => res(ctx.status(500)) + ), + ], + }, +}; + +const dummyQrCode = + 'iVBORw0KGgoAAAANSUhEUgAAAB0AAAAdAQMAAABsXfVMAAAABlBMVEUAAAD///+l2Z/dAAAAAnRSTlP//8i138cAAAAJcEhZcwAACxIAAAsSAdLdfvwAAABrSURBVAiZY/gPBAxoxAcxh3qG71fv1zN8iQ8EEReBRACQ+H4ZKPZBFCj7/3v9f4aPU9vqGX4kFtUzfG5mBLK2aNUz/PM3AsmqAk2RNQTquLYLqDdG/z/QlGAgES4CFLu4GygrXF2Pbi+IAADZqFQFAjXZWgAAAABJRU5ErkJggg=='; + +export function CreateMfaApp() { + return ; +} +CreateMfaApp.parameters = { + msw: { + handlers: [ + rest.post( + cfg.getMfaCreateRegistrationChallengeUrl('privilege-token'), + (req, res, ctx) => res(ctx.json({ totp: { qrCode: dummyQrCode } })) + ), + ], + }, +}; + +export function SavePasskey() { + return ; +} + +export function SaveMfaHardwareDevice() { + return ( + + ); +} + +export function SaveMfaAuthenticatorApp() { + return ; +} + +const stepProps = { + // StepComponentProps + next: () => {}, + prev: () => {}, + hasTransitionEnded: true, + stepIndex: 0, + flowLength: 1, + refCallback: () => {}, + + // Other props + privilegeToken: 'privilege-token', + usage: 'passwordless' as DeviceUsage, + auth2faType: 'optional' as Auth2faType, + credential: { id: 'cred-id', type: 'public-key' }, + newMfaDeviceType: 'webauthn' as Auth2faType, + onNewMfaDeviceTypeChange: () => {}, + onDeviceCreated: () => {}, + onAuthenticated: () => {}, + onClose: () => {}, + onPasskeyCreated: () => {}, + onSuccess: () => {}, +}; diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx index 03114b5efef7a..2e19beca398ab 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.test.tsx @@ -16,29 +16,35 @@ * along with this program. If not, see . */ -import { render, screen, userEvent } from 'design/utils/testing'; +import { render, screen } from 'design/utils/testing'; import React from 'react'; import { within } from '@testing-library/react'; +import { userEvent, UserEvent } from '@testing-library/user-event'; import TeleportContext from 'teleport/teleportContext'; import { ContextProvider } from 'teleport'; -import MfaService from 'teleport/services/mfa'; -import cfg from 'teleport/config'; +import MfaService, { DeviceUsage } from 'teleport/services/mfa'; import auth from 'teleport/services/auth/auth'; import { AddAuthDeviceWizard } from '.'; const dummyCredential: Credential = { id: 'cred-id', type: 'public-key' }; +let ctx: TeleportContext; +let user: UserEvent; +let onSuccess: jest.Mock; beforeEach(() => { - jest.replaceProperty(cfg.auth, 'second_factor', 'optional'); + ctx = new TeleportContext(); + user = userEvent.setup(); + onSuccess = jest.fn(); + jest .spyOn(MfaService.prototype, 'createNewWebAuthnDevice') .mockResolvedValueOnce(dummyCredential); jest .spyOn(MfaService.prototype, 'saveNewWebAuthnDevice') - .mockResolvedValueOnce('some-credential'); + .mockResolvedValueOnce(undefined); jest .spyOn(auth, 'createPrivilegeTokenWithWebauthn') .mockResolvedValueOnce('webauthn-privilege-token'); @@ -47,22 +53,29 @@ beforeEach(() => { .mockImplementationOnce(token => Promise.resolve(`totp-privilege-token-${token}`) ); + jest.spyOn(auth, 'createMfaRegistrationChallenge').mockResolvedValueOnce({ + qrCode: 'dummy-qr-code', + webauthnPublicKey: {} as PublicKeyCredentialCreationOptions, + }); + jest + .spyOn(MfaService.prototype, 'addNewTotpDevice') + .mockResolvedValueOnce(undefined); }); afterEach(jest.resetAllMocks); function TestWizard({ - ctx, privilegeToken, - onSuccess, + usage, }: { - ctx: TeleportContext; privilegeToken?: string; - onSuccess(): void; + usage: DeviceUsage; }) { return ( {}} onSuccess={onSuccess} @@ -73,15 +86,8 @@ function TestWizard({ describe('flow without reauthentication', () => { test('adds a passkey', async () => { - const ctx = new TeleportContext(); - const user = userEvent.setup(); - const onSuccess = jest.fn(); render( - + ); const createStep = within(screen.getByTestId('create-step')); @@ -108,14 +114,67 @@ describe('flow without reauthentication', () => { }); expect(onSuccess).toHaveBeenCalled(); }); + + test('adds a WebAuthn MFA', async () => { + render(); + + const createStep = within(screen.getByTestId('create-step')); + await user.click(createStep.getByLabelText('Hardware Device')); + await user.click( + createStep.getByRole('button', { name: 'Create an MFA method' }) + ); + expect(ctx.mfaService.createNewWebAuthnDevice).toHaveBeenCalledWith({ + tokenId: 'privilege-token', + deviceUsage: 'mfa', + }); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('MFA Method Name'), 'new-mfa'); + await user.click( + saveStep.getByRole('button', { name: 'Save the MFA method' }) + ); + expect(ctx.mfaService.saveNewWebAuthnDevice).toHaveBeenCalledWith({ + credential: dummyCredential, + addRequest: { + deviceName: 'new-mfa', + deviceUsage: 'mfa', + tokenId: 'privilege-token', + }, + }); + expect(onSuccess).toHaveBeenCalled(); + }); + + test('adds an authenticator app', async () => { + render(); + + const createStep = within(screen.getByTestId('create-step')); + await user.click(createStep.getByLabelText('Authenticator App')); + expect(createStep.getByRole('img')).toHaveAttribute( + 'src', + 'data:image/png;base64,dummy-qr-code' + ); + await user.click( + createStep.getByRole('button', { name: 'Create an MFA method' }) + ); + + const saveStep = within(screen.getByTestId('save-step')); + await user.type(saveStep.getByLabelText('MFA Method Name'), 'new-mfa'); + await user.type(saveStep.getByLabelText(/Authenticator Code/), '345678'); + await user.click( + saveStep.getByRole('button', { name: 'Save the MFA method' }) + ); + expect(ctx.mfaService.addNewTotpDevice).toHaveBeenCalledWith({ + tokenId: 'privilege-token', + secondFactorToken: '345678', + deviceName: 'new-mfa', + }); + expect(onSuccess).toHaveBeenCalled(); + }); }); describe('flow with reauthentication', () => { test('adds a passkey with WebAuthn reauthentication', async () => { - const ctx = new TeleportContext(); - const user = userEvent.setup(); - const onSuccess = jest.fn(); - render(); + render(); const reauthenticateStep = within( screen.getByTestId('reauthenticate-step') @@ -148,10 +207,7 @@ describe('flow with reauthentication', () => { }); test('adds a passkey with OTP reauthentication', async () => { - const ctx = new TeleportContext(); - const user = userEvent.setup(); - const onSuccess = jest.fn(); - render(); + render(); const reauthenticateStep = within( screen.getByTestId('reauthenticate-step') diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx index aa426ac0e8cad..41c709fc020b4 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx @@ -19,34 +19,47 @@ import { OutlineDanger } from 'design/Alert/Alert'; import { ButtonPrimary, ButtonSecondary } from 'design/Button'; import Dialog from 'design/Dialog'; +import Flex from 'design/Flex'; +import Image from 'design/Image'; +import Indicator from 'design/Indicator'; +import Link from 'design/Link'; import { SingleRowBox } from 'design/MultiRowBox'; import { RadioGroup } from 'design/RadioGroup'; import { StepComponentProps, StepSlider } from 'design/StepSlider'; import Text from 'design/Text'; -import React, { useState, FormEvent } from 'react'; +import React, { useState, useEffect, FormEvent } from 'react'; import FieldInput from 'shared/components/FieldInput'; import Validation, { Validator } from 'shared/components/Validation'; import { requiredField } from 'shared/components/Validation/rules'; +import { useAsync } from 'shared/hooks/useAsync'; import useAttempt from 'shared/hooks/useAttemptNext'; import { Auth2faType } from 'shared/services'; -import createMfaOptions from 'shared/utils/createMfaOptions'; +import createMfaOptions, { MfaOption } from 'shared/utils/createMfaOptions'; import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate'; -import { MfaChallengeScope } from 'teleport/services/auth/auth'; +import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; +import { DeviceUsage } from 'teleport/services/mfa'; import useTeleport from 'teleport/useTeleport'; interface AddAuthDeviceWizardProps { + /** Indicates usage of the device to be added: MFA or a passkey. */ + usage: DeviceUsage; + /** MFA type setting, as configured in the cluster's configuration. */ + auth2faType: Auth2faType; + /** + * A privilege token that may have been created previously; if present, the + * reauthentication step will be skipped. + */ privilegeToken?: string; onClose(): void; onSuccess(): void; } -/** - * A wizard for adding MFA and passkey devices. Currently only supports - * passkeys. - */ +/** A wizard for adding MFA and passkey devices. */ export function AddAuthDeviceWizard({ privilegeToken: privilegeTokenProp = '', + usage, + auth2faType, onClose, onSuccess, }: AddAuthDeviceWizardProps) { @@ -54,6 +67,14 @@ export function AddAuthDeviceWizard({ const [privilegeToken, setPrivilegeToken] = useState(privilegeTokenProp); const [credential, setCredential] = useState(null); + const mfaOptions = createMfaOptions({ + auth2faType, + required: true, + }); + + /** A new MFA device type, irrelevant if usage === 'passkey'. */ + const [newMfaDeviceType, setNewMfaDeviceType] = useState(mfaOptions[0].value); + return ( ); } const wizardFlows = { - withReauthentication: [ - ReauthenticateStep, - CreatePasskeyStep, - SavePasskeyStep, - ], - withoutReauthentication: [CreatePasskeyStep, SavePasskeyStep], + withReauthentication: [ReauthenticateStep, CreateDeviceStep, SaveDeviceStep], + withoutReauthentication: [CreateDeviceStep, SaveDeviceStep], }; interface ReauthenticateStepProps extends StepComponentProps { + auth2faType: Auth2faType; onAuthenticated(privilegeToken: string): void; onClose(): void; } -function ReauthenticateStep({ +export function ReauthenticateStep({ next, refCallback, stepIndex, flowLength, + auth2faType, onClose, onAuthenticated: onAuthenticatedProp, }: ReauthenticateStepProps) { @@ -105,20 +128,13 @@ function ReauthenticateStep({ onAuthenticatedProp(privilegeToken); next(); }; - const { - attempt, - auth2faType, - preferredMfaType, - clearAttempt, - submitWithTotp, - submitWithWebauthn, - } = useReAuthenticate({ - onAuthenticated, - challengeScope, - }); + const { attempt, clearAttempt, submitWithTotp, submitWithWebauthn } = + useReAuthenticate({ + onAuthenticated, + challengeScope, + }); const mfaOptions = createMfaOptions({ auth2faType, - preferredType: preferredMfaType, required: true, }); @@ -167,6 +183,7 @@ function ReauthenticateStep({ name="mfaOption" options={mfaOptions} value={mfaOption} + autoFocus flexDirection="row" gap={3} onChange={o => { @@ -182,7 +199,6 @@ function ReauthenticateStep({ autoComplete="one-time-code" value={authCode} placeholder="123 456" - autoFocus onChange={onAuthCodeChanged} readonly={attempt.status === 'processing'} /> @@ -196,34 +212,45 @@ function ReauthenticateStep({ ); } -interface CreatePasskeyStepProps extends StepComponentProps { +interface CreateDeviceStepProps extends StepComponentProps { + usage: DeviceUsage; + auth2faType: Auth2faType; privilegeToken: string; + newMfaDeviceType: Auth2faType; + onNewMfaDeviceTypeChange(o: Auth2faType): void; onClose(): void; - onPasskeyCreated(c: Credential): void; + onDeviceCreated(c: Credential): void; } -function CreatePasskeyStep({ +export function CreateDeviceStep({ prev, next, refCallback, stepIndex, flowLength, + usage, + auth2faType, privilegeToken, + newMfaDeviceType, + onNewMfaDeviceTypeChange, onClose, - onPasskeyCreated, -}: CreatePasskeyStepProps) { + onDeviceCreated, +}: CreateDeviceStepProps) { const ctx = useTeleport(); const createPasskeyAttempt = useAttempt(); - const onCreate = () => { - createPasskeyAttempt.run(async () => { - const credential = await ctx.mfaService.createNewWebAuthnDevice({ - tokenId: privilegeToken, - deviceUsage: 'passwordless', + if (usage === 'passwordless' || newMfaDeviceType === 'webauthn') { + createPasskeyAttempt.run(async () => { + const credential = await ctx.mfaService.createNewWebAuthnDevice({ + tokenId: privilegeToken, + deviceUsage: usage, + }); + onDeviceCreated(credential); + next(); }); - onPasskeyCreated(credential); + } else { next(); - }); + } }; return ( @@ -231,12 +258,24 @@ function CreatePasskeyStep({ Step {stepIndex + 1} of {flowLength} - Create a Passkey + + {usage === 'passwordless' ? 'Create a Passkey' : 'Create an MFA Method'} + {createPasskeyAttempt.attempt.status === 'failed' && ( {createPasskeyAttempt.attempt.statusText} )} - - Create a passkey + {usage === 'passwordless' && } + {usage === 'mfa' && ( + + )} + + {usage === 'passwordless' ? 'Create a passkey' : 'Create an MFA method'} + {stepIndex === 0 ? ( Cancel ) : ( @@ -246,51 +285,163 @@ function CreatePasskeyStep({ ); } +function CreateMfaBox({ + auth2faType, + newMfaDeviceType, + privilegeToken, + onNewMfaDeviceTypeChange, +}: { + auth2faType: Auth2faType; + newMfaDeviceType: Auth2faType; + privilegeToken: string; + onNewMfaDeviceTypeChange(o: Auth2faType): void; +}) { + const mfaOptions = createMfaOptions({ + auth2faType, + required: true, + }).map((o: MfaOption) => + // Be more specific about the WebAuthn device type (it's not a passkey). + o.value === 'webauthn' ? { ...o, label: 'Hardware Device' } : o + ); + + return ( + <> + Multi-factor type + { + onNewMfaDeviceTypeChange(o as Auth2faType); + }} + /> + {newMfaDeviceType === 'otp' && ( + + )} + + ); +} + +function QrCodeBox({ privilegeToken }: { privilegeToken: string }) { + const [fetchQrCodeAttempt, fetchQrCode] = useAsync((privilegeToken: string) => + auth.createMfaRegistrationChallenge(privilegeToken, 'totp') + ); + + useEffect(() => { + fetchQrCode(privilegeToken); + }, []); + + return ( + props.theme.colors.interactive.tonal.neutral[0]}; + `} + > + + {fetchQrCodeAttempt.status === 'error' && ( + + Could not load the QR code. {fetchQrCodeAttempt.statusText} + + )} + {fetchQrCodeAttempt.status === 'processing' && } + {fetchQrCodeAttempt.status === 'success' && ( + + )} + + + Scan the QR Code with any authenticator app. +
+ We recommend{' '} + + Authy + + . +
+
+ ); +} + interface SaveKeyStepProps extends StepComponentProps { privilegeToken: string; credential: Credential; + usage: DeviceUsage; + newMfaDeviceType: Auth2faType; onSuccess(): void; } -function SavePasskeyStep({ +export function SaveDeviceStep({ refCallback, prev, stepIndex, flowLength, privilegeToken, credential, + usage, + newMfaDeviceType, onSuccess, }: SaveKeyStepProps) { const ctx = useTeleport(); const saveAttempt = useAttempt(); const [deviceName, setDeviceName] = useState(''); + const [authCode, setAuthCode] = useState(''); const onSave = (e: FormEvent, validator: Validator) => { e.preventDefault(); if (!validator.validate()) return; - saveAttempt.run(async () => { - await ctx.mfaService.saveNewWebAuthnDevice({ - addRequest: { + if (usage === 'passwordless' || newMfaDeviceType === 'webauthn') { + saveAttempt.run(async () => { + await ctx.mfaService.saveNewWebAuthnDevice({ + addRequest: { + tokenId: privilegeToken, + deviceUsage: usage, + deviceName, + }, + credential, + }); + onSuccess(); + }); + } else { + saveAttempt.run(async () => { + await ctx.mfaService.addNewTotpDevice({ tokenId: privilegeToken, - deviceUsage: 'passwordless', + secondFactorToken: authCode, deviceName, - }, - credential, + }); + onSuccess(); }); - onSuccess(); - }); + } }; const onNameChange = (e: React.ChangeEvent) => { setDeviceName(e.target.value); }; + const onAuthCodeChanged = (e: React.ChangeEvent) => { + setAuthCode(e.target.value); + }; + return (
Step {stepIndex + 1} of {flowLength} - Save the Passkey + + {usage === 'passwordless' ? 'Save the Passkey' : 'Save the MFA method'} + {saveAttempt.attempt.status === 'failed' && ( {saveAttempt.attempt.statusText} )} @@ -298,7 +449,11 @@ function SavePasskeyStep({ {({ validator }) => (
onSave(e, validator)}> - Save the Passkey + + {usage === 'mfa' && newMfaDeviceType === 'otp' && ( + + )} + + {usage === 'passwordless' + ? 'Save the Passkey' + : 'Save the MFA method'} + Back )} diff --git a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts index e743caf2280a6..9335729de8b1d 100644 --- a/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts +++ b/web/packages/teleport/src/Account/ManageDevices/useManageDevices.ts @@ -23,6 +23,10 @@ import Ctx from 'teleport/teleportContext'; import cfg from 'teleport/config'; import auth from 'teleport/services/auth'; import { DeviceUsage, MfaDevice } from 'teleport/services/mfa'; +import { storageService } from 'teleport/services/storageService'; + +const useNewAddAuthDeviceDialog = + storageService.isNewAddAuthDeviceDialogEnabled(); export default function useManageDevices(ctx: Ctx) { const [devices, setDevices] = useState([]); @@ -58,29 +62,19 @@ export default function useManageDevices(ctx: Ctx) { } function onAddDevice(restrictUsage?: DeviceUsage) { + const showDialog = useNewAddAuthDeviceDialog + ? setPasskeyWizardVisible + : setIsDialogVisible; setRestrictNewDeviceUsage(restrictUsage); if (devices.length === 0) { createRestrictedTokenAttempt.run(() => auth.createRestrictedPrivilegeToken().then(token => { setToken(token); - setIsDialogVisible(true); - }) - ); - } else { - setIsDialogVisible(true); - } - } - - function onAddPasskey() { - if (devices.length === 0) { - createRestrictedTokenAttempt.run(() => - auth.createRestrictedPrivilegeToken().then(token => { - setToken(token); - setPasskeyWizardVisible(true); + showDialog(true); }) ); } else { - setPasskeyWizardVisible(true); + showDialog(true); } } @@ -122,7 +116,6 @@ export default function useManageDevices(ctx: Ctx) { setToken, onAddDevice, onRemoveDevice, - onAddPasskey, onPasskeyAdded, deviceToRemove, fetchDevices, From 1d4225fd73a342d26750f41d1952b7322886473d Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Mon, 19 Feb 2024 12:45:54 +0100 Subject: [PATCH 4/5] Make the auth device wizard look pretty (#38353) * Add capability of adding MFA to the new auth device wizard * Review * Review * Make the auth device wizard look pretty * License * Review * Fix lint --- web/packages/design/src/Icon/Icons.story.tsx | 3 + .../src/Icon/Icons/DeviceMobileCamera.tsx | 73 +++++++++ .../design/src/Icon/Icons/Fingerprint.tsx | 105 ++++++++++++ .../src/Icon/Icons/FingerprintSimple.tsx | 89 ++++++++++ .../design/src/Icon/Icons/MobileCamera.tsx | 69 ++++++++ .../design/src/Icon/Icons/UserFocus.tsx | 72 +++++++++ .../src/Icon/assets/DeviceMobileCamera.svg | 4 + .../src/Icon/assets/FingerprintSimple.svg | 8 + .../design/src/Icon/assets/UserFocus.svg | 7 + web/packages/design/src/Icon/index.ts | 3 + .../AddAuthDeviceWizard.tsx | 153 +++++++++++++----- 11 files changed, 546 insertions(+), 40 deletions(-) create mode 100644 web/packages/design/src/Icon/Icons/DeviceMobileCamera.tsx create mode 100644 web/packages/design/src/Icon/Icons/Fingerprint.tsx create mode 100644 web/packages/design/src/Icon/Icons/FingerprintSimple.tsx create mode 100644 web/packages/design/src/Icon/Icons/MobileCamera.tsx create mode 100644 web/packages/design/src/Icon/Icons/UserFocus.tsx create mode 100644 web/packages/design/src/Icon/assets/DeviceMobileCamera.svg create mode 100644 web/packages/design/src/Icon/assets/FingerprintSimple.svg create mode 100644 web/packages/design/src/Icon/assets/UserFocus.svg diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx index 864e6de278d41..71ee6cb18730d 100644 --- a/web/packages/design/src/Icon/Icons.story.tsx +++ b/web/packages/design/src/Icon/Icons.story.tsx @@ -101,6 +101,7 @@ export const Icons = () => ( + @@ -111,6 +112,7 @@ export const Icons = () => ( + @@ -195,6 +197,7 @@ export const Icons = () => ( + diff --git a/web/packages/design/src/Icon/Icons/DeviceMobileCamera.tsx b/web/packages/design/src/Icon/Icons/DeviceMobileCamera.tsx new file mode 100644 index 0000000000000..09b75a91c8fb9 --- /dev/null +++ b/web/packages/design/src/Icon/Icons/DeviceMobileCamera.tsx @@ -0,0 +1,73 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function DeviceMobileCamera({ + size = 24, + color, + ...otherProps +}: IconProps) { + return ( + + + + + ); +} diff --git a/web/packages/design/src/Icon/Icons/Fingerprint.tsx b/web/packages/design/src/Icon/Icons/Fingerprint.tsx new file mode 100644 index 0000000000000..58150517f954d --- /dev/null +++ b/web/packages/design/src/Icon/Icons/Fingerprint.tsx @@ -0,0 +1,105 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function Fingerprint({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + + + + + ); +} diff --git a/web/packages/design/src/Icon/Icons/FingerprintSimple.tsx b/web/packages/design/src/Icon/Icons/FingerprintSimple.tsx new file mode 100644 index 0000000000000..c192bb20419dd --- /dev/null +++ b/web/packages/design/src/Icon/Icons/FingerprintSimple.tsx @@ -0,0 +1,89 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function FingerprintSimple({ + size = 24, + color, + ...otherProps +}: IconProps) { + return ( + + + + + + + + + ); +} diff --git a/web/packages/design/src/Icon/Icons/MobileCamera.tsx b/web/packages/design/src/Icon/Icons/MobileCamera.tsx new file mode 100644 index 0000000000000..13523914ced5f --- /dev/null +++ b/web/packages/design/src/Icon/Icons/MobileCamera.tsx @@ -0,0 +1,69 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function MobileCamera({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + ); +} diff --git a/web/packages/design/src/Icon/Icons/UserFocus.tsx b/web/packages/design/src/Icon/Icons/UserFocus.tsx new file mode 100644 index 0000000000000..5eed01b5973d4 --- /dev/null +++ b/web/packages/design/src/Icon/Icons/UserFocus.tsx @@ -0,0 +1,72 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function UserFocus({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + + + + ); +} diff --git a/web/packages/design/src/Icon/assets/DeviceMobileCamera.svg b/web/packages/design/src/Icon/assets/DeviceMobileCamera.svg new file mode 100644 index 0000000000000..4594d5dae93c7 --- /dev/null +++ b/web/packages/design/src/Icon/assets/DeviceMobileCamera.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/packages/design/src/Icon/assets/FingerprintSimple.svg b/web/packages/design/src/Icon/assets/FingerprintSimple.svg new file mode 100644 index 0000000000000..dced9a44ffa09 --- /dev/null +++ b/web/packages/design/src/Icon/assets/FingerprintSimple.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/web/packages/design/src/Icon/assets/UserFocus.svg b/web/packages/design/src/Icon/assets/UserFocus.svg new file mode 100644 index 0000000000000..134d68cd3363d --- /dev/null +++ b/web/packages/design/src/Icon/assets/UserFocus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts index ec4c300003fe0..7c057234bdede 100644 --- a/web/packages/design/src/Icon/index.ts +++ b/web/packages/design/src/Icon/index.ts @@ -89,6 +89,7 @@ export { CreditCard } from './Icons/CreditCard'; export { Cross } from './Icons/Cross'; export { Database } from './Icons/Database'; export { Desktop } from './Icons/Desktop'; +export { DeviceMobileCamera } from './Icons/DeviceMobileCamera'; export { Devices } from './Icons/Devices'; export { Download } from './Icons/Download'; export { Earth } from './Icons/Earth'; @@ -99,6 +100,7 @@ export { EnvelopeOpen } from './Icons/EnvelopeOpen'; export { EqualizersVertical } from './Icons/EqualizersVertical'; export { Expand } from './Icons/Expand'; export { Facebook } from './Icons/Facebook'; +export { FingerprintSimple } from './Icons/FingerprintSimple'; export { Floppy } from './Icons/Floppy'; export { FlowArrow } from './Icons/FlowArrow'; export { FolderPlus } from './Icons/FolderPlus'; @@ -183,6 +185,7 @@ export { UsbDrive } from './Icons/UsbDrive'; export { User } from './Icons/User'; export { UserAdd } from './Icons/UserAdd'; export { UserCircleGear } from './Icons/UserCircleGear'; +export { UserFocus } from './Icons/UserFocus'; export { UserIdBadge } from './Icons/UserIdBadge'; export { UserList } from './Icons/UserList'; export { Users } from './Icons/Users'; diff --git a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx index 41c709fc020b4..5f7db352b86c5 100644 --- a/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/AddAuthDeviceWizard/AddAuthDeviceWizard.tsx @@ -17,13 +17,14 @@ */ import { OutlineDanger } from 'design/Alert/Alert'; +import Box from 'design/Box'; import { ButtonPrimary, ButtonSecondary } from 'design/Button'; import Dialog from 'design/Dialog'; import Flex from 'design/Flex'; +import * as Icon from 'design/Icon'; import Image from 'design/Image'; import Indicator from 'design/Indicator'; import Link from 'design/Link'; -import { SingleRowBox } from 'design/MultiRowBox'; import { RadioGroup } from 'design/RadioGroup'; import { StepComponentProps, StepSlider } from 'design/StepSlider'; import Text from 'design/Text'; @@ -35,6 +36,7 @@ import { useAsync } from 'shared/hooks/useAsync'; import useAttempt from 'shared/hooks/useAttemptNext'; import { Auth2faType } from 'shared/services'; import createMfaOptions, { MfaOption } from 'shared/utils/createMfaOptions'; +import styled from 'styled-components'; import useReAuthenticate from 'teleport/components/ReAuthenticate/useReAuthenticate'; import auth, { MfaChallengeScope } from 'teleport/services/auth/auth'; @@ -168,10 +170,11 @@ export function ReauthenticateStep({ return (
- - Step {stepIndex + 1} of {flowLength} - - Verify Identity + {attempt.status === 'failed' && ( {errorMessage} )} @@ -186,6 +189,7 @@ export function ReauthenticateStep({ autoFocus flexDirection="row" gap={3} + mb={4} onChange={o => { setMfaOption(o as Auth2faType); clearAttempt(); @@ -203,8 +207,14 @@ export function ReauthenticateStep({ readonly={attempt.status === 'processing'} /> )} - Verify my identity - Cancel + + + Verify my identity + + + Cancel + + )} @@ -255,12 +265,14 @@ export function CreateDeviceStep({ return (
- - Step {stepIndex + 1} of {flowLength} - - - {usage === 'passwordless' ? 'Create a Passkey' : 'Create an MFA Method'} - + + {createPasskeyAttempt.attempt.status === 'failed' && ( {createPasskeyAttempt.attempt.statusText} )} @@ -273,14 +285,22 @@ export function CreateDeviceStep({ onNewMfaDeviceTypeChange={onNewMfaDeviceTypeChange} /> )} - - {usage === 'passwordless' ? 'Create a passkey' : 'Create an MFA method'} - - {stepIndex === 0 ? ( - Cancel - ) : ( - Back - )} + + + {usage === 'passwordless' + ? 'Create a passkey' + : 'Create an MFA method'} + + {stepIndex === 0 ? ( + + Cancel + + ) : ( + + Back + + )} +
); } @@ -314,6 +334,7 @@ function CreateMfaBox({ autoFocus flexDirection="row" gap={3} + mb={4} onChange={o => { onNewMfaDeviceTypeChange(o as Auth2faType); }} @@ -338,11 +359,10 @@ function QrCodeBox({ privilegeToken }: { privilegeToken: string }) { props.theme.colors.interactive.tonal.neutral[0]}; - `} + bg="interactive.tonal.neutral.0" > {fetchQrCodeAttempt.status === 'error' && ( @@ -358,6 +378,7 @@ function QrCodeBox({ privilegeToken }: { privilegeToken: string }) { style={{ boxSizing: 'border-box', border: '8px solid white', + borderRadius: '8px', }} /> )} @@ -436,12 +457,14 @@ export function SaveDeviceStep({ return (
- - Step {stepIndex + 1} of {flowLength} - - - {usage === 'passwordless' ? 'Save the Passkey' : 'Save the MFA method'} - + + {saveAttempt.attempt.status === 'failed' && ( {saveAttempt.attempt.statusText} )} @@ -475,12 +498,16 @@ export function SaveDeviceStep({ readonly={saveAttempt.attempt.status === 'processing'} /> )} - - {usage === 'passwordless' - ? 'Save the Passkey' - : 'Save the MFA method'} - - Back + + + {usage === 'passwordless' + ? 'Save the Passkey' + : 'Save the MFA method'} + + + Back + + )} @@ -488,17 +515,63 @@ export function SaveDeviceStep({ ); } +function DialogHeader({ + stepIndex, + flowLength, + title, +}: { + stepIndex: number; + flowLength: number; + title: string; +}) { + return ( + + + Step {stepIndex + 1} of {flowLength} + + {title} + + ); +} + function PasskeyBlurb() { return ( - + + + + + + + + + + + + +

Teleport supports passkeys, a password replacement that validates your identity using touch, facial recognition, a device password, or a PIN.

-

+

Passkeys can be used to sign in as a simple and secure alternative to your password and multi-factor credentials.

-
+ ); } + +const OverlappingChip = styled.span` + display: inline-block; + background: ${props => props.theme.colors.levels.surface}; + border: ${props => props.theme.borders[1]}; + border-color: ${props => props.theme.colors.interactive.tonal.neutral[2]}; + border-radius: 50%; + margin-right: -6px; +`; From 9171ba28bd7a35e740a5dd09f79b2535479ca2eb Mon Sep 17 00:00:00 2001 From: Bartosz Leper Date: Wed, 21 Feb 2024 18:56:18 +0100 Subject: [PATCH 5/5] Remove the old AddDevice dialog and substitute it with the new wizard (#38393) * Remove the old AddDevice dialog Also cleans up the accompanying local storage flag. * Rename `canAddMFA` Co-authored-by: Zac Bergquist * Update a success message Co-authored-by: Zac Bergquist * review * Review --------- Co-authored-by: Zac Bergquist --- .../teleport/src/Account/Account.story.tsx | 15 +- .../teleport/src/Account/Account.test.tsx | 6 +- web/packages/teleport/src/Account/Account.tsx | 74 +- .../AddDevice/AddDevice.story.test.tsx | 40 - .../AddDevice/AddDevice.story.tsx | 116 - .../ManageDevices/AddDevice/AddDevice.tsx | 284 --- .../AddDevice.story.test.tsx.snap | 2049 ----------------- .../Account/ManageDevices/AddDevice/index.ts | 21 - .../ManageDevices/AddDevice/useAddDevice.ts | 106 - .../Account/ManageDevices/useManageDevices.ts | 47 +- web/packages/teleport/src/config.ts | 4 + .../services/storageService/storageService.ts | 5 - .../src/services/storageService/types.ts | 4 - 13 files changed, 53 insertions(+), 2718 deletions(-) delete mode 100644 web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.test.tsx delete mode 100644 web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.tsx delete mode 100644 web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.tsx delete mode 100644 web/packages/teleport/src/Account/ManageDevices/AddDevice/__snapshots__/AddDevice.story.test.tsx.snap delete mode 100644 web/packages/teleport/src/Account/ManageDevices/AddDevice/index.ts delete mode 100644 web/packages/teleport/src/Account/ManageDevices/AddDevice/useAddDevice.ts diff --git a/web/packages/teleport/src/Account/Account.story.tsx b/web/packages/teleport/src/Account/Account.story.tsx index 8a5a3372a6a3e..f3af94216ff2f 100644 --- a/web/packages/teleport/src/Account/Account.story.tsx +++ b/web/packages/teleport/src/Account/Account.story.tsx @@ -31,7 +31,7 @@ export const LoadedPasskeysOff = () => ( ); -export const LoadedMfaOff = () => ; +export const LoadedMfaOff = () => ; export const LoadingDevices = () => ( null, onAddDevice: () => null, - hideAddDevice: () => null, - fetchDevices: () => null, fetchDevicesAttempt: { status: 'success' }, createRestrictedTokenAttempt: { status: '' }, deviceToRemove: null, @@ -105,12 +103,11 @@ const props: AccountProps = { hideReAuthenticate: () => null, hideRemoveDevice: () => null, isReAuthenticateVisible: false, - isAddDeviceVisible: false, isRemoveDeviceVisible: false, isSso: false, - restrictNewDeviceUsage: null, + newDeviceUsage: null, canAddPasskeys: true, - canAddMFA: true, + canAddMfa: true, devices: [ { id: '1', @@ -161,8 +158,8 @@ const props: AccountProps = { residentKey: false, }, ], - onPasskeyAdded: () => {}, + onDeviceAdded: () => {}, isReauthenticationRequired: false, - passkeyWizardVisible: false, - closePasskeyWizard: () => {}, + addDeviceWizardVisible: false, + closeAddDeviceWizard: () => {}, }; diff --git a/web/packages/teleport/src/Account/Account.test.tsx b/web/packages/teleport/src/Account/Account.test.tsx index 905b3a6d6ad1d..53b40b6af9c3e 100644 --- a/web/packages/teleport/src/Account/Account.test.tsx +++ b/web/packages/teleport/src/Account/Account.test.tsx @@ -41,17 +41,21 @@ describe('passkey + mfa button state', () => { cfg.auth.allowPasswordless = defaultPasswordless; }); + // Note: the "off" and "otp" cases don't make sense with passwordless turned + // on (the auth server wouldn't start in this configuration), but we're still + // testing them for completeness. test.each` mfa | pwdless | pkEnabled | mfaEnabled ${'on'} | ${true} | ${true} | ${true} ${'on'} | ${false} | ${false} | ${true} ${'optional'} | ${true} | ${true} | ${true} ${'optional'} | ${false} | ${false} | ${true} - ${'otp'} | ${true} | ${false} | ${true} ${'otp'} | ${false} | ${false} | ${true} + ${'otp'} | ${true} | ${true} | ${true} ${'webauthn'} | ${true} | ${true} | ${true} ${'webauthn'} | ${false} | ${false} | ${true} ${'off'} | ${false} | ${false} | ${false} + ${'off'} | ${true} | ${true} | ${false} `( '2fa($mfa) with pwdless($pwdless) = passkey($pkEnabled) mfa($mfaEnabled)', async ({ mfa, pwdless, pkEnabled, mfaEnabled }) => { diff --git a/web/packages/teleport/src/Account/Account.tsx b/web/packages/teleport/src/Account/Account.tsx index 3ebadd6aca1b6..d7b2d40de58e9 100644 --- a/web/packages/teleport/src/Account/Account.tsx +++ b/web/packages/teleport/src/Account/Account.tsx @@ -23,8 +23,6 @@ import { Attempt } from 'shared/hooks/useAttemptNext'; import * as Icon from 'design/Icon'; import { Notification, NotificationItem } from 'shared/components/Notification'; -import createMfaOptions from 'shared/utils/createMfaOptions'; - import useTeleport from 'teleport/useTeleport'; import { FeatureBox } from 'teleport/components/Layout'; import ReAuthenticate from 'teleport/components/ReAuthenticate'; @@ -38,7 +36,6 @@ import { AuthDeviceList } from './ManageDevices/AuthDeviceList/AuthDeviceList'; import useManageDevices, { State as ManageDevicesState, } from './ManageDevices/useManageDevices'; -import AddDevice from './ManageDevices/AddDevice'; import { ActionButton, Header } from './Header'; import { PasswordBox } from './PasswordBox'; import { AddAuthDeviceWizard } from './ManageDevices/AddAuthDeviceWizard'; @@ -61,32 +58,14 @@ export default function AccountPage({ enterpriseComponent }: AccountPageProps) { const isSso = ctx.storeUser.isSso(); const manageDevicesState = useManageDevices(ctx); - // Note: we are using the same logic here as the `AddDevice` component uses to - // determine whether to show various options. This creates a duplication of - // logic, but this is a quick bug fix to make sure that we don't show a dialog - // that normally would require an OTP token, but is shown in a passwordless - // context and thus can't progress. - // TODO(bl-nero): When implementing a new device enrollment dialog, refactor - // this so that the options used by both components have the same source of - // truth. - const mfaOptions = createMfaOptions({ - auth2faType: cfg.getAuth2faType(), - required: true, - }); - - const canAddPasskeys = - cfg.isPasswordlessEnabled() && - mfaOptions.some(option => option.value === 'webauthn'); - - const canAddMFA = mfaOptions.some( - option => option.value === 'otp' || option.value === 'webauthn' - ); + const canAddPasskeys = cfg.isPasswordlessEnabled(); + const canAddMfa = cfg.isMfaEnabled(); return ( @@ -96,7 +75,7 @@ export default function AccountPage({ enterpriseComponent }: AccountPageProps) { export interface AccountProps extends ManageDevicesState, AccountPageProps { isSso: boolean; canAddPasskeys: boolean; - canAddMFA: boolean; + canAddMfa: boolean; } export function Account({ @@ -105,25 +84,22 @@ export function Account({ setToken, onAddDevice, onRemoveDevice, - onPasskeyAdded, + onDeviceAdded, deviceToRemove, - fetchDevices, removeDevice, fetchDevicesAttempt, createRestrictedTokenAttempt, isReAuthenticateVisible, - isAddDeviceVisible, isRemoveDeviceVisible, - passkeyWizardVisible, + addDeviceWizardVisible, hideReAuthenticate, - hideAddDevice, hideRemoveDevice, - closePasskeyWizard, + closeAddDeviceWizard, isSso, - canAddMFA, + canAddMfa, canAddPasskeys, enterpriseComponent: EnterpriseComponent, - restrictNewDeviceUsage, + newDeviceUsage, }: AccountProps) { const passkeys = devices.filter(d => d.residentKey); const mfaDevices = devices.filter(d => !d.residentKey); @@ -131,7 +107,7 @@ export function Account({ createRestrictedTokenAttempt.status === 'processing' || fetchDevicesAttempt.status !== 'success'; const disableAddPasskey = disableAddDevice || !canAddPasskeys; - const disableAddMFA = disableAddDevice || !canAddMFA; + const disableAddMfa = disableAddDevice || !canAddMfa; const [notifications, setNotifications] = useState([]); const [prevFetchStatus, setPrevFetchStatus] = useState(''); @@ -174,9 +150,13 @@ export function Account({ addNotification('info', 'Your password has been changed.'); } - function onAddPasskeySuccess() { - addNotification('info', 'Passkey successfully saved.'); - onPasskeyAdded(); + function onAddDeviceSuccess() { + const message = + newDeviceUsage === 'passwordless' + ? 'Passkey successfully saved.' + : 'MFA device successfully saved.'; + addNotification('info', message); + onDeviceAdded(); } return ( @@ -233,9 +213,9 @@ export function Account({ showIndicator={fetchDevicesAttempt.status === 'processing'} actions={ )} - {isAddDeviceVisible && ( - - )} {EnterpriseComponent && ( )} @@ -281,13 +253,13 @@ export function Account({ /> )} - {passkeyWizardVisible && ( + {addDeviceWizardVisible && ( )} diff --git a/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.test.tsx b/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.test.tsx deleted file mode 100644 index 34a5365829dce..0000000000000 --- a/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React from 'react'; -import { render, screen } from 'design/utils/testing'; - -import { LoadedWebauthn, Failed, QrCodeFailed } from './AddDevice.story'; - -test('render dialog to add a new mfa device with webauthn as preferred type', () => { - render(); - - expect(screen.getByTestId('Modal')).toMatchSnapshot(); -}); - -test('render failed state for dialog to add a new mfa device', () => { - render(); - - expect(screen.getByTestId('Modal')).toMatchSnapshot(); -}); - -test('render failed state for fetching QR Code for dialog to add a new mfa device', () => { - render(); - - expect(screen.getByTestId('Modal')).toMatchSnapshot(); -}); diff --git a/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.tsx b/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.tsx deleted file mode 100644 index fee9d7b5ecd85..0000000000000 --- a/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.story.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React from 'react'; - -import { State } from './useAddDevice'; -import { AddDevice } from './AddDevice'; - -export default { - title: 'Teleport/Account/Manage Devices/Add Device Dialog', -}; - -export const LoadedWebauthn = () => ; - -export const RestrictedToPasswordless = () => ( - -); - -export const RestrictedToMFA = () => ( - -); - -export const Failed = () => ( - -); - -export const QrCodeProcessing = () => ( - -); - -export const QrCodeFailed = () => ( - -); - -const props: State = { - addDeviceAttempt: { status: '' }, - fetchQrCodeAttempt: { status: 'success' }, - addTotpDevice: () => null, - addWebauthnDevice: () => null, - clearAttempt: () => null, - onClose: () => null, - auth2faType: 'on', - isPasswordlessEnabled: true, - restrictDeviceUsage: undefined, - qrCode: - 'iVBORw0KGgoAAAANSUhEUgAAAcgAAAHIEAAAAAC/Wvl1AAAJV0lEQVR4nOzdsW4jORZA0fbC///LXowV' + - 'TFIWmqAefUtzTrDJeEtltS+YPDx+fn39ASL+99svAPzr85//+fj47df4ycr5ff1bXD9h/2f3vcenTf0L' + - 'rTzh2tnvd9/jfZ2QECJICBEkhAgSQgQJIYKEEEFCiCAhRJAQIkgI+fz5P50dOz870rT/u3VH0VZ+dv+3' + - '2B9mW1H41vc9e18nJIQIEkIECSGChBBBQoggIUSQECJICBEkhAgSQp6Mzl0rjCkV9stNjYytvG9hkOzs' + - '+F5nxO3vrL+vExJCBAkhgoQQQUKIICFEkBAiSAgRJIQIEkIECSHLo3M8nB1bOztItv9pU8N3+59W54SE' + - 'EEFCiCAhRJAQIkgIESSECBJCBAkhgoQQQULIG43OvcdOs7NDZ9cKQ3L7o4n3HKhzQkKIICFEkBAiSAgR' + - 'JIQIEkIECSGChBBBQoggIWR5dO5uA0lT42Vnnzu1427/Hfaf0B2H+42/dSckhAgSQgQJIYKEEEFCiCAh' + - 'RJAQIkgIESSECBJCnozOTQ1mTdnfLzd1UenZcbjumF3hZ691/tadkBAiSAgRJIQIEkIECSGChBBBQogg' + - 'IUSQECJICPkenbvbJrlrZwez9t9h39khubPvcPY763BCQoggIUSQECJICBEkhAgSQgQJIYKEEEFCiCAh' + - '5OPrq3FJ6LWpS0L3nzD1PUxdatp936l3uNZ9swcnJIQIEkIECSGChBBBQoggIUSQECJICBEkhAgSQkYv' + - 'bN3f8FW4SPPs1aErule+Fi6u7Xr2PTghIUSQECJICBEkhAgSQgQJIYKEEEFCiCAhRJAQ8jF5geV/b1Tq' + - '7Ja8qecW9vedvVi1s4vOCQkhgoQQQUKIICFEkBAiSAgRJIQIEkIECSGChJDl0bnCoNOUs/vaCjv5pv7d' + - '9jfUrTy3MGb3qt/CCQkhgoQQQUKIICFEkBAiSAgRJIQIEkIECSGChJAnF7ZeK1zFWbgA9ezPrji7HW7F' + - '1PfQHai7Zusc3IQgIUSQECJICBEkhAgSQgQJIYKEEEFCiCAh5Hvr3NTw0oqzI3nda1GvFS65feffbZ+t' + - 'c/CGBAkhgoQQQUKIICFEkBAiSAgRJIQIEkIECSFPRudWnL189OwQ177uOxQGFqeeULhEeP3NnJAQIkgI' + - 'ESSECBJCBAkhgoQQQUKIICFEkBAiSAj5vrC1cLXlylDU2R13+084O1h47exut8K1viumBgvXn+CEhBBB' + - 'QoggIUSQECJICBEkhAgSQgQJIYKEEEFCyMfqWFdhO9yUV126+Xc/u/8OK+52Ke/KE/adHXm0dQ5uQpAQ' + - 'IkgIESSECBJCBAkhgoQQQUKIICFEkBDyZHTu7KjU3a58LexKu3Z2QO09LnedYusc3JogIUSQECJICBEk' + - 'hAgSQgQJIYKEEEFCiCAhZHnr3A+PGdppdvYJ+wq76M5+k1MDliu6G/XWOSEhRJAQIkgIESSECBJCBAkh' + - 'goQQQUKIICFEkBDy+eclQ1z7g0P7G+qmRqWmhu9WfuPCpr53VtiPaOsc5AgSQgQJIYKEEEFCiCAhRJAQ' + - 'IkgIESSECBJCPn/+T91NZ/tP2B9buzY1QrjvbtfOrpgafPuNPXtOSAgRJIQIEkIECSGChBBBQoggIUSQ' + - 'ECJICBEkhHyPznV2bv3dz147O+g0NV5WGFub+nvY36jX/Xu4tv4bOyEhRJAQIkgIESSECBJCBAkhgoQQ' + - 'QUKIICFEkBDyZOvcvpUxpf2RpsJY1bWpa2f3P23f2VG/qUtup76z9Sc4ISFEkBAiSAgRJIQIEkIECSGC' + - 'hBBBQoggIUSQELI8Ojc1KlXYZjd1mefUtbN3GzecGt87+/3uf9o1W+cgR5AQIkgIESSECBJCBAkhgoQQ' + - 'QUKIICFEkBDyPTp3dlyrMII19Q5TQ1wr9sfA7vZvPLXVb2pI7hknJIQIEkIECSGChBBBQoggIUSQECJI' + - 'CBEkhAgSQp5snfuNnVt/94SzV5IWLnfdf0JhoG7f2b/JFa96ByckhAgSQgQJIYKEEEFCiCAhRJAQIkgI' + - 'ESSECBJCPn4e+Tm7I2xFYbdbYYPa1BNWFEbRrp39Hl71Dk5ICBEkhAgSQgQJIYKEEEFCiCAhRJAQIkgI' + - 'ESSELF/YOmV/09nUvraz9rfDXetu6iu8w9Snrf9uTkgIESSECBJCBAkhgoQQQUKIICFEkBAiSAgRJIQ8' + - '2Tr3w//hZteBvvM+vLtt1Fv5tMLw3bWpa4gfP+uEhBBBQoggIUSQECJICBEkhAgSQgQJIYKEEEFCyPLW' + - 'ucKOsKnhu6kxu8IVqiufdnbccF/3r2SdExJCBAkhgoQQQUKIICFEkBAiSAgRJIQIEkIECSGfr3nM1GWp' + - 'Zwe+zm4v23d2f9/+O+x/v1NPKFwl++CEhBBBQoggIUSQECJICBEkhAgSQgQJIYKEEEFCyPLoXGEo6m5j' + - 'dlNb3N5jb92KwuWus+/ghIQQQUKIICFEkBAiSAgRJIQIEkIECSGChBBBQsjH11fjQtF93QG199gD173q' + - '9D2+3wcnJIQIEkIECSGChBBBQoggIUSQECJICBEkhAgSQj7udknpn8Ehrv19Yt1Pu3Z2i9vZ8b2zI4+v' + - '+h6ckBAiSAgRJIQIEkIECSGChBBBQoggIUSQECJICPm+sPXs1q4V1wNJ++NlZ6282f4w28oTCpfcFnYe' + - '7n8Pr/rWnZAQIkgIESSECBJCBAkhgoQQQUKIICFEkBAiSAj5/Pk/nd1Hd3a8rKCw7+/sHrizQ3LXpj7t' + - 'Vc91QkKIICFEkBAiSAgRJIQIEkIECSGChBBBQoggIeTJ6Ny1qctSu87+xvvby1Z20Z21/y/f/Y1fNRbo' + - 'hIQQQUKIICFEkBAiSAgRJIQIEkIECSGChBBBQsjy6FzX1IjbynOnriQtvMOK7vewb2rn4eMJTkgIESSE' + - 'CBJCBAkhgoQQQUKIICFEkBAiSAgRJIS80ejc1IWtZy+CvdtOvmtTg3pTY3b772DrHLwhQUKIICFEkBAi' + - 'SAgRJIQIEkIECSGChBBBQsjy6FxhtGvlHaY2yV1beW7hCtV93d1uUz879WkPTkgIESSECBJCBAkhgoQQ' + - 'QUKIICFEkBAiSAgRJIQ8GZ3rjnbtD76d3X92ber60v0xsClnB9/OsnUO3pAgIUSQECJICBEkhAgSQgQJ' + - 'IYKEEEFCiCAh5KMwdgQ8OCEhRJAQ8v8AAAD//1QuL6EmJFBiAAAAAElFTkSuQmCC', -}; diff --git a/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.tsx b/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.tsx deleted file mode 100644 index e6f08ef6f353d..0000000000000 --- a/web/packages/teleport/src/Account/ManageDevices/AddDevice/AddDevice.tsx +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React, { useState, useMemo } from 'react'; -import { - Text, - Flex, - Image, - ButtonPrimary, - ButtonSecondary, - Link, - Indicator, -} from 'design'; -import Dialog, { - DialogHeader, - DialogTitle, - DialogContent, - DialogFooter, -} from 'design/Dialog'; -import { Danger } from 'design/Alert'; -import FieldInput from 'shared/components/FieldInput'; -import Validation from 'shared/components/Validation'; -import { - requiredToken, - requiredField, -} from 'shared/components/Validation/rules'; -import FieldSelect from 'shared/components/FieldSelect'; - -import createMfaOptions, { MfaOption } from 'shared/utils/createMfaOptions'; - -import secKeyGraphic from 'design/assets/images/sec-key-graphic.svg'; - -import { DeviceUsage } from 'teleport/services/mfa'; -import useTeleport from 'teleport/useTeleport'; - -import useAddDevice, { State, Props } from './useAddDevice'; - -const deviceUsageOpts: DeviceusageOpt[] = [ - { - value: 'mfa', - label: 'no', - }, - { - value: 'passwordless', - label: 'yes', - }, -]; - -export default function Container(props: Props) { - const ctx = useTeleport(); - const state = useAddDevice(ctx, props); - return ( - - ); -} - -export function AddDevice({ - addDeviceAttempt, - fetchQrCodeAttempt, - addTotpDevice, - addWebauthnDevice, - clearAttempt, - onClose, - qrCode, - auth2faType, - isPasswordlessEnabled, - restrictDeviceUsage, -}: State) { - const [otpToken, setOtpToken] = useState(''); - const [deviceName, setDeviceName] = useState(''); - - const mfaOptions = useMemo( - () => createMfaOptions({ auth2faType: auth2faType, required: true }), - [] - ); - - const [mfaOption, setMfaOption] = useState(mfaOptions[0]); - const [usageOption, setUsageOption] = useState(deviceUsageOpts[0]); - - const resolvedDeviceUsage = restrictDeviceUsage ?? usageOption.value; - - function onSetMfaOption(option: MfaOption) { - setOtpToken(''); - clearAttempt(); - setMfaOption(option); - } - - function onSubmit(e: React.MouseEvent) { - e.preventDefault(); - - if (mfaOption.value === 'webauthn') { - addWebauthnDevice(deviceName, resolvedDeviceUsage); - } - if (mfaOption.value === 'otp') { - addTotpDevice(otpToken, deviceName); - } - } - - let hardwareInstructions = 'Enter a name for your hardware key.'; - if (addDeviceAttempt.status === 'processing') { - hardwareInstructions = 'Follow the prompts from your browser.'; - } - - const dialogTitle = - restrictDeviceUsage === 'passwordless' - ? 'Add New Passkey' - : 'Add New Two-Factor Device'; - - return ( - - {({ validator }) => ( - ({ width: '484px' })} - disableEscapeKeyDown={false} - onClose={onClose} - open={true} - > -
- - {dialogTitle} - - {addDeviceAttempt.status === 'failed' && ( - - {addDeviceAttempt.statusText} - - )} - {fetchQrCodeAttempt.status === 'failed' && ( - - {fetchQrCodeAttempt.statusText} - - )} - - props.theme.colors.spotBackground[0]}; - `} - > - {mfaOption.value === 'otp' && ( - <> - - {fetchQrCodeAttempt.status === 'processing' && ( - - )} - {fetchQrCodeAttempt.status === 'success' && ( - - )} - - - Scan the QR Code with any authenticator app and enter the - generated code.{' '} - - We recommend{' '} - - Authy - - . - - - - )} - {mfaOption.value === 'webauthn' && ( - <> - - {hardwareInstructions} - - )} - - {restrictDeviceUsage !== 'passwordless' && ( - - { - validator.reset(); - onSetMfaOption(o); - }} - mr={3} - isDisabled={addDeviceAttempt.status === 'processing'} - elevated={true} - /> - {mfaOption.value === 'otp' && ( - setOtpToken(e.target.value)} - placeholder="123 456" - readonly={addDeviceAttempt.status === 'processing'} - /> - )} - {mfaOption.value === 'webauthn' && - isPasswordlessEnabled && - !restrictDeviceUsage && ( - setUsageOption(o)} - isDisabled={addDeviceAttempt.status === 'processing'} - elevated={true} - /> - )} - - )} - setDeviceName(e.target.value)} - readonly={addDeviceAttempt.status === 'processing'} - mb={1} - /> - - - validator.validate() && onSubmit(e)} - disabled={addDeviceAttempt.status === 'processing'} - mr={3} - > - Add device - - - Cancel - - -
-
- )} -
- ); -} - -type DeviceusageOpt = { value: DeviceUsage; label: string }; diff --git a/web/packages/teleport/src/Account/ManageDevices/AddDevice/__snapshots__/AddDevice.story.test.tsx.snap b/web/packages/teleport/src/Account/ManageDevices/AddDevice/__snapshots__/AddDevice.story.test.tsx.snap deleted file mode 100644 index 8e4d977134c0a..0000000000000 --- a/web/packages/teleport/src/Account/ManageDevices/AddDevice/__snapshots__/AddDevice.story.test.tsx.snap +++ /dev/null @@ -1,2049 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`render dialog to add a new mfa device with webauthn as preferred type 1`] = ` -.c4 { - box-sizing: border-box; - margin-bottom: 16px; - min-height: 32px; -} - -.c7 { - box-sizing: border-box; - margin-bottom: 32px; - flex: 1; -} - -.c9 { - box-sizing: border-box; - margin-bottom: 24px; - padding: 16px; - height: 256px; - border-radius: 8px; -} - -.c14 { - box-sizing: border-box; -} - -.c15 { - box-sizing: border-box; - max-width: 50%; - margin-bottom: 24px; - margin-right: 16px; - width: 100%; -} - -.c18 { - box-sizing: border-box; - margin-bottom: 24px; - width: 50%; -} - -.c19 { - box-sizing: border-box; - margin-bottom: 4px; - width: 100%; -} - -.c22 { - line-height: 1.5; - margin: 0; - display: inline-flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-weight: 600; - outline: none; - position: relative; - text-align: center; - text-decoration: none; - text-transform: uppercase; - transition: all 0.3s; - -webkit-font-smoothing: antialiased; - color: #000000; - background: #9F85FF; - min-height: 40px; - font-size: 12px; - padding: 0px 40px; - margin-right: 16px; - width: 45%; -} - -.c22:hover, -.c22:focus { - background: #B29DFF; -} - -.c22:active { - background: #C5B6FF; -} - -.c22:disabled { - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.3); - cursor: auto; -} - -.c23 { - line-height: 1.5; - margin: 0; - display: inline-flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-weight: 600; - outline: none; - position: relative; - text-align: center; - text-decoration: none; - text-transform: uppercase; - transition: all 0.3s; - -webkit-font-smoothing: antialiased; - color: #FFFFFF; - background: rgba(255,255,255,0.07); - min-height: 40px; - font-size: 12px; - padding: 0px 40px; - width: 30%; -} - -.c23:hover, -.c23:focus { - background: rgba(255,255,255,0.13); -} - -.c23:active { - background: rgba(255,255,255,0.18); -} - -.c23:disabled { - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.3); - cursor: auto; -} - -.c6 { - overflow: hidden; - text-overflow: ellipsis; - font-weight: 300; - font-size: 22px; - line-height: 32px; - text-transform: uppercase; - margin: 0px; - color: #FFFFFF; -} - -.c13 { - overflow: hidden; - text-overflow: ellipsis; - margin: 0px; - margin-top: 16px; -} - -.c21 { - appearance: none; - border: 1px solid rgba(255,255,255,0.54); - border-radius: 4px; - box-sizing: border-box; - display: block; - height: 40px; - font-size: 16px; - padding: 0 16px; - outline: none; - width: 100%; - background: #222C59; - color: #FFFFFF; - margin-top: 4px; -} - -.c21:hover, -.c21:focus, -.c21:active { - border: 1px solid rgba(255,255,255,0.72); -} - -.c21::-ms-clear { - display: none; -} - -.c21::placeholder { - color: rgba(255,255,255,0.54); - opacity: 1; -} - -.c21:read-only { - cursor: not-allowed; -} - -.c21:disabled { - color: rgba(255,255,255,0.36); - border-color: rgba(255,255,255,0.36); -} - -.c16 { - color: #FFFFFF; - display: block; - font-size: 12px; - width: 100%; - margin-bottom: 4px; -} - -.c20 { - color: #FFFFFF; - display: block; - font-size: 12px; - width: 100%; - margin-bottom: 0px; -} - -.c12 { - display: block; - outline: none; - height: 168px; -} - -.c5 { - display: flex; - align-items: center; -} - -.c8 { - display: flex; - flex-direction: column; -} - -.c10 { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; -} - -.c1 { - z-index: -1; - position: fixed; - right: 0; - bottom: 0; - top: 0; - left: 0; - background-color: rgba(0,0,0,0.5); - opacity: 1; - touch-action: none; -} - -.c0 { - position: fixed; - z-index: 1200; - right: 0; - bottom: 0; - top: 0; - left: 0; -} - -.c2 { - height: 100%; - outline: none; - color: black; - display: flex; - align-items: center; - justify-content: center; - opacity: 1; - will-change: opacity; - transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; -} - -.c3 { - padding: 32px; - padding-top: 24px; - background: #222C59; - color: #FFFFFF; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0,0,0,0.24); - display: flex; - flex-direction: column; - position: relative; - overflow-y: auto; - max-height: calc(100% - 96px); - width: 484px; -} - -.c17 .react-select-container { - box-sizing: border-box; - display: block; - font-size: 14px; - outline: none; - width: 100%; - color: #FFFFFF; - background-color: transparent; - margin-bottom: 0px; - border-radius: 4px; -} - -.c17 .react-select__control { - outline: none; - min-height: 40px; - height: fit-content; - border: 1px solid rgba(255,255,255,0.54); - border-radius: 4px; - background-color: transparent; - box-shadow: none; -} - -.c17 .react-select__control .react-select__dropdown-indicator { - color: rgba(255,255,255,0.54); -} - -.c17 .react-select__control:hover, -.c17 .react-select__control:focus, -.c17 .react-select__control:active { - border: 1px solid rgba(255,255,255,0.72); - background-color: rgba(255,255,255,0.07); - cursor: pointer; -} - -.c17 .react-select__control:hover .react-select__dropdown-indicator, -.c17 .react-select__control:focus .react-select__dropdown-indicator, -.c17 .react-select__control:active .react-select__dropdown-indicator { - color: #FFFFFF; -} - -.c17 .react-select__control .react-select__indicator:hover, -.c17 .react-select__control .react-select__dropdown-indicator:hover, -.c17 .react-select__control .react-select__indicator:focus, -.c17 .react-select__control .react-select__dropdown-indicator:focus, -.c17 .react-select__control .react-select__indicator:active, -.c17 .react-select__control .react-select__dropdown-indicator:active { - color: #FFFFFF; -} - -.c17 .react-select__control--is-focused { - border-color: rgba(255,255,255,0.72); - background-color: rgba(255,255,255,0.07); - cursor: pointer; -} - -.c17 .react-select__control--is-focused .react-select__dropdown-indicator { - color: #FFFFFF; -} - -.c17 .react-select__single-value { - color: #FFFFFF; -} - -.c17 .react-select__placeholder { - color: rgba(255,255,255,0.54); -} - -.c17 .react-select__multi-value { - background-color: rgba(255,255,255,0.13); -} - -.c17 .react-select__multi-value .react-select__multi-value__label { - color: #FFFFFF; - padding: 0 6px; -} - -.c17 .react-select__multi-value .react-select__multi-value__remove { - color: #FFFFFF; -} - -.c17 .react-select__multi-value .react-select__multi-value__remove:hover { - background-color: rgba(255,255,255,0.07); - color: #FF6257; -} - -.c17 .react-select__option:hover { - cursor: pointer; - background-color: rgba(255,255,255,0.07); -} - -.c17 .react-select__option--is-focused { - background-color: rgba(255,255,255,0.07); -} - -.c17 .react-select__option--is-focused:hover { - cursor: pointer; - background-color: rgba(255,255,255,0.07); -} - -.c17 .react-select__option--is-selected { - background-color: rgba(255,255,255,0.13); - color: inherit; - font-weight: 500; -} - -.c17 .react-select__option--is-selected:hover { - background-color: rgba(255,255,255,0.13); -} - -.c17 .react-select__clear-indicator { - color: rgba(255,255,255,0.72); -} - -.c17 .react-select__clear-indicator:hover, -.c17 .react-select__clear-indicator:focus { - background-color: rgba(255,255,255,0.07); -} - -.c17 .react-select__clear-indicator:hover svg, -.c17 .react-select__clear-indicator:focus svg { - color: #FF6257; -} - -.c17 .react-select__menu { - margin-top: 0px; - background-color: #4A5688; - box-shadow: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); -} - -.c17 .react-select__menu .react-select__menu-list::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.13); - border-radius: 4px; -} - -.c17 .react-select__indicator-separator { - display: none; -} - -.c17 .react-select__loading-indicator { - display: none; -} - -.c17 .react-select__control--is-disabled { - color: rgba(255,255,255,0.36); - border: 1px solid rgba(255,255,255,0.36); -} - -.c17 .react-select__control--is-disabled .react-select__single-value, -.c17 .react-select__control--is-disabled .react-select__placeholder { - color: rgba(255,255,255,0.36); -} - -.c17 .react-select__control--is-disabled .react-select__indicator { - color: rgba(255,255,255,0.36); -} - -.c17 .react-select__input { - color: #FFFFFF; -} - -.c11 { - background: rgba(255,255,255,0.07); -} - -
- -`; - -exports[`render failed state for dialog to add a new mfa device 1`] = ` -.c7 { - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; - box-sizing: border-box; - box-shadow: 0 1px 4px rgba(0,0,0,0.24); - margin: 0 0 24px 0; - min-height: 40px; - padding: 8px 16px; - overflow: auto; - word-break: break-word; - line-height: 1.5; - margin-top: 8px; - background: #FF6257; - color: #000000; - width: 100%; -} - -.c7 a { - color: #FFFFFF; -} - -.c4 { - box-sizing: border-box; - margin-bottom: 16px; - min-height: 32px; -} - -.c8 { - box-sizing: border-box; - margin-bottom: 32px; - flex: 1; -} - -.c10 { - box-sizing: border-box; - margin-bottom: 24px; - padding: 16px; - height: 256px; - border-radius: 8px; -} - -.c15 { - box-sizing: border-box; -} - -.c16 { - box-sizing: border-box; - max-width: 50%; - margin-bottom: 24px; - margin-right: 16px; - width: 100%; -} - -.c19 { - box-sizing: border-box; - margin-bottom: 24px; - width: 50%; -} - -.c20 { - box-sizing: border-box; - margin-bottom: 4px; - width: 100%; -} - -.c23 { - line-height: 1.5; - margin: 0; - display: inline-flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-weight: 600; - outline: none; - position: relative; - text-align: center; - text-decoration: none; - text-transform: uppercase; - transition: all 0.3s; - -webkit-font-smoothing: antialiased; - color: #000000; - background: #9F85FF; - min-height: 40px; - font-size: 12px; - padding: 0px 40px; - margin-right: 16px; - width: 45%; -} - -.c23:hover, -.c23:focus { - background: #B29DFF; -} - -.c23:active { - background: #C5B6FF; -} - -.c23:disabled { - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.3); - cursor: auto; -} - -.c24 { - line-height: 1.5; - margin: 0; - display: inline-flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-weight: 600; - outline: none; - position: relative; - text-align: center; - text-decoration: none; - text-transform: uppercase; - transition: all 0.3s; - -webkit-font-smoothing: antialiased; - color: #FFFFFF; - background: rgba(255,255,255,0.07); - min-height: 40px; - font-size: 12px; - padding: 0px 40px; - width: 30%; -} - -.c24:hover, -.c24:focus { - background: rgba(255,255,255,0.13); -} - -.c24:active { - background: rgba(255,255,255,0.18); -} - -.c24:disabled { - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.3); - cursor: auto; -} - -.c6 { - overflow: hidden; - text-overflow: ellipsis; - font-weight: 300; - font-size: 22px; - line-height: 32px; - text-transform: uppercase; - margin: 0px; - color: #FFFFFF; -} - -.c14 { - overflow: hidden; - text-overflow: ellipsis; - margin: 0px; - margin-top: 16px; -} - -.c22 { - appearance: none; - border: 1px solid rgba(255,255,255,0.54); - border-radius: 4px; - box-sizing: border-box; - display: block; - height: 40px; - font-size: 16px; - padding: 0 16px; - outline: none; - width: 100%; - background: #222C59; - color: #FFFFFF; - margin-top: 4px; -} - -.c22:hover, -.c22:focus, -.c22:active { - border: 1px solid rgba(255,255,255,0.72); -} - -.c22::-ms-clear { - display: none; -} - -.c22::placeholder { - color: rgba(255,255,255,0.54); - opacity: 1; -} - -.c22:read-only { - cursor: not-allowed; -} - -.c22:disabled { - color: rgba(255,255,255,0.36); - border-color: rgba(255,255,255,0.36); -} - -.c17 { - color: #FFFFFF; - display: block; - font-size: 12px; - width: 100%; - margin-bottom: 4px; -} - -.c21 { - color: #FFFFFF; - display: block; - font-size: 12px; - width: 100%; - margin-bottom: 0px; -} - -.c13 { - display: block; - outline: none; - height: 168px; -} - -.c5 { - display: flex; - align-items: center; -} - -.c9 { - display: flex; - flex-direction: column; -} - -.c11 { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; -} - -.c1 { - z-index: -1; - position: fixed; - right: 0; - bottom: 0; - top: 0; - left: 0; - background-color: rgba(0,0,0,0.5); - opacity: 1; - touch-action: none; -} - -.c0 { - position: fixed; - z-index: 1200; - right: 0; - bottom: 0; - top: 0; - left: 0; -} - -.c2 { - height: 100%; - outline: none; - color: black; - display: flex; - align-items: center; - justify-content: center; - opacity: 1; - will-change: opacity; - transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; -} - -.c3 { - padding: 32px; - padding-top: 24px; - background: #222C59; - color: #FFFFFF; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0,0,0,0.24); - display: flex; - flex-direction: column; - position: relative; - overflow-y: auto; - max-height: calc(100% - 96px); - width: 484px; -} - -.c18 .react-select-container { - box-sizing: border-box; - display: block; - font-size: 14px; - outline: none; - width: 100%; - color: #FFFFFF; - background-color: transparent; - margin-bottom: 0px; - border-radius: 4px; -} - -.c18 .react-select__control { - outline: none; - min-height: 40px; - height: fit-content; - border: 1px solid rgba(255,255,255,0.54); - border-radius: 4px; - background-color: transparent; - box-shadow: none; -} - -.c18 .react-select__control .react-select__dropdown-indicator { - color: rgba(255,255,255,0.54); -} - -.c18 .react-select__control:hover, -.c18 .react-select__control:focus, -.c18 .react-select__control:active { - border: 1px solid rgba(255,255,255,0.72); - background-color: rgba(255,255,255,0.07); - cursor: pointer; -} - -.c18 .react-select__control:hover .react-select__dropdown-indicator, -.c18 .react-select__control:focus .react-select__dropdown-indicator, -.c18 .react-select__control:active .react-select__dropdown-indicator { - color: #FFFFFF; -} - -.c18 .react-select__control .react-select__indicator:hover, -.c18 .react-select__control .react-select__dropdown-indicator:hover, -.c18 .react-select__control .react-select__indicator:focus, -.c18 .react-select__control .react-select__dropdown-indicator:focus, -.c18 .react-select__control .react-select__indicator:active, -.c18 .react-select__control .react-select__dropdown-indicator:active { - color: #FFFFFF; -} - -.c18 .react-select__control--is-focused { - border-color: rgba(255,255,255,0.72); - background-color: rgba(255,255,255,0.07); - cursor: pointer; -} - -.c18 .react-select__control--is-focused .react-select__dropdown-indicator { - color: #FFFFFF; -} - -.c18 .react-select__single-value { - color: #FFFFFF; -} - -.c18 .react-select__placeholder { - color: rgba(255,255,255,0.54); -} - -.c18 .react-select__multi-value { - background-color: rgba(255,255,255,0.13); -} - -.c18 .react-select__multi-value .react-select__multi-value__label { - color: #FFFFFF; - padding: 0 6px; -} - -.c18 .react-select__multi-value .react-select__multi-value__remove { - color: #FFFFFF; -} - -.c18 .react-select__multi-value .react-select__multi-value__remove:hover { - background-color: rgba(255,255,255,0.07); - color: #FF6257; -} - -.c18 .react-select__option:hover { - cursor: pointer; - background-color: rgba(255,255,255,0.07); -} - -.c18 .react-select__option--is-focused { - background-color: rgba(255,255,255,0.07); -} - -.c18 .react-select__option--is-focused:hover { - cursor: pointer; - background-color: rgba(255,255,255,0.07); -} - -.c18 .react-select__option--is-selected { - background-color: rgba(255,255,255,0.13); - color: inherit; - font-weight: 500; -} - -.c18 .react-select__option--is-selected:hover { - background-color: rgba(255,255,255,0.13); -} - -.c18 .react-select__clear-indicator { - color: rgba(255,255,255,0.72); -} - -.c18 .react-select__clear-indicator:hover, -.c18 .react-select__clear-indicator:focus { - background-color: rgba(255,255,255,0.07); -} - -.c18 .react-select__clear-indicator:hover svg, -.c18 .react-select__clear-indicator:focus svg { - color: #FF6257; -} - -.c18 .react-select__menu { - margin-top: 0px; - background-color: #4A5688; - box-shadow: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); -} - -.c18 .react-select__menu .react-select__menu-list::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.13); - border-radius: 4px; -} - -.c18 .react-select__indicator-separator { - display: none; -} - -.c18 .react-select__loading-indicator { - display: none; -} - -.c18 .react-select__control--is-disabled { - color: rgba(255,255,255,0.36); - border: 1px solid rgba(255,255,255,0.36); -} - -.c18 .react-select__control--is-disabled .react-select__single-value, -.c18 .react-select__control--is-disabled .react-select__placeholder { - color: rgba(255,255,255,0.36); -} - -.c18 .react-select__control--is-disabled .react-select__indicator { - color: rgba(255,255,255,0.36); -} - -.c18 .react-select__input { - color: #FFFFFF; -} - -.c12 { - background: rgba(255,255,255,0.07); -} - -
- -`; - -exports[`render failed state for fetching QR Code for dialog to add a new mfa device 1`] = ` -.c7 { - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; - box-sizing: border-box; - box-shadow: 0 1px 4px rgba(0,0,0,0.24); - margin: 0 0 24px 0; - min-height: 40px; - padding: 8px 16px; - overflow: auto; - word-break: break-word; - line-height: 1.5; - margin-top: 8px; - background: #FF6257; - color: #000000; - width: 100%; -} - -.c7 a { - color: #FFFFFF; -} - -.c4 { - box-sizing: border-box; - margin-bottom: 16px; - min-height: 32px; -} - -.c8 { - box-sizing: border-box; - margin-bottom: 32px; - flex: 1; -} - -.c10 { - box-sizing: border-box; - margin-bottom: 24px; - padding: 16px; - height: 256px; - border-radius: 8px; -} - -.c13 { - box-sizing: border-box; - height: 168px; -} - -.c18 { - box-sizing: border-box; -} - -.c19 { - box-sizing: border-box; - max-width: 50%; - margin-bottom: 24px; - margin-right: 16px; - width: 100%; -} - -.c22 { - box-sizing: border-box; - margin-bottom: 24px; - width: 50%; -} - -.c25 { - box-sizing: border-box; - margin-bottom: 4px; - width: 100%; -} - -.c26 { - line-height: 1.5; - margin: 0; - display: inline-flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-weight: 600; - outline: none; - position: relative; - text-align: center; - text-decoration: none; - text-transform: uppercase; - transition: all 0.3s; - -webkit-font-smoothing: antialiased; - color: #000000; - background: #9F85FF; - min-height: 40px; - font-size: 12px; - padding: 0px 40px; - margin-right: 16px; - width: 45%; -} - -.c26:hover, -.c26:focus { - background: #B29DFF; -} - -.c26:active { - background: #C5B6FF; -} - -.c26:disabled { - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.3); - cursor: auto; -} - -.c27 { - line-height: 1.5; - margin: 0; - display: inline-flex; - justify-content: center; - align-items: center; - box-sizing: border-box; - border: none; - border-radius: 4px; - cursor: pointer; - font-family: inherit; - font-weight: 600; - outline: none; - position: relative; - text-align: center; - text-decoration: none; - text-transform: uppercase; - transition: all 0.3s; - -webkit-font-smoothing: antialiased; - color: #FFFFFF; - background: rgba(255,255,255,0.07); - min-height: 40px; - font-size: 12px; - padding: 0px 40px; - width: 30%; -} - -.c27:hover, -.c27:focus { - background: rgba(255,255,255,0.13); -} - -.c27:active { - background: rgba(255,255,255,0.18); -} - -.c27:disabled { - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.3); - cursor: auto; -} - -.c6 { - overflow: hidden; - text-overflow: ellipsis; - font-weight: 300; - font-size: 22px; - line-height: 32px; - text-transform: uppercase; - margin: 0px; - color: #FFFFFF; -} - -.c15 { - overflow: hidden; - text-overflow: ellipsis; - font-size: 12px; - margin: 0px; - margin-top: 8px; - text-align: center; -} - -.c16 { - overflow: hidden; - text-overflow: ellipsis; - margin: 0px; - color: rgba(255,255,255,0.72); -} - -.c24 { - appearance: none; - border: 1px solid rgba(255,255,255,0.54); - border-radius: 4px; - box-sizing: border-box; - display: block; - height: 40px; - font-size: 16px; - padding: 0 16px; - outline: none; - width: 100%; - background: #222C59; - color: #FFFFFF; - margin-top: 4px; -} - -.c24:hover, -.c24:focus, -.c24:active { - border: 1px solid rgba(255,255,255,0.72); -} - -.c24::-ms-clear { - display: none; -} - -.c24::placeholder { - color: rgba(255,255,255,0.54); - opacity: 1; -} - -.c24:read-only { - cursor: not-allowed; -} - -.c24:disabled { - color: rgba(255,255,255,0.36); - border-color: rgba(255,255,255,0.36); -} - -.c20 { - color: #FFFFFF; - display: block; - font-size: 12px; - width: 100%; - margin-bottom: 4px; -} - -.c23 { - color: #FFFFFF; - display: block; - font-size: 12px; - width: 100%; - margin-bottom: 0px; -} - -.c17 { - color: #009EFF; - font-weight: normal; - background: none; - text-decoration: underline; - text-transform: none; -} - -.c5 { - display: flex; - align-items: center; -} - -.c9 { - display: flex; - flex-direction: column; -} - -.c11 { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; -} - -.c14 { - display: flex; - align-items: center; - justify-content: center; -} - -.c1 { - z-index: -1; - position: fixed; - right: 0; - bottom: 0; - top: 0; - left: 0; - background-color: rgba(0,0,0,0.5); - opacity: 1; - touch-action: none; -} - -.c0 { - position: fixed; - z-index: 1200; - right: 0; - bottom: 0; - top: 0; - left: 0; -} - -.c2 { - height: 100%; - outline: none; - color: black; - display: flex; - align-items: center; - justify-content: center; - opacity: 1; - will-change: opacity; - transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; -} - -.c3 { - padding: 32px; - padding-top: 24px; - background: #222C59; - color: #FFFFFF; - border-radius: 8px; - box-shadow: 0 8px 32px rgba(0,0,0,0.24); - display: flex; - flex-direction: column; - position: relative; - overflow-y: auto; - max-height: calc(100% - 96px); - width: 484px; -} - -.c21 .react-select-container { - box-sizing: border-box; - display: block; - font-size: 14px; - outline: none; - width: 100%; - color: #FFFFFF; - background-color: transparent; - margin-bottom: 0px; - border-radius: 4px; -} - -.c21 .react-select__control { - outline: none; - min-height: 40px; - height: fit-content; - border: 1px solid rgba(255,255,255,0.54); - border-radius: 4px; - background-color: transparent; - box-shadow: none; -} - -.c21 .react-select__control .react-select__dropdown-indicator { - color: rgba(255,255,255,0.54); -} - -.c21 .react-select__control:hover, -.c21 .react-select__control:focus, -.c21 .react-select__control:active { - border: 1px solid rgba(255,255,255,0.72); - background-color: rgba(255,255,255,0.07); - cursor: pointer; -} - -.c21 .react-select__control:hover .react-select__dropdown-indicator, -.c21 .react-select__control:focus .react-select__dropdown-indicator, -.c21 .react-select__control:active .react-select__dropdown-indicator { - color: #FFFFFF; -} - -.c21 .react-select__control .react-select__indicator:hover, -.c21 .react-select__control .react-select__dropdown-indicator:hover, -.c21 .react-select__control .react-select__indicator:focus, -.c21 .react-select__control .react-select__dropdown-indicator:focus, -.c21 .react-select__control .react-select__indicator:active, -.c21 .react-select__control .react-select__dropdown-indicator:active { - color: #FFFFFF; -} - -.c21 .react-select__control--is-focused { - border-color: rgba(255,255,255,0.72); - background-color: rgba(255,255,255,0.07); - cursor: pointer; -} - -.c21 .react-select__control--is-focused .react-select__dropdown-indicator { - color: #FFFFFF; -} - -.c21 .react-select__single-value { - color: #FFFFFF; -} - -.c21 .react-select__placeholder { - color: rgba(255,255,255,0.54); -} - -.c21 .react-select__multi-value { - background-color: rgba(255,255,255,0.13); -} - -.c21 .react-select__multi-value .react-select__multi-value__label { - color: #FFFFFF; - padding: 0 6px; -} - -.c21 .react-select__multi-value .react-select__multi-value__remove { - color: #FFFFFF; -} - -.c21 .react-select__multi-value .react-select__multi-value__remove:hover { - background-color: rgba(255,255,255,0.07); - color: #FF6257; -} - -.c21 .react-select__option:hover { - cursor: pointer; - background-color: rgba(255,255,255,0.07); -} - -.c21 .react-select__option--is-focused { - background-color: rgba(255,255,255,0.07); -} - -.c21 .react-select__option--is-focused:hover { - cursor: pointer; - background-color: rgba(255,255,255,0.07); -} - -.c21 .react-select__option--is-selected { - background-color: rgba(255,255,255,0.13); - color: inherit; - font-weight: 500; -} - -.c21 .react-select__option--is-selected:hover { - background-color: rgba(255,255,255,0.13); -} - -.c21 .react-select__clear-indicator { - color: rgba(255,255,255,0.72); -} - -.c21 .react-select__clear-indicator:hover, -.c21 .react-select__clear-indicator:focus { - background-color: rgba(255,255,255,0.07); -} - -.c21 .react-select__clear-indicator:hover svg, -.c21 .react-select__clear-indicator:focus svg { - color: #FF6257; -} - -.c21 .react-select__menu { - margin-top: 0px; - background-color: #4A5688; - box-shadow: 0px 5px 5px -3px rgba(0,0,0,0.2),0px 8px 10px 1px rgba(0,0,0,0.14),0px 3px 14px 2px rgba(0,0,0,0.12); -} - -.c21 .react-select__menu .react-select__menu-list::-webkit-scrollbar-thumb { - background: rgba(255,255,255,0.13); - border-radius: 4px; -} - -.c21 .react-select__indicator-separator { - display: none; -} - -.c21 .react-select__loading-indicator { - display: none; -} - -.c21 .react-select__control--is-disabled { - color: rgba(255,255,255,0.36); - border: 1px solid rgba(255,255,255,0.36); -} - -.c21 .react-select__control--is-disabled .react-select__single-value, -.c21 .react-select__control--is-disabled .react-select__placeholder { - color: rgba(255,255,255,0.36); -} - -.c21 .react-select__control--is-disabled .react-select__indicator { - color: rgba(255,255,255,0.36); -} - -.c21 .react-select__input { - color: #FFFFFF; -} - -.c12 { - background: rgba(255,255,255,0.07); -} - -
-