Files
claude-task-master/apps/cli/src/utils/auth-ui.ts

270 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @fileoverview Shared Auth UI Utilities
* Provides reusable UI components for authentication flows:
* - Countdown timer with spinner
* - MFA code prompting
*
* These are presentation-layer concerns that use ora, inquirer, and chalk.
*/
import {
AUTH_TIMEOUT_MS,
type AuthCredentials,
AuthenticationError,
MFA_MAX_ATTEMPTS
} from '@tm/core';
import chalk from 'chalk';
import inquirer from 'inquirer';
import ora, { type Ora } from 'ora';
import * as ui from './ui.js';
// Re-export constants for convenience
export { AUTH_TIMEOUT_MS, MFA_MAX_ATTEMPTS };
/**
* Countdown timer state
*/
export interface CountdownState {
interval: NodeJS.Timeout | null;
spinner: Ora | null;
}
/**
* Creates and manages an authentication countdown timer
* Displays a spinner with remaining time during OAuth flow
*/
export class AuthCountdownTimer {
private interval: NodeJS.Timeout | null = null;
private spinner: Ora | null = null;
private readonly totalMs: number;
constructor(totalMs: number = AUTH_TIMEOUT_MS) {
this.totalMs = totalMs;
}
/**
* Start the countdown timer
*/
start(): void {
const startTime = Date.now();
const endTime = startTime + this.totalMs;
const updateCountdown = () => {
const remaining = Math.max(0, endTime - Date.now());
const mins = Math.floor(remaining / 60000);
const secs = Math.floor((remaining % 60000) / 1000);
const timeStr = `${mins}:${secs.toString().padStart(2, '0')}`;
if (this.spinner) {
this.spinner.text = `Waiting for authentication... ${chalk.cyan(timeStr)} remaining`;
}
if (remaining <= 0 && this.interval) {
clearInterval(this.interval);
}
};
const initialMins = Math.floor(this.totalMs / 60000);
const initialSecs = Math.floor((this.totalMs % 60000) / 1000);
const initialTimeStr = `${initialMins}:${initialSecs.toString().padStart(2, '0')}`;
this.spinner = ora({
text: `Waiting for authentication... ${chalk.cyan(initialTimeStr)} remaining`,
spinner: 'dots'
}).start();
this.interval = setInterval(updateCountdown, 1000);
}
/**
* Stop the countdown timer
* @param result - 'success', 'failure', or 'mfa' (MFA required, not success/failure)
*/
stop(result: 'success' | 'failure' | 'mfa'): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
if (this.spinner) {
if (result === 'mfa') {
this.spinner.stop(); // MFA required, not success/failure
} else if (result === 'success') {
this.spinner.succeed('Authentication successful!');
} else {
this.spinner.fail('Authentication failed');
}
this.spinner = null;
}
}
/**
* Ensure cleanup even if not explicitly stopped
*/
cleanup(): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
if (this.spinner) {
this.spinner.stop();
this.spinner = null;
}
}
}
/**
* Display MFA required message
*/
export function displayMFARequired(): void {
console.log(
chalk.yellow('\n⚠ Multi-factor authentication is enabled on your account')
);
console.log(
chalk.white(' Please enter the 6-digit code from your authenticator app\n')
);
}
/**
* Prompt for MFA code with validation
* @returns The entered MFA code or throws if cancelled
*/
export async function promptForMFACode(): Promise<string> {
try {
const response = await inquirer.prompt([
{
type: 'input',
name: 'mfaCode',
message: 'Enter your 6-digit MFA code:',
validate: (input: string) => {
const trimmed = (input || '').trim();
if (trimmed.length === 0) {
return 'MFA code cannot be empty';
}
if (!/^\d{6}$/.test(trimmed)) {
return 'MFA code must be exactly 6 digits (0-9)';
}
return true;
}
}
]);
return response.mfaCode.trim();
} catch (error: any) {
// Handle user cancellation (Ctrl+C)
if (
error.name === 'ExitPromptError' ||
error.message?.includes('force closed')
) {
ui.displayWarning(' MFA verification cancelled by user');
throw new AuthenticationError(
'MFA verification cancelled',
'MFA_VERIFICATION_FAILED'
);
}
throw error;
}
}
/**
* Display MFA verification success
*/
export function displayMFASuccess(): void {
console.log(chalk.green('\n✓ MFA verification successful!'));
}
/**
* Display invalid MFA code message
* @param remaining - Number of attempts remaining
*/
export function displayInvalidMFACode(remaining: number): void {
if (remaining > 0) {
ui.displayError(`Invalid MFA code. Please try again.`);
}
}
/**
* Display authentication URL and instructions
* @param authUrl - The OAuth URL to display
*/
export function displayAuthInstructions(authUrl: string): void {
console.log(chalk.blue.bold('\n[auth] Browser Authentication\n'));
console.log(chalk.white(' Opening your browser to authenticate...'));
console.log(chalk.gray(" If the browser doesn't open, visit:"));
console.log(chalk.cyan.underline(` ${authUrl}\n`));
}
/**
* Display waiting for auth message
*/
export function displayWaitingForAuth(): void {
console.log(
chalk.dim(' If you signed up, check your email to confirm your account.')
);
console.log(
chalk.dim(' The CLI will automatically detect when you log in.\n')
);
}
/**
* MFA verification options for verifyMFAWithRetry
*/
export interface MFAVerificationUIOptions {
maxAttempts?: number;
onInvalidCode?: (attempt: number, remaining: number) => void;
}
/**
* Create standard MFA verification callbacks
* These can be passed to AuthDomain.verifyMFAWithRetry or AuthManager.verifyMFAWithRetry
*/
export function createMFACallbacks(options: MFAVerificationUIOptions = {}) {
return {
promptCallback: promptForMFACode,
options: {
maxAttempts: options.maxAttempts ?? MFA_MAX_ATTEMPTS,
onInvalidCode: options.onInvalidCode ?? displayInvalidMFACode
}
};
}
/**
* Handle complete MFA verification flow
* @param authDomainOrManager - AuthDomain or AuthManager instance
* @param factorId - The MFA factor ID
* @returns AuthCredentials on success
*/
export async function handleMFAFlow(
verifyMFAWithRetry: (
factorId: string,
promptCallback: () => Promise<string>,
options: {
maxAttempts: number;
onInvalidCode: (attempt: number, remaining: number) => void;
}
) => Promise<{
success: boolean;
credentials?: AuthCredentials;
attemptsUsed: number;
}>,
factorId: string
): Promise<AuthCredentials> {
displayMFARequired();
const { promptCallback, options } = createMFACallbacks();
const result = await verifyMFAWithRetry(factorId, promptCallback, options);
if (result.success && result.credentials) {
displayMFASuccess();
return result.credentials;
}
throw new AuthenticationError(
`MFA verification failed after ${result.attemptsUsed} attempts`,
'MFA_VERIFICATION_FAILED'
);
}