diff --git a/web/packages/teleterm/src/services/config/appConfigSchema.ts b/web/packages/teleterm/src/services/config/appConfigSchema.ts index a18f81469dbdf..264a6b8e4b5c9 100644 --- a/web/packages/teleterm/src/services/config/appConfigSchema.ts +++ b/web/packages/teleterm/src/services/config/appConfigSchema.ts @@ -114,6 +114,12 @@ export const createAppConfigSchema = (platform: Platform) => { .boolean() .default(false) .describe('Enables sharing the computer.'), + 'headless.skipConfirm': z + .boolean() + .default(false) + .describe( + 'Skips the confirmation prompt for headless login approval and instead prompts for WebAuthn immediately.' + ), }); }; diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx index 3055740aba833..fc73d9b1c3482 100644 --- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx +++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessAuthentication.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useRef } from 'react'; +import React, { useRef, useEffect } from 'react'; import { useAsync } from 'shared/hooks/useAsync'; @@ -29,6 +29,7 @@ interface HeadlessAuthenticationProps { rootClusterUri: RootClusterUri; headlessAuthenticationId: string; clientIp: string; + skipConfirm: boolean; onCancel(): void; onSuccess(): void; } @@ -68,18 +69,23 @@ export function HeadlessAuthentication(props: HeadlessAuthenticationProps) { } } + useEffect(() => { + if (props.skipConfirm && updateHeadlessStateAttempt.status === '') { + handleHeadlessApprove(); + } + }, []); + return ( { - props.onCancel(); - refAbortCtrl.current.abort(); - }} + onCancel={props.onCancel} /> ); } diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx index 66d79f760dade..41441de550add 100644 --- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx +++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.story.tsx @@ -30,7 +30,9 @@ export const Story = () => ( {}} + abortApproval={() => {}} onReject={async () => {}} updateHeadlessStateAttempt={makeEmptyAttempt()} onCancel={() => {}} diff --git a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx index cf495ce7a4a24..e01e182e07783 100644 --- a/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx +++ b/web/packages/teleterm/src/ui/HeadlessAuthn/HeadlessPrompt/HeadlessPrompt.tsx @@ -32,7 +32,9 @@ import type * as tsh from 'teleterm/services/tshd/types'; export type HeadlessPromptProps = { cluster: tsh.Cluster; clientIp: string; + skipConfirm: boolean; onApprove(): Promise; + abortApproval(): void; onReject(): Promise; headlessAuthenticationId: string; updateHeadlessStateAttempt: Attempt; @@ -42,13 +44,17 @@ export type HeadlessPromptProps = { export function HeadlessPrompt({ cluster, clientIp, + skipConfirm, onApprove, + abortApproval, onReject, headlessAuthenticationId, updateHeadlessStateAttempt, onCancel, }: HeadlessPromptProps) { - const [waitForMfa, setWaitForMfa] = useState(false); + // skipConfirm automatically attempts to approve a headless auth attempt, + // so let's show waitForMfa from the very beginning in that case. + const [waitForMfa, setWaitForMfa] = useState(skipConfirm); return ( Headless command on {cluster.name} - + { + abortApproval(); + onCancel(); + }} + > @@ -72,52 +85,55 @@ export function HeadlessPrompt({ {updateHeadlessStateAttempt.statusText} )} - - {!waitForMfa && ( - <> - - - Someone initiated a headless command from {clientIp}. -
- If it was not you, click Reject and contact your administrator. -
- - Request ID: {headlessAuthenticationId} - -
- - { - e.preventDefault(); - setWaitForMfa(true); - onApprove(); - }} - > - Approve - - { - e.preventDefault(); - onReject(); - }} - > - Reject - - - - )} + + + Someone initiated a headless command from {clientIp}. +
+ If it was not you, click Cancel and contact your administrator. +
+ + Request ID: {headlessAuthenticationId} + +
{waitForMfa && ( Complete MFA verification to approve the Headless Login. - + { + abortApproval(); + onReject(); + }} + /> )} + {!waitForMfa && ( + + { + e.preventDefault(); + setWaitForMfa(true); + onApprove(); + }} + > + Approve + + { + e.preventDefault(); + onReject(); + }} + > + Cancel + + + )}
); } diff --git a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx index 139516f240162..255c9fd565d20 100644 --- a/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx +++ b/web/packages/teleterm/src/ui/ModalsHost/ModalsHost.tsx @@ -139,6 +139,7 @@ function renderDialog(dialog: Dialog, handleClose: () => void) { rootClusterUri={dialog.rootClusterUri} headlessAuthenticationId={dialog.headlessAuthenticationId} clientIp={dialog.headlessAuthenticationClientIp} + skipConfirm={dialog.skipConfirm} onCancel={() => { handleClose(); dialog.onCancel(); diff --git a/web/packages/teleterm/src/ui/appContext.ts b/web/packages/teleterm/src/ui/appContext.ts index 9e57201994a98..ae4b61410f92a 100644 --- a/web/packages/teleterm/src/ui/appContext.ts +++ b/web/packages/teleterm/src/ui/appContext.ts @@ -143,7 +143,8 @@ export default class AppContext implements IAppContext { this.headlessAuthenticationService = new HeadlessAuthenticationService( mainProcessClient, this.modalsService, - tshClient + tshClient, + this.configService ); } diff --git a/web/packages/teleterm/src/ui/services/headlessAuthn/headlessAuthnService.ts b/web/packages/teleterm/src/ui/services/headlessAuthn/headlessAuthnService.ts index d20ffef588214..fca254250aecc 100644 --- a/web/packages/teleterm/src/ui/services/headlessAuthn/headlessAuthnService.ts +++ b/web/packages/teleterm/src/ui/services/headlessAuthn/headlessAuthnService.ts @@ -17,6 +17,7 @@ import { SendPendingHeadlessAuthenticationRequest } from 'teleterm/services/tshdEvents'; import { MainProcessClient } from 'teleterm/types'; import { ModalsService } from 'teleterm/ui/services/modals'; +import { ConfigService } from 'teleterm/services/config'; import type * as types from 'teleterm/services/tshd/types'; @@ -24,14 +25,21 @@ export class HeadlessAuthenticationService { constructor( private mainProcessClient: MainProcessClient, private modalsService: ModalsService, - private tshClient: types.TshClient + private tshClient: types.TshClient, + private configService: ConfigService ) {} sendPendingHeadlessAuthentication( request: SendPendingHeadlessAuthenticationRequest, onRequestCancelled: (callback: () => void) => void ): Promise { - this.mainProcessClient.forceFocusWindow(); + const skipConfirm = this.configService.get('headless.skipConfirm').value; + + // If the user wants to skip the confirmation step, then don't force the window. + // Instead, they can just tap their blinking yubikey with the window in the background. + if (!skipConfirm) { + this.mainProcessClient.forceFocusWindow(); + } return new Promise(resolve => { const { closeDialog } = this.modalsService.openImportantDialog({ @@ -39,6 +47,7 @@ export class HeadlessAuthenticationService { rootClusterUri: request.rootClusterUri, headlessAuthenticationId: request.headlessAuthenticationId, headlessAuthenticationClientIp: request.headlessAuthenticationClientIp, + skipConfirm: skipConfirm, onSuccess: () => resolve(), onCancel: () => resolve(), }); diff --git a/web/packages/teleterm/src/ui/services/modals/modalsService.ts b/web/packages/teleterm/src/ui/services/modals/modalsService.ts index 1ee4ff057a351..c7136022b3701 100644 --- a/web/packages/teleterm/src/ui/services/modals/modalsService.ts +++ b/web/packages/teleterm/src/ui/services/modals/modalsService.ts @@ -198,6 +198,7 @@ export interface DialogHeadlessAuthentication { rootClusterUri: RootClusterUri; headlessAuthenticationId: string; headlessAuthenticationClientIp: string; + skipConfirm: boolean; onSuccess(): void; onCancel(): void; }