@hhoangphuoc/escape-room-cli
Version:
A CLI for playing AI-generated escape room games. Install globally with: npm install -g @hhoangphuoc/escape-room-cli
304 lines (303 loc) • 16 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import Spinner from 'ink-spinner';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { getApiUrl } from '../utils/apiConfig.js';
import { useAuth } from '../hooks/useAuth.js';
import { validateName, validateEmail, validateOpenAIApiKey, validateAnthropicApiKey, validateRegistrationData } from '../utils/validation.js';
const USER_CONFIG_FILE = path.join(os.homedir(), '.escape-room-config.json');
export async function handleLogin(userId, apiKey, provider) {
const apiUrl = getApiUrl();
const loginPayload = { userId };
if (apiKey && provider) {
loginPayload.apiKey = apiKey;
loginPayload.provider = provider;
}
const loginResponse = await fetch(`${apiUrl}/api/users/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loginPayload)
});
return loginResponse;
}
const UserRegistration = ({ onRegistrationComplete, onCancel, username = '', email = '' }) => {
const { login } = useAuth();
const [step, setStep] = useState('verifying');
const [name, setName] = useState(username);
const [userEmail, setUserEmail] = useState(email); // Can be empty string
const [currentApiKeyProvider, setCurrentApiKeyProvider] = useState('openai');
const [currentCliApiKey, setCurrentCliApiKey] = useState(''); // Can be empty string
const [message, setMessage] = useState('Checking for existing configuration...');
const [isSubmitting, setIsSubmitting] = useState(false);
// Validation states
const [validationErrors, setValidationErrors] = useState({});
const [isValidating, setIsValidating] = useState(false);
const [validationMessage, setValidationMessage] = useState('');
// Handle ESC key to cancel registration
useInput((_, key) => {
if (key.escape && onCancel) {
// Don't allow cancellation during submission/loading states
if (step !== 'loading' && step !== 'verifying' && !isSubmitting) {
onCancel();
}
}
});
useEffect(() => {
setMessage('Please register to continue.');
let initialStep = 'name';
if (username) {
setName(username);
initialStep = 'email';
if (email) {
setUserEmail(email);
initialStep = 'apiKey';
}
}
const openaiEnvKey = process.env['OPENAI_API_KEY'];
const anthropicEnvKey = process.env['ANTHROPIC_API_KEY'];
if (openaiEnvKey) {
setCurrentCliApiKey(openaiEnvKey);
setCurrentApiKeyProvider('openai');
}
else if (anthropicEnvKey) {
setCurrentCliApiKey(anthropicEnvKey);
setCurrentApiKeyProvider('anthropic');
}
setStep(initialStep);
}, []);
// Validation functions
const validateNameInput = async (nameValue) => {
const validation = validateName(nameValue);
if (!validation.isValid) {
setValidationErrors(prev => ({ ...prev, name: validation.error }));
return false;
}
setValidationErrors(prev => ({ ...prev, name: undefined }));
return true;
};
const validateEmailInput = async (emailValue) => {
if (!emailValue.trim()) {
// Email is optional
setValidationErrors(prev => ({ ...prev, email: undefined }));
return true;
}
setIsValidating(true);
setValidationMessage('Validating email...');
try {
const validation = await validateEmail(emailValue);
if (!validation.isValid) {
setValidationErrors(prev => ({ ...prev, email: validation.error }));
return false;
}
setValidationErrors(prev => ({ ...prev, email: undefined }));
return true;
}
catch (error) {
setValidationErrors(prev => ({ ...prev, email: 'Email validation failed' }));
return false;
}
finally {
setIsValidating(false);
setValidationMessage('');
}
};
const validateApiKeyInput = async (apiKeyValue) => {
if (!apiKeyValue.trim()) {
// API key is optional
setValidationErrors(prev => ({ ...prev, apiKey: undefined }));
return true;
}
setIsValidating(true);
setValidationMessage('Validating API key...');
try {
let validation;
if (currentApiKeyProvider === 'openai' || apiKeyValue.startsWith('sk-proj-')) {
validation = await validateOpenAIApiKey(apiKeyValue);
}
else {
validation = validateAnthropicApiKey(apiKeyValue);
}
if (!validation.isValid) {
setValidationErrors(prev => ({ ...prev, apiKey: validation.error }));
return false;
}
setValidationErrors(prev => ({ ...prev, apiKey: undefined }));
return true;
}
catch (error) {
setValidationErrors(prev => ({ ...prev, apiKey: 'API key validation failed' }));
return false;
}
finally {
setIsValidating(false);
setValidationMessage('');
}
};
// Step navigation with validation
const handleNameSubmit = async () => {
const isValid = await validateNameInput(name);
if (isValid) {
setStep('email');
}
};
const handleEmailSubmit = async () => {
const isValid = await validateEmailInput(userEmail);
if (isValid) {
setStep('apiKey');
}
};
const saveUserConfig = async () => {
if (isSubmitting)
return;
setIsSubmitting(true);
setStep('loading');
setMessage('Validating registration data...');
// Validation before submission
const validationResult = await validateRegistrationData({
name,
email: userEmail || undefined,
apiKey: currentCliApiKey || undefined,
provider: currentApiKeyProvider
});
if (!validationResult.isValid) {
setValidationErrors(validationResult.errors);
setMessage('Please fix validation errors and try again.');
setStep('apiKey');
setIsSubmitting(false);
return;
}
setMessage('Registering and saving configuration...');
const finalRegApiKey = currentCliApiKey || undefined;
const finalRegProvider = currentApiKeyProvider;
// Modified: Don't send API key to backend - keep it local only
const registrationPayload = { name, provider: finalRegProvider };
if (userEmail)
registrationPayload.email = userEmail;
// API key is NOT included in the payload to backend - UNCOMMENTED
//if (finalRegApiKey) registrationPayload.apiKey = finalRegApiKey;
let receivedUserId;
let receivedToken;
try {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/users/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registrationPayload)
});
const data = await response.json();
if (response.ok && data.userId && data.token) {
receivedUserId = data.userId;
receivedToken = data.token;
setMessage('Registered with backend server!');
try {
// API key saved locally only
const configToSave = {
name,
userId: receivedUserId,
registeredAt: new Date().toISOString(),
apiKeys: finalRegApiKey && finalRegProvider ? { [finalRegProvider]: finalRegApiKey } : undefined
};
if (userEmail)
configToSave.email = userEmail;
fs.writeFileSync(USER_CONFIG_FILE, JSON.stringify(configToSave, null, 2));
setMessage('Configuration saved! Registration complete.');
login({
userName: name,
userId: receivedUserId,
sessionToken: receivedToken,
apiKey: finalRegApiKey,
apiKeyProvider: finalRegProvider
});
onRegistrationComplete();
}
catch (saveError) {
setMessage('Registered successfully, but could not save configuration locally.');
login({
userName: name,
userId: receivedUserId,
sessionToken: receivedToken,
apiKey: finalRegApiKey,
apiKeyProvider: finalRegProvider
});
onRegistrationComplete();
}
}
else {
// Enhanced error handling for different HTTP status codes
let errorMessage = `Registration failed: ${data.error || 'Unknown error'}`;
if (response.status === 409) {
errorMessage = `Email already registered: ${data.error || 'Try using a different email.'}`;
}
else if (response.status === 400) {
errorMessage = `Invalid registration data: ${data.error || 'Please check your inputs.'}`;
}
else if (response.status >= 500) {
errorMessage = `Server error (${response.status}): ${data.error || 'Please try again later.'}`;
}
setMessage(errorMessage);
setStep('apiKey');
setIsSubmitting(false);
}
}
catch (networkError) {
let errorMessage = 'Network error. Could not register.';
if (networkError instanceof Error) {
if (networkError.message.includes('fetch')) {
errorMessage = 'Connection failed. Please check your internet connection and server status.';
}
else if (networkError.message.includes('timeout')) {
errorMessage = 'Request timed out. Please try again.';
}
}
setMessage(errorMessage);
setStep('apiKey');
setIsSubmitting(false);
}
};
if (step === 'verifying' || step === 'loading') {
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", message] }) }));
}
if (step === 'name') {
return (_jsxs(Box, { flexDirection: "column", rowGap: 0.5, padding: 0.5, children: [_jsxs(Box, { flexDirection: "column", padding: 0.5, marginBottom: 1, borderStyle: "round", borderColor: "cyan", rowGap: 0.5, children: [_jsx(Text, { bold: true, color: "cyan", children: "What's your name?" }), _jsx(TextInput, { value: name, onChange: (value) => {
setName(value);
setValidationErrors(prev => ({ ...prev, name: undefined })); // Clear error on change
}, onSubmit: handleNameSubmit, placeholder: "Your Name" }), validationErrors.name && (_jsxs(Text, { color: "red", children: ["\u2717 ", validationErrors.name] }))] }), _jsx(Box, { marginTop: 0.5, children: _jsxs(Text, { color: "gray", children: ["Press Enter to continue ", onCancel ? ' • ESC to cancel' : ''] }) })] }));
}
if (step === 'email') {
return (_jsxs(Box, { flexDirection: "column", rowGap: 0.5, padding: 0.5, children: [_jsxs(Box, { flexDirection: "column", padding: 0.5, marginBottom: 1, borderStyle: "round", borderColor: "cyan", rowGap: 0.5, children: [_jsx(Text, { bold: true, color: "cyan", children: "Your email (optional):" }), _jsx(TextInput, { value: userEmail, onChange: (value) => {
setUserEmail(value);
setValidationErrors(prev => ({ ...prev, email: undefined })); // Clear error on change
}, onSubmit: handleEmailSubmit, placeholder: "your.email@nedap.com" }), validationErrors.email && (_jsxs(Text, { color: "red", children: ["\u2717 ", validationErrors.email] })), isValidating && validationMessage && (_jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", validationMessage] }))] }), _jsx(Box, { marginTop: 0.5, children: _jsxs(Text, { color: "gray", children: ["Press Enter to continue ", onCancel ? ' • ESC to cancel' : ''] }) })] }));
}
if (step === 'apiKey') {
const handleApiKeySubmit = async () => {
if (currentCliApiKey.trim()) {
const isValid = await validateApiKeyInput(currentCliApiKey);
if (!isValid) {
return;
}
}
await saveUserConfig();
};
return (_jsxs(Box, { flexDirection: "column", rowGap: 0.5, padding: 0.5, children: [_jsxs(Box, { flexDirection: "column", padding: 0.5, marginBottom: 1, borderStyle: "round", borderColor: "cyan", rowGap: 0.5, children: [_jsx(Text, { bold: true, color: "cyan", children: "Your AI API Key (optional - OpenAI or Anthropic):" }), _jsxs(Box, { marginTop: 1, flexDirection: "row", alignItems: "center", children: [_jsx(Text, { color: "cyan", children: "Current AI Provider: " }), _jsxs(Text, { children: [" [", currentApiKeyProvider.toUpperCase(), "] "] })] }), _jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: "API Key: " }), _jsx(TextInput, { value: currentCliApiKey, onChange: (value) => {
setCurrentCliApiKey(value);
setValidationErrors(prev => ({ ...prev, apiKey: undefined })); // Clear error on change
// Auto-detect provider based on key format
if (value.startsWith('sk-proj-')) {
setCurrentApiKeyProvider('openai');
}
else if (value.startsWith('sk-ant-') || value.startsWith('anthropic')) {
setCurrentApiKeyProvider('anthropic');
}
}, onSubmit: handleApiKeySubmit, placeholder: "sk-proj-... or sk-ant-...", mask: "*" })] }), validationErrors.apiKey && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "red", children: ["\u2717 ", validationErrors.apiKey] }) })), isValidating && validationMessage && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " ", validationMessage] }) })), _jsxs(Box, { marginTop: 1, children: [currentCliApiKey && _jsxs(Text, { color: "gray", children: ["Your key will be stored locally only in ", USER_CONFIG_FILE] }), !currentCliApiKey && _jsx(Text, { color: "gray", children: "You can skip this and add an API key later for AI features" })] })] }), _jsx(Box, { marginTop: 0.5, children: _jsxs(Text, { color: "gray", children: ["Press Enter to register and save", onCancel ? ' • ESC to cancel' : '', "."] }) })] }));
}
if (step === 'complete') {
return (_jsx(Box, { padding: 1, children: _jsxs(Text, { color: "green", children: ["\u2713 ", message || 'Ready! Starting game...'] }) }));
}
return _jsx(Text, { children: "Loading registration..." });
};
export default UserRegistration;