mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-29 22:02:04 +00:00
270 lines
6.7 KiB
TypeScript
270 lines
6.7 KiB
TypeScript
/**
|
||
* @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'
|
||
);
|
||
}
|