Compare commits
10 Commits
71c27fb060
...
01da8fe907
46
README.md
46
README.md
@@ -107,6 +107,14 @@ Advanced automation capabilities with new robust connectivity:
|
|||||||
- **Competitive Intelligence** - AI researches top competitors for design inspiration
|
- **Competitive Intelligence** - AI researches top competitors for design inspiration
|
||||||
- **Mobile-First** - All generated code is responsive by default
|
- **Mobile-First** - All generated code is responsive by default
|
||||||
|
|
||||||
|
### 📦 UX Package Generator (New!)
|
||||||
|
|
||||||
|
Export your entire frontend (HTML/CSS/JS) into a single, portable `ux_package.json` file.
|
||||||
|
|
||||||
|
- **Portable Payload** - Perfect for loading into other AI coding agents (Gemini, ChatGPT, Claude) to build the backend logic.
|
||||||
|
- **One-Click Export** - Instantly package your project's frontend artifacts and instructions.
|
||||||
|
- **Backend-Ready** - Includes specific prompts instructing the AI how to implement the server-side logic based on your frontend.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ System Requirements
|
## 🛠️ System Requirements
|
||||||
@@ -176,19 +184,33 @@ Access **state-of-the-art open-weight models** for FREE via Ollama Cloud:
|
|||||||
|
|
||||||
#### 🚀 Available Free Models:
|
#### 🚀 Available Free Models:
|
||||||
|
|
||||||
| Model | Size | Best For |
|
| Model | Size | Category | Best For |
|
||||||
|-------|------|----------|
|
|-------|------|----------|----------|
|
||||||
| **GPT-OSS 120B** | 120B | OpenAI's open-weight reasoning model |
|
| **GPT-OSS 120B** | 120B | Flagship | OpenAI's open-weight reasoning model |
|
||||||
| **DeepSeek V3.2** | MoE | Superior reasoning & agent performance |
|
| **DeepSeek V3.2** | MoE | Flagship | Superior reasoning & agent performance |
|
||||||
| **Gemini 3 Pro Preview** | Cloud | Google's SOTA reasoning model |
|
| **DeepSeek V3.1** | 671B | Flagship | Hybrid thinking/non-thinking mode |
|
||||||
| **Qwen3 Coder 480B** | 480B | Agentic coding, long context |
|
| **Gemini 3 Pro Preview** | Cloud | Flagship | Google's most intelligent model with SOTA reasoning |
|
||||||
| **Devstral 2 123B** | 123B | Multi-file editing, software agents |
|
| **Gemini 3 Flash Preview** | Cloud | Fast | Frontier intelligence built for speed |
|
||||||
| **Kimi K2** | MoE | State-of-the-art coding agent tasks |
|
| **Qwen3 Coder 480B** | 480B | Coding | Agentic coding, long context |
|
||||||
| **Qwen3 VL 235B** | 235B | Vision + language understanding |
|
| **Qwen3 Coder 30B** | 30B | Coding | Agentic coding model |
|
||||||
| **Gemini 3 Flash** | Cloud | Fast, frontier intelligence |
|
| **Devstral 2 123B** | 123B | Coding | Multi-file editing, software agents |
|
||||||
| **Ministral 3** | 3-14B | Edge deployment, fast responses |
|
| **Devstral Small 2 24B** | 24B | Coding | Vision + tools for software engineering agents |
|
||||||
|
| **RNJ-1 8B** | 8B | Coding | Essential AI model optimized for code and STEM |
|
||||||
|
| **Qwen3 Next 80B** | 80B | Reasoning | Strong parameter efficiency and inference speed |
|
||||||
|
| **Kimi K2** | MoE | Reasoning | State-of-the-art coding agent tasks |
|
||||||
|
| **Kimi K2 Thinking** | MoE | Reasoning | Moonshot AI's best open-source thinking model |
|
||||||
|
| **Cogito 2.1** | 671B | Reasoning | Instruction tuned generative model |
|
||||||
|
| **Qwen3 VL 235B** | 235B | Vision | Most powerful vision-language model in Qwen family |
|
||||||
|
| **Qwen3 VL 32B** | 32B | Vision | Powerful vision-language understanding |
|
||||||
|
| **Gemma 3 27B** | 27B | Vision | Most capable model that runs on a single GPU |
|
||||||
|
| **Ministral 3 14B** | 14B | Fast | Edge deployment, fast responses |
|
||||||
|
| **Ministral 3 8B** | 8B | Fast | Edge deployment with vision + tools |
|
||||||
|
| **Nemotron 3 Nano** | Nano | Fast | Efficient, open, and intelligent agentic model |
|
||||||
|
| **GLM 4.6** | Large | Flagship | Advanced agentic, reasoning and coding |
|
||||||
|
| **MiniMax M2** | Large | Flagship | High-efficiency LLM for coding agents |
|
||||||
|
| **Mistral Large 3** | MoE | Flagship | Multimodal MoE for production-grade tasks |
|
||||||
|
|
||||||
...and many more! Open the **AI Model Manager** in Goose Ultra to see all available models.
|
...and more! Open the **AI Model Manager** in Goose Ultra to see the full list.
|
||||||
|
|
||||||
#### 📖 Ollama Cloud Docs:
|
#### 📖 Ollama Cloud Docs:
|
||||||
- API Documentation: [docs.ollama.com/cloud](https://docs.ollama.com/cloud)
|
- API Documentation: [docs.ollama.com/cloud](https://docs.ollama.com/cloud)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import * as viAutomation from './vi-automation.js';
|
|||||||
import { execFile } from 'child_process';
|
import { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
// User Authentication & Qwen OAuth
|
||||||
|
import * as userData from './user-data.js';
|
||||||
|
import * as qwenOAuth from './qwen-oauth.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -703,4 +707,168 @@ ipcMain.handle('vi-open-browser', async (_, { url }) => {
|
|||||||
return await viAutomation.openBrowser(url);
|
return await viAutomation.openBrowser(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER AUTHENTICATION SYSTEM
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Get secret questions list
|
||||||
|
ipcMain.handle('user-get-secret-questions', () => {
|
||||||
|
return userData.SECRET_QUESTIONS;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new user account
|
||||||
|
ipcMain.handle('user-create', async (_, { displayName, questionId, answer }) => {
|
||||||
|
try {
|
||||||
|
const { user, secretCode } = userData.createUser(displayName, questionId, answer);
|
||||||
|
// Auto-start session for new user
|
||||||
|
const session = userData.startSession(user);
|
||||||
|
return { success: true, user, secretCode, session };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAuth] Create user failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticate user with secret code
|
||||||
|
ipcMain.handle('user-login', async (_, { secretCode }) => {
|
||||||
|
try {
|
||||||
|
const user = userData.authenticateUser(secretCode);
|
||||||
|
if (user) {
|
||||||
|
const session = userData.startSession(user);
|
||||||
|
return { success: true, user, session };
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Invalid secret code' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAuth] Login failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current session
|
||||||
|
ipcMain.handle('user-get-session', () => {
|
||||||
|
return userData.getCurrentSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout (end session)
|
||||||
|
ipcMain.handle('user-logout', async (_, { cleanData }) => {
|
||||||
|
try {
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
if (cleanData && session?.userId) {
|
||||||
|
userData.cleanUserData(session.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
userData.endSession();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAuth] Logout failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
ipcMain.handle('user-get-stats', async (_, { userId }) => {
|
||||||
|
try {
|
||||||
|
return userData.getUserStats(userId);
|
||||||
|
} catch (error) {
|
||||||
|
return { projectCount: 0, chatCount: 0, totalSizeBytes: 0, hasQwenTokens: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean user data without logout
|
||||||
|
ipcMain.handle('user-clean-data', async (_, { userId }) => {
|
||||||
|
try {
|
||||||
|
userData.cleanUserData(userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's projects directory
|
||||||
|
ipcMain.handle('user-get-projects-dir', (_, { userId }) => {
|
||||||
|
return userData.getUserProjectsDir(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// QWEN OAUTH (INLINE DEVICE FLOW)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Start Qwen OAuth device flow
|
||||||
|
ipcMain.on('qwen-auth-start', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!window || window.isDestroyed()) return;
|
||||||
|
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
await qwenOAuth.startDeviceFlow(
|
||||||
|
// onProgress
|
||||||
|
(progress) => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('qwen-auth-progress', progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// onSuccess
|
||||||
|
(credentials) => {
|
||||||
|
// Save tokens to user-specific location if logged in
|
||||||
|
if (session?.userId) {
|
||||||
|
qwenOAuth.saveUserTokens(session.userId, app.getPath('userData'), credentials);
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy location for backward compatibility
|
||||||
|
qwenOAuth.saveLegacyTokens(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('qwen-auth-success', credentials);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// onError
|
||||||
|
(error) => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('qwen-auth-error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel ongoing Qwen OAuth
|
||||||
|
ipcMain.on('qwen-auth-cancel', () => {
|
||||||
|
qwenOAuth.cancelAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Qwen auth status for current user
|
||||||
|
ipcMain.handle('qwen-get-auth-status', () => {
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
let tokens = null;
|
||||||
|
if (session?.userId) {
|
||||||
|
tokens = qwenOAuth.loadUserTokens(session.userId, app.getPath('userData'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy tokens if not logged in or no user tokens
|
||||||
|
if (!tokens) {
|
||||||
|
tokens = qwenOAuth.loadLegacyTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = tokens?.access_token &&
|
||||||
|
(!tokens.expiry_date || tokens.expiry_date > Date.now() + 30000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: !!tokens?.access_token,
|
||||||
|
isValid,
|
||||||
|
expiresAt: tokens?.expiry_date || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear Qwen tokens for current user
|
||||||
|
ipcMain.handle('qwen-clear-tokens', () => {
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
if (session?.userId) {
|
||||||
|
qwenOAuth.clearUserTokens(session.userId, app.getPath('userData'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
console.log('Goose Ultra Electron Main Process Started');
|
console.log('Goose Ultra Electron Main Process Started');
|
||||||
|
|||||||
@@ -97,5 +97,64 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
|
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
|
||||||
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
|
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
|
||||||
getModels: () => ipcRenderer.invoke('ollama-get-models')
|
getModels: () => ipcRenderer.invoke('ollama-get-models')
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// USER AUTHENTICATION SYSTEM
|
||||||
|
// ==========================================
|
||||||
|
user: {
|
||||||
|
// Get list of secret questions
|
||||||
|
getSecretQuestions: () => ipcRenderer.invoke('user-get-secret-questions'),
|
||||||
|
|
||||||
|
// Create a new account (returns { success, user, secretCode, session } or { success: false, error })
|
||||||
|
create: (displayName, questionId, answer) =>
|
||||||
|
ipcRenderer.invoke('user-create', { displayName, questionId, answer }),
|
||||||
|
|
||||||
|
// Login with secret code (returns { success, user, session } or { success: false, error })
|
||||||
|
login: (secretCode) => ipcRenderer.invoke('user-login', { secretCode }),
|
||||||
|
|
||||||
|
// Get current session (returns session object or null)
|
||||||
|
getSession: () => ipcRenderer.invoke('user-get-session'),
|
||||||
|
|
||||||
|
// Logout (optionally clean data)
|
||||||
|
logout: (cleanData = false) => ipcRenderer.invoke('user-logout', { cleanData }),
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
getStats: (userId) => ipcRenderer.invoke('user-get-stats', { userId }),
|
||||||
|
|
||||||
|
// Clean all user data (projects, chats, keys)
|
||||||
|
cleanData: (userId) => ipcRenderer.invoke('user-clean-data', { userId }),
|
||||||
|
|
||||||
|
// Get user's projects directory path
|
||||||
|
getProjectsDir: (userId) => ipcRenderer.invoke('user-get-projects-dir', { userId })
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// QWEN OAUTH (INLINE DEVICE FLOW)
|
||||||
|
// ==========================================
|
||||||
|
qwenAuth: {
|
||||||
|
// Start the device authorization flow (triggers browser open)
|
||||||
|
start: () => ipcRenderer.send('qwen-auth-start'),
|
||||||
|
|
||||||
|
// Cancel ongoing authorization
|
||||||
|
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
|
||||||
|
|
||||||
|
// Get current auth status
|
||||||
|
getStatus: () => ipcRenderer.invoke('qwen-get-auth-status'),
|
||||||
|
|
||||||
|
// Clear saved tokens
|
||||||
|
clearTokens: () => ipcRenderer.invoke('qwen-clear-tokens'),
|
||||||
|
|
||||||
|
// Event listeners for auth flow
|
||||||
|
onProgress: (callback) => ipcRenderer.on('qwen-auth-progress', (_, data) => callback(data)),
|
||||||
|
onSuccess: (callback) => ipcRenderer.on('qwen-auth-success', (_, creds) => callback(creds)),
|
||||||
|
onError: (callback) => ipcRenderer.on('qwen-auth-error', (_, error) => callback(error)),
|
||||||
|
|
||||||
|
// Cleanup listeners
|
||||||
|
removeListeners: () => {
|
||||||
|
ipcRenderer.removeAllListeners('qwen-auth-progress');
|
||||||
|
ipcRenderer.removeAllListeners('qwen-auth-success');
|
||||||
|
ipcRenderer.removeAllListeners('qwen-auth-error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
360
bin/goose-ultra-final/electron/qwen-oauth.js
Normal file
360
bin/goose-ultra-final/electron/qwen-oauth.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Qwen OAuth2 Device Flow for Goose Ultra
|
||||||
|
*
|
||||||
|
* Implements RFC 8628 OAuth 2.0 Device Authorization Grant
|
||||||
|
* with PKCE (Proof Key for Code Exchange)
|
||||||
|
*
|
||||||
|
* Based on: qwen-code/packages/core/src/qwen/qwenOAuth2.ts
|
||||||
|
* License: Apache-2.0 (Qwen)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { shell } from 'electron';
|
||||||
|
|
||||||
|
// ===== OAUTH CONFIGURATION =====
|
||||||
|
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
||||||
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
|
||||||
|
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
||||||
|
|
||||||
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
||||||
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
||||||
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
// ===== PKCE UTILITIES (RFC 7636) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random code verifier for PKCE
|
||||||
|
* @returns A random string of 43-128 characters
|
||||||
|
*/
|
||||||
|
export function generateCodeVerifier() {
|
||||||
|
return crypto.randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a code challenge from a code verifier using SHA-256
|
||||||
|
* @param {string} codeVerifier
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function generateCodeChallenge(codeVerifier) {
|
||||||
|
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PKCE code verifier and challenge pair
|
||||||
|
*/
|
||||||
|
export function generatePKCEPair() {
|
||||||
|
const code_verifier = generateCodeVerifier();
|
||||||
|
const code_challenge = generateCodeChallenge(code_verifier);
|
||||||
|
return { code_verifier, code_challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HELPERS =====
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectToUrlEncoded(data) {
|
||||||
|
return Object.keys(data)
|
||||||
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CANCELLATION =====
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
export function cancelAuth() {
|
||||||
|
isCancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MAIN DEVICE FLOW =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the OAuth Device Authorization Flow
|
||||||
|
*
|
||||||
|
* @param {Function} onProgress - Callback for progress updates
|
||||||
|
* @param {Function} onSuccess - Callback with credentials on success
|
||||||
|
* @param {Function} onError - Callback with error message on failure
|
||||||
|
*/
|
||||||
|
export async function startDeviceFlow(onProgress, onSuccess, onError) {
|
||||||
|
isCancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Generate PKCE pair
|
||||||
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||||
|
console.log('[QwenOAuth] Starting device flow with PKCE...');
|
||||||
|
|
||||||
|
// 2. Request device code
|
||||||
|
const deviceAuthBody = objectToUrlEncoded({
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
scope: QWEN_OAUTH_SCOPE,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'x-request-id': crypto.randomUUID()
|
||||||
|
},
|
||||||
|
body: deviceAuthBody
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deviceAuthResponse.ok) {
|
||||||
|
const errorText = await deviceAuthResponse.text();
|
||||||
|
throw new Error(`Device authorization failed: ${deviceAuthResponse.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceAuth = await deviceAuthResponse.json();
|
||||||
|
console.log('[QwenOAuth] Device auth response:', {
|
||||||
|
user_code: deviceAuth.user_code,
|
||||||
|
expires_in: deviceAuth.expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) {
|
||||||
|
throw new Error('Invalid device authorization response');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Notify UI and open browser
|
||||||
|
onProgress({
|
||||||
|
status: 'awaiting_auth',
|
||||||
|
url: deviceAuth.verification_uri_complete,
|
||||||
|
userCode: deviceAuth.user_code,
|
||||||
|
expiresIn: deviceAuth.expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-open browser
|
||||||
|
try {
|
||||||
|
await shell.openExternal(deviceAuth.verification_uri_complete);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[QwenOAuth] Failed to open browser:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Poll for token
|
||||||
|
let pollInterval = 2000; // 2 seconds
|
||||||
|
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
if (isCancelled) {
|
||||||
|
onError('Authentication cancelled by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(pollInterval);
|
||||||
|
|
||||||
|
const tokenBody = objectToUrlEncoded({
|
||||||
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
device_code: deviceAuth.device_code,
|
||||||
|
code_verifier
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: tokenBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
// Success case
|
||||||
|
if (tokenData.access_token) {
|
||||||
|
console.log('[QwenOAuth] Token obtained successfully!');
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
refresh_token: tokenData.refresh_token || null,
|
||||||
|
token_type: tokenData.token_type || 'Bearer',
|
||||||
|
resource_url: tokenData.resource_url || null,
|
||||||
|
expiry_date: tokenData.expires_in
|
||||||
|
? Date.now() + (tokenData.expires_in * 1000)
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
|
||||||
|
onSuccess(credentials);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending case (user hasn't authorized yet)
|
||||||
|
if (tokenData.error === 'authorization_pending') {
|
||||||
|
onProgress({
|
||||||
|
status: 'polling',
|
||||||
|
attempt: attempt + 1,
|
||||||
|
maxAttempts
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow down case
|
||||||
|
if (tokenData.error === 'slow_down') {
|
||||||
|
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
||||||
|
console.log('[QwenOAuth] Server requested slow_down, interval now:', pollInterval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access denied
|
||||||
|
if (tokenData.error === 'access_denied') {
|
||||||
|
onError('Access denied. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other error
|
||||||
|
if (tokenData.error) {
|
||||||
|
onError(tokenData.error_description || tokenData.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (pollError) {
|
||||||
|
console.error('[QwenOAuth] Poll error:', pollError.message);
|
||||||
|
// Continue polling on network errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
onError('Authorization timed out. Please try again.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[QwenOAuth] Device flow failed:', error);
|
||||||
|
onError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh an access token using a refresh token
|
||||||
|
*
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @returns {Promise<Object>} New credentials
|
||||||
|
*/
|
||||||
|
export async function refreshAccessToken(refreshToken) {
|
||||||
|
const body = objectToUrlEncoded({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error_description || data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: data.refresh_token || refreshToken,
|
||||||
|
token_type: data.token_type || 'Bearer',
|
||||||
|
resource_url: data.resource_url || null,
|
||||||
|
expiry_date: data.expires_in ? Date.now() + (data.expires_in * 1000) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TOKEN PERSISTENCE (User-Isolated) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the token storage path for a specific user
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} userDataPath - app.getPath('userData')
|
||||||
|
*/
|
||||||
|
export function getUserTokenPath(userId, userDataPath) {
|
||||||
|
return path.join(userDataPath, 'user_data', userId, 'qwen_tokens.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tokens for a specific user
|
||||||
|
*/
|
||||||
|
export async function saveUserTokens(userId, userDataPath, credentials) {
|
||||||
|
const tokenPath = getUserTokenPath(userId, userDataPath);
|
||||||
|
const dir = path.dirname(tokenPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(tokenPath, JSON.stringify(credentials, null, 2));
|
||||||
|
console.log('[QwenOAuth] Tokens saved for user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tokens for a specific user
|
||||||
|
*/
|
||||||
|
export function loadUserTokens(userId, userDataPath) {
|
||||||
|
const tokenPath = getUserTokenPath(userId, userDataPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[QwenOAuth] Failed to load user tokens:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear tokens for a specific user
|
||||||
|
*/
|
||||||
|
export function clearUserTokens(userId, userDataPath) {
|
||||||
|
const tokenPath = getUserTokenPath(userId, userDataPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
fs.unlinkSync(tokenPath);
|
||||||
|
console.log('[QwenOAuth] Tokens cleared for user:', userId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[QwenOAuth] Failed to clear tokens:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LEGACY SUPPORT (Global tokens for backward compatibility) =====
|
||||||
|
|
||||||
|
const LEGACY_TOKEN_PATH = path.join(os.homedir(), '.qwen', 'oauth_creds.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tokens from legacy location (used when no user session)
|
||||||
|
*/
|
||||||
|
export function loadLegacyTokens() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(LEGACY_TOKEN_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[QwenOAuth] Failed to load legacy tokens:', e.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tokens to legacy location (for backward compatibility)
|
||||||
|
*/
|
||||||
|
export function saveLegacyTokens(credentials) {
|
||||||
|
const dir = path.dirname(LEGACY_TOKEN_PATH);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(LEGACY_TOKEN_PATH, JSON.stringify(credentials, null, 2));
|
||||||
|
}
|
||||||
474
bin/goose-ultra-final/electron/user-data.js
Normal file
474
bin/goose-ultra-final/electron/user-data.js
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* User Data Service for Goose Ultra
|
||||||
|
*
|
||||||
|
* Manages user authentication, session, and data isolation.
|
||||||
|
* Each user has their own isolated environment with separate:
|
||||||
|
* - Projects
|
||||||
|
* - Chat history
|
||||||
|
* - API keys (Qwen, Ollama)
|
||||||
|
* - Custom personas
|
||||||
|
* - Settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
|
// ===== USER DATA STRUCTURE =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GooseUser
|
||||||
|
* @property {string} userId - UUID
|
||||||
|
* @property {string} displayName - User's chosen display name
|
||||||
|
* @property {string} secretCodeHash - SHA256 hash of the secret code
|
||||||
|
* @property {string} secretQuestionId - ID of the secret question used
|
||||||
|
* @property {number} createdAt - Timestamp
|
||||||
|
* @property {number} lastLoginAt - Timestamp
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UserSession
|
||||||
|
* @property {string} userId
|
||||||
|
* @property {string} displayName
|
||||||
|
* @property {number} loginAt
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== FILE PATHS =====
|
||||||
|
|
||||||
|
const getSystemDir = () => path.join(app.getPath('userData'), 'system');
|
||||||
|
const getUsersFile = () => path.join(getSystemDir(), 'users.json');
|
||||||
|
const getSessionFile = () => path.join(getSystemDir(), 'current_session.json');
|
||||||
|
const getUserDataDir = () => path.join(app.getPath('userData'), 'user_data');
|
||||||
|
|
||||||
|
// ===== SECRET QUESTIONS =====
|
||||||
|
|
||||||
|
export const SECRET_QUESTIONS = [
|
||||||
|
{ id: 'mother_maiden', question: "What is your mother's maiden name?" },
|
||||||
|
{ id: 'first_pet', question: "What was your first pet's name?" },
|
||||||
|
{ id: 'favorite_teacher', question: "What was your favorite teacher's name?" },
|
||||||
|
{ id: 'birth_city', question: "In what city were you born?" },
|
||||||
|
{ id: 'first_car', question: "What was the make of your first car?" },
|
||||||
|
{ id: 'childhood_nickname', question: "What was your childhood nickname?" },
|
||||||
|
{ id: 'custom', question: "Custom question (user-defined)" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== SECRET CODE GENERATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique secret code for a new user
|
||||||
|
* Format: GU-XXXX-XXXX-XXXX (16 alphanumeric chars)
|
||||||
|
*
|
||||||
|
* @param {string} displayName
|
||||||
|
* @param {string} questionId
|
||||||
|
* @param {string} answer
|
||||||
|
* @returns {string} The secret code
|
||||||
|
*/
|
||||||
|
export function generateSecretCode(displayName, questionId, answer) {
|
||||||
|
const timestamp = Date.now().toString();
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const raw = `${displayName}|${questionId}|${answer}|${timestamp}|${salt}`;
|
||||||
|
|
||||||
|
// Create a hash and take 12 bytes
|
||||||
|
const hash = crypto.createHash('sha256').update(raw).digest();
|
||||||
|
const encoded = hash.slice(0, 12).toString('base64url').toUpperCase();
|
||||||
|
|
||||||
|
// Format as GU-XXXX-XXXX-XXXX
|
||||||
|
const formatted = `GU-${encoded.slice(0, 4)}-${encoded.slice(4, 8)}-${encoded.slice(8, 12)}`;
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a secret code for secure storage
|
||||||
|
* @param {string} secretCode
|
||||||
|
* @returns {string} SHA256 hash
|
||||||
|
*/
|
||||||
|
export function hashSecretCode(secretCode) {
|
||||||
|
// Normalize the code (remove dashes, uppercase)
|
||||||
|
const normalized = secretCode.replace(/-/g, '').toUpperCase();
|
||||||
|
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== USER MANAGEMENT =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure system directories exist
|
||||||
|
*/
|
||||||
|
function ensureSystemDirs() {
|
||||||
|
const systemDir = getSystemDir();
|
||||||
|
const userDataDir = getUserDataDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(systemDir)) {
|
||||||
|
fs.mkdirSync(systemDir, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(userDataDir)) {
|
||||||
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all registered users
|
||||||
|
* @returns {GooseUser[]}
|
||||||
|
*/
|
||||||
|
export function loadUsers() {
|
||||||
|
ensureSystemDirs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getUsersFile())) {
|
||||||
|
return JSON.parse(fs.readFileSync(getUsersFile(), 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to load users:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save users list
|
||||||
|
* @param {GooseUser[]} users
|
||||||
|
*/
|
||||||
|
function saveUsers(users) {
|
||||||
|
ensureSystemDirs();
|
||||||
|
fs.writeFileSync(getUsersFile(), JSON.stringify(users, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user account
|
||||||
|
*
|
||||||
|
* @param {string} displayName
|
||||||
|
* @param {string} questionId
|
||||||
|
* @param {string} answer
|
||||||
|
* @returns {{ user: GooseUser, secretCode: string }}
|
||||||
|
*/
|
||||||
|
export function createUser(displayName, questionId, answer) {
|
||||||
|
ensureSystemDirs();
|
||||||
|
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const secretCode = generateSecretCode(displayName, questionId, answer);
|
||||||
|
const secretCodeHash = hashSecretCode(secretCode);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
secretCodeHash,
|
||||||
|
secretQuestionId: questionId,
|
||||||
|
createdAt: now,
|
||||||
|
lastLoginAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to users list
|
||||||
|
const users = loadUsers();
|
||||||
|
users.push(user);
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
// Create user's data directory
|
||||||
|
const userDir = path.join(getUserDataDir(), userId);
|
||||||
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create subdirectories
|
||||||
|
fs.mkdirSync(path.join(userDir, 'projects'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(userDir, 'chats'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(userDir, 'vault'), { recursive: true });
|
||||||
|
|
||||||
|
// Initialize settings
|
||||||
|
const defaultSettings = {
|
||||||
|
preferredFramework: null,
|
||||||
|
chatPersona: 'assistant',
|
||||||
|
theme: 'dark',
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(userDir, 'settings.json'),
|
||||||
|
JSON.stringify(defaultSettings, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[UserData] Created new user:', userId, displayName);
|
||||||
|
|
||||||
|
return { user, secretCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a user with their secret code
|
||||||
|
*
|
||||||
|
* @param {string} secretCode
|
||||||
|
* @returns {GooseUser | null}
|
||||||
|
*/
|
||||||
|
export function authenticateUser(secretCode) {
|
||||||
|
const hash = hashSecretCode(secretCode);
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
const user = users.find(u => u.secretCodeHash === hash);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update last login
|
||||||
|
user.lastLoginAt = Date.now();
|
||||||
|
saveUsers(users);
|
||||||
|
console.log('[UserData] User authenticated:', user.userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserData] Authentication failed: invalid secret code');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SESSION MANAGEMENT =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a user session
|
||||||
|
* @param {GooseUser} user
|
||||||
|
*/
|
||||||
|
export function startSession(user) {
|
||||||
|
const session = {
|
||||||
|
userId: user.userId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
loginAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureSystemDirs();
|
||||||
|
fs.writeFileSync(getSessionFile(), JSON.stringify(session, null, 2));
|
||||||
|
console.log('[UserData] Session started for:', user.displayName);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current active session
|
||||||
|
* @returns {UserSession | null}
|
||||||
|
*/
|
||||||
|
export function getCurrentSession() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getSessionFile())) {
|
||||||
|
return JSON.parse(fs.readFileSync(getSessionFile(), 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to load session:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the current session (logout)
|
||||||
|
*/
|
||||||
|
export function endSession() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getSessionFile())) {
|
||||||
|
fs.unlinkSync(getSessionFile());
|
||||||
|
console.log('[UserData] Session ended');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to end session:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== USER DATA PATHS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data directory for a specific user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserDirectory(userId) {
|
||||||
|
return path.join(getUserDataDir(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the projects directory for a user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserProjectsDir(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the chats directory for a user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserChatsDir(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'chats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the vault directory for a user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserVaultDir(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'vault');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user settings path
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserSettingsPath(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DATA CLEANUP =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean all data for a specific user
|
||||||
|
* This removes:
|
||||||
|
* - All projects
|
||||||
|
* - All chats
|
||||||
|
* - All saved credentials
|
||||||
|
* - Custom personas
|
||||||
|
* - Settings
|
||||||
|
*
|
||||||
|
* Note: The user account itself remains intact
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function cleanUserData(userId) {
|
||||||
|
const userDir = getUserDirectory(userId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDir)) {
|
||||||
|
console.log('[UserData] No data to clean for user:', userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all contents but keep the directory structure
|
||||||
|
const removeContents = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const items = fs.readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dir, item);
|
||||||
|
const stat = fs.statSync(itemPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.rmSync(itemPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(itemPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean each subdirectory
|
||||||
|
removeContents(path.join(userDir, 'projects'));
|
||||||
|
removeContents(path.join(userDir, 'chats'));
|
||||||
|
removeContents(path.join(userDir, 'vault'));
|
||||||
|
|
||||||
|
// Reset settings to default
|
||||||
|
const defaultSettings = {
|
||||||
|
preferredFramework: null,
|
||||||
|
chatPersona: 'assistant',
|
||||||
|
theme: 'dark',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cleanedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(userDir, 'settings.json'),
|
||||||
|
JSON.stringify(defaultSettings, null, 2)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to reset settings:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Qwen tokens
|
||||||
|
const tokenPath = path.join(userDir, 'qwen_tokens.json');
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
fs.unlinkSync(tokenPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserData] Cleaned all data for user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user account completely
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function deleteUser(userId) {
|
||||||
|
// Remove user data
|
||||||
|
const userDir = getUserDirectory(userId);
|
||||||
|
if (fs.existsSync(userDir)) {
|
||||||
|
fs.rmSync(userDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from users list
|
||||||
|
let users = loadUsers();
|
||||||
|
users = users.filter(u => u.userId !== userId);
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
console.log('[UserData] Deleted user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MIGRATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy global data to a user's isolated environment
|
||||||
|
* This is called when:
|
||||||
|
* 1. First user is created and old data exists
|
||||||
|
* 2. Explicitly requested by user
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} legacyProjectsDir - Old global projects directory
|
||||||
|
*/
|
||||||
|
export function migrateGlobalDataToUser(userId, legacyProjectsDir) {
|
||||||
|
const userProjectsDir = getUserProjectsDir(userId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(legacyProjectsDir)) {
|
||||||
|
console.log('[UserData] No legacy data to migrate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all projects
|
||||||
|
const projects = fs.readdirSync(legacyProjectsDir);
|
||||||
|
for (const project of projects) {
|
||||||
|
const src = path.join(legacyProjectsDir, project);
|
||||||
|
const dest = path.join(userProjectsDir, project);
|
||||||
|
|
||||||
|
if (fs.statSync(src).isDirectory()) {
|
||||||
|
fs.cpSync(src, dest, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserData] Migrated', projects.length, 'projects to user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTICS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about a user's data
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserStats(userId) {
|
||||||
|
const userDir = getUserDirectory(userId);
|
||||||
|
|
||||||
|
const countItems = (dir) => {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDirSize = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return 0;
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dir, item.name);
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
size += getDirSize(itemPath);
|
||||||
|
} else {
|
||||||
|
size += fs.statSync(itemPath).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectCount: countItems(getUserProjectsDir(userId)),
|
||||||
|
chatCount: countItems(getUserChatsDir(userId)),
|
||||||
|
totalSizeBytes: getDirSize(userDir),
|
||||||
|
hasQwenTokens: fs.existsSync(path.join(userDir, 'qwen_tokens.json'))
|
||||||
|
};
|
||||||
|
}
|
||||||
511
bin/goose-ultra-final/implementation_plan_user_auth.md
Normal file
511
bin/goose-ultra-final/implementation_plan_user_auth.md
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
# Implementation Plan: Secret Key User System & Inline Qwen OAuth
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan outlines the implementation of:
|
||||||
|
1. **Secret Key User Authentication** - Users create accounts with a name + secret question, receive a unique key
|
||||||
|
2. **Isolated User Environments** - Each user has separate data (API keys, chats, sessions, projects)
|
||||||
|
3. **Inline Qwen OAuth** - Replace external CLI dependency with native device flow authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: User Identity & Secret Key System
|
||||||
|
|
||||||
|
### 1.1 Secret Code Generation
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
```
|
||||||
|
SecretCode = Base64(SHA256(userName + secretQuestion + answer + timestamp + randomSalt))[:24]
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output: `GU-AXBY12-CDWZ34-EFGH56`
|
||||||
|
|
||||||
|
**Security Properties:**
|
||||||
|
- One-way derivation (cannot reverse-engineer original answer)
|
||||||
|
- Time-salted to prevent duplicate codes
|
||||||
|
- 24-character code is memorable yet secure (144 bits of entropy)
|
||||||
|
|
||||||
|
### 1.2 User Data Model
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GooseUser {
|
||||||
|
userId: string; // UUID
|
||||||
|
displayName: string;
|
||||||
|
secretCodeHash: string; // SHA256 hash of the secret code (for verification)
|
||||||
|
createdAt: number;
|
||||||
|
lastLoginAt: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Files & Storage Structure
|
||||||
|
|
||||||
|
**Location:** `%AppData%/GooseUltra/` (Windows) or `~/.config/GooseUltra/` (Linux/Mac)
|
||||||
|
|
||||||
|
```
|
||||||
|
GooseUltra/
|
||||||
|
├── system/
|
||||||
|
│ ├── users.json # Array of GooseUser (stores hashes, not codes)
|
||||||
|
│ └── current_session.json # { userId, loginAt }
|
||||||
|
└── user_data/
|
||||||
|
└── {userId}/
|
||||||
|
├── settings.json # User-specific settings
|
||||||
|
├── qwen_tokens.json # User's Qwen OAuth credentials
|
||||||
|
├── ollama_key.enc # User's Ollama API key
|
||||||
|
├── projects/ # User's projects
|
||||||
|
├── chats/ # User's chat history
|
||||||
|
└── vault/ # User's credential vault
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 New Components
|
||||||
|
|
||||||
|
| Component | Location | Purpose |
|
||||||
|
|-----------|----------|---------|
|
||||||
|
| `LoginGate.tsx` | `src/components/` | Full-screen intro/login component |
|
||||||
|
| `UserOnboarding.tsx` | `src/components/` | Name + secret question wizard |
|
||||||
|
| `SecretCodeReveal.tsx` | `src/components/` | Shows code once with copy button |
|
||||||
|
| `UserContext.tsx` | `src/` | React context for current user |
|
||||||
|
|
||||||
|
### 1.5 Onboarding Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Welcome to Goose Ultra │
|
||||||
|
│ │
|
||||||
|
│ ○ I'm new here (Create Account) │
|
||||||
|
│ ○ I have a secret code (Login) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓ "New User"
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Step 1: What's your name? │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ [Your Display Name ] │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Step 2: Set Your Secret Question │
|
||||||
|
│ │
|
||||||
|
│ Pick a question (dropdown): │
|
||||||
|
│ • Mother's maiden name? │
|
||||||
|
│ • First pet's name? │
|
||||||
|
│ • Favorite teacher's name? │
|
||||||
|
│ • City you were born in? │
|
||||||
|
│ • Your custom question... │
|
||||||
|
│ │
|
||||||
|
│ Your answer: [______________] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 🎉 Your Secret Code is Ready! │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────┐ │
|
||||||
|
│ │ GU-AXBY12-CDWZ34-EFGH56 │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ [📋 Copy to Clipboard] │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ SAVE THIS CODE OFFLINE! │
|
||||||
|
│ This is the ONLY way to log in. │
|
||||||
|
│ We cannot recover it. │
|
||||||
|
│ │
|
||||||
|
│ [ ] I have saved my code securely │
|
||||||
|
│ │
|
||||||
|
│ [Continue to Goose Ultra →] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Data Isolation
|
||||||
|
|
||||||
|
### 2.1 Data Isolation Layer
|
||||||
|
|
||||||
|
**New Service:** `src/services/userDataService.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class UserDataService {
|
||||||
|
private userId: string | null = null;
|
||||||
|
|
||||||
|
setCurrentUser(userId: string) { ... }
|
||||||
|
|
||||||
|
getUserDataPath(): string {
|
||||||
|
// Returns: userData/user_data/{userId}/
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUserSettings(): Promise<UserSettings> { ... }
|
||||||
|
async saveUserSettings(settings: UserSettings): Promise<void> { ... }
|
||||||
|
|
||||||
|
async loadQwenTokens(): Promise<QwenCredentials | null> { ... }
|
||||||
|
async saveQwenTokens(tokens: QwenCredentials): Promise<void> { ... }
|
||||||
|
|
||||||
|
async getProjectsPath(): string { ... }
|
||||||
|
async getChatsPath(): string { ... }
|
||||||
|
|
||||||
|
async cleanUserData(): Promise<void> {
|
||||||
|
// Wipes all user data (projects, chats, keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Logout & Clean Data
|
||||||
|
|
||||||
|
**Logout Flow:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Logging Out... │
|
||||||
|
│ │
|
||||||
|
│ Would you like to clean your data? │
|
||||||
|
│ │
|
||||||
|
│ This will permanently delete: │
|
||||||
|
│ • All your projects │
|
||||||
|
│ • All chat history │
|
||||||
|
│ • Saved API keys │
|
||||||
|
│ • Custom personas │
|
||||||
|
│ │
|
||||||
|
│ Your account will remain intact. │
|
||||||
|
│ You can log in again with your code. │
|
||||||
|
│ │
|
||||||
|
│ [Keep Data & Logout] [Clean & Logout] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Clean Data" Explanation (to show users):**
|
||||||
|
|
||||||
|
> **What does "Clean Data" mean?**
|
||||||
|
>
|
||||||
|
> Cleaning your data removes all personal information from this device, including:
|
||||||
|
> - **Projects:** All HTML, CSS, and JavaScript you've created
|
||||||
|
> - **Chat History:** All conversations with the AI
|
||||||
|
> - **API Keys:** Any Qwen or Ollama credentials you've entered
|
||||||
|
> - **Personas:** Custom AI personalities you've configured
|
||||||
|
>
|
||||||
|
> **Why clean?**
|
||||||
|
> - You're using a shared or public computer
|
||||||
|
> - You want to free up disk space
|
||||||
|
> - You're troubleshooting issues
|
||||||
|
> - You want a fresh start
|
||||||
|
>
|
||||||
|
> **Note:** Your account code will still work. Cleaning only affects data on THIS device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Inline Qwen OAuth (No External CLI)
|
||||||
|
|
||||||
|
### 3.1 Current vs. New Architecture
|
||||||
|
|
||||||
|
**Current Flow (Requires External CLI):**
|
||||||
|
```
|
||||||
|
User clicks "Auth" → Electron opens external Qwen CLI → CLI does OAuth → Writes ~/.qwen/oauth_creds.json → Goose reads it
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Flow (Fully Inline):**
|
||||||
|
```
|
||||||
|
User clicks "Auth" → Electron starts Device Flow → Opens browser for authorization → Polls for token → Saves per-user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 New Electron Module: `qwen-oauth.js`
|
||||||
|
|
||||||
|
**Based on:** `qwen-code-reference/packages/core/src/qwen/qwenOAuth2.ts`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// electron/qwen-oauth.js
|
||||||
|
|
||||||
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/device/code';
|
||||||
|
const QWEN_OAUTH_TOKEN_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/token';
|
||||||
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
||||||
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
||||||
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
// PKCE Helpers
|
||||||
|
function generateCodeVerifier() { ... }
|
||||||
|
function generateCodeChallenge(verifier) { ... }
|
||||||
|
|
||||||
|
// Main OAuth Flow
|
||||||
|
export async function startDeviceFlow(onProgress, onSuccess, onError) {
|
||||||
|
// 1. Generate PKCE pair
|
||||||
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||||
|
|
||||||
|
// 2. Request device code from Qwen
|
||||||
|
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
scope: QWEN_OAUTH_SCOPE,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const { device_code, user_code, verification_uri_complete, expires_in } = await deviceAuthResponse.json();
|
||||||
|
|
||||||
|
// 3. Notify UI with authorization URL
|
||||||
|
onProgress({
|
||||||
|
status: 'awaiting_auth',
|
||||||
|
url: verification_uri_complete,
|
||||||
|
userCode: user_code,
|
||||||
|
expiresIn: expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Open browser automatically
|
||||||
|
shell.openExternal(verification_uri_complete);
|
||||||
|
|
||||||
|
// 5. Poll for token
|
||||||
|
const pollInterval = 2000;
|
||||||
|
const maxAttempts = Math.ceil(expires_in / (pollInterval / 1000));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
await sleep(pollInterval);
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
device_code,
|
||||||
|
code_verifier
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (tokenData.access_token) {
|
||||||
|
// SUCCESS!
|
||||||
|
const credentials = {
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
refresh_token: tokenData.refresh_token,
|
||||||
|
token_type: tokenData.token_type,
|
||||||
|
resource_url: tokenData.resource_url,
|
||||||
|
expiry_date: Date.now() + (tokenData.expires_in * 1000)
|
||||||
|
};
|
||||||
|
onSuccess(credentials);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.error === 'authorization_pending') {
|
||||||
|
onProgress({ status: 'polling', attempt, maxAttempts });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.error === 'slow_down') {
|
||||||
|
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other error
|
||||||
|
onError(tokenData.error_description || tokenData.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError('Authorization timed out');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(refreshToken) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 IPC Bridge Updates
|
||||||
|
|
||||||
|
**New handlers in `main.js`:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import * as qwenOAuth from './qwen-oauth.js';
|
||||||
|
|
||||||
|
// Start Device Authorization Flow
|
||||||
|
ipcMain.on('qwen-auth-start', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
|
||||||
|
await qwenOAuth.startDeviceFlow(
|
||||||
|
(progress) => window.webContents.send('qwen-auth-progress', progress),
|
||||||
|
(credentials) => {
|
||||||
|
// Save to user-specific location
|
||||||
|
const userId = getCurrentUserId(); // From session
|
||||||
|
userDataService.saveQwenTokens(userId, credentials);
|
||||||
|
window.webContents.send('qwen-auth-success', credentials);
|
||||||
|
},
|
||||||
|
(error) => window.webContents.send('qwen-auth-error', error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel ongoing auth
|
||||||
|
ipcMain.on('qwen-auth-cancel', () => {
|
||||||
|
qwenOAuth.cancelAuth();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Preload Updates
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// preload.js - add to existing
|
||||||
|
|
||||||
|
qwenAuth: {
|
||||||
|
start: () => ipcRenderer.send('qwen-auth-start'),
|
||||||
|
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
|
||||||
|
onProgress: (cb) => ipcRenderer.on('qwen-auth-progress', (_, data) => cb(data)),
|
||||||
|
onSuccess: (cb) => ipcRenderer.on('qwen-auth-success', (_, creds) => cb(creds)),
|
||||||
|
onError: (cb) => ipcRenderer.on('qwen-auth-error', (_, err) => cb(err)),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 UI Component: Inline Auth Dialog
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/QwenAuthDialog.tsx
|
||||||
|
|
||||||
|
export const QwenAuthDialog = ({ onComplete }: { onComplete: () => void }) => {
|
||||||
|
const [status, setStatus] = useState<'idle' | 'awaiting' | 'polling' | 'success' | 'error'>('idle');
|
||||||
|
const [authUrl, setAuthUrl] = useState('');
|
||||||
|
const [userCode, setUserCode] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.electron?.qwenAuth) return;
|
||||||
|
|
||||||
|
window.electron.qwenAuth.onProgress((data) => {
|
||||||
|
if (data.status === 'awaiting_auth') {
|
||||||
|
setStatus('awaiting');
|
||||||
|
setAuthUrl(data.url);
|
||||||
|
setUserCode(data.userCode);
|
||||||
|
} else if (data.status === 'polling') {
|
||||||
|
setStatus('polling');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron.qwenAuth.onSuccess(() => {
|
||||||
|
setStatus('success');
|
||||||
|
setTimeout(onComplete, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron.qwenAuth.onError((err) => {
|
||||||
|
setStatus('error');
|
||||||
|
setError(err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startAuth = () => {
|
||||||
|
setStatus('awaiting');
|
||||||
|
window.electron?.qwenAuth?.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-8 max-w-md w-full border border-white/10">
|
||||||
|
{status === 'idle' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Connect to Qwen</h2>
|
||||||
|
<p className="text-zinc-400 mb-6">
|
||||||
|
Authenticate with your Qwen account to access AI models.
|
||||||
|
</p>
|
||||||
|
<button onClick={startAuth} className="w-full py-3 bg-primary text-black font-bold rounded-xl">
|
||||||
|
Sign in with Qwen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'awaiting' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Complete in Browser</h2>
|
||||||
|
<p className="text-zinc-400 mb-4">
|
||||||
|
A browser window should have opened. Enter this code:
|
||||||
|
</p>
|
||||||
|
<div className="bg-black p-4 rounded-xl text-center mb-4">
|
||||||
|
<span className="font-mono text-3xl text-primary">{userCode}</span>
|
||||||
|
</div>
|
||||||
|
<a href={authUrl} target="_blank" className="text-primary underline text-sm">
|
||||||
|
Click here if browser didn't open
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'polling' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Waiting for Authorization...</h2>
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold text-primary mb-4">✓ Connected!</h2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold text-red-500 mb-4">Authentication Failed</h2>
|
||||||
|
<p className="text-zinc-400 mb-6">{error}</p>
|
||||||
|
<button onClick={startAuth} className="w-full py-3 bg-zinc-800 text-white font-bold rounded-xl">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Implementation Order
|
||||||
|
|
||||||
|
### Step 1: Foundation (Electron Main)
|
||||||
|
1. Create `userDataService.js` in `electron/`
|
||||||
|
2. Create `qwen-oauth.js` in `electron/`
|
||||||
|
3. Update `main.js` with new IPC handlers
|
||||||
|
4. Update `preload.js` with new bridges
|
||||||
|
|
||||||
|
### Step 2: User System (React)
|
||||||
|
1. Create `UserContext.tsx`
|
||||||
|
2. Create `LoginGate.tsx`
|
||||||
|
3. Create `UserOnboarding.tsx`
|
||||||
|
4. Create `SecretCodeReveal.tsx`
|
||||||
|
5. Wrap `App.tsx` with `LoginGate`
|
||||||
|
|
||||||
|
### Step 3: Data Migration
|
||||||
|
1. Migrate existing global data to first user
|
||||||
|
2. Update all file paths in services to use `userDataService`
|
||||||
|
|
||||||
|
### Step 4: Qwen OAuth UI
|
||||||
|
1. Create `QwenAuthDialog.tsx`
|
||||||
|
2. Update `AISettingsModal` to use inline auth
|
||||||
|
3. Remove references to external CLI
|
||||||
|
|
||||||
|
### Step 5: Logout & Cleanup
|
||||||
|
1. Add logout button to sidebar
|
||||||
|
2. Create cleanup dialog with explanation
|
||||||
|
3. Implement `cleanUserData()` function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `electron/main.js` | Add user session management, new IPC handlers |
|
||||||
|
| `electron/preload.js` | Expose user and auth bridges |
|
||||||
|
| `electron/qwen-api.js` | Load tokens from user-specific path |
|
||||||
|
| `src/App.tsx` | Wrap with LoginGate and UserContext |
|
||||||
|
| `src/orchestrator.ts` | Make project loading user-aware |
|
||||||
|
| `src/services/automationService.ts` | Update file paths |
|
||||||
|
| `src/components/LayoutComponents.tsx` | Add logout button, update auth UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Secret Code Storage**: Only SHA256 hash is stored; actual code never persisted
|
||||||
|
2. **Credential Isolation**: Each user's Qwen/Ollama tokens are in separate directories
|
||||||
|
3. **Clean Data**: Complete wipe of user-specific folder
|
||||||
|
4. **No Recovery**: By design, secret codes cannot be recovered (offline storage is essential)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Effort |
|
||||||
|
|-------|--------|
|
||||||
|
| Phase 1: User Identity | 4-6 hours |
|
||||||
|
| Phase 2: Data Isolation | 3-4 hours |
|
||||||
|
| Phase 3: Inline OAuth | 4-5 hours |
|
||||||
|
| Phase 4: Integration | 2-3 hours |
|
||||||
|
| **Total** | **13-18 hours** |
|
||||||
@@ -5,6 +5,7 @@ import { TabNav, StartView, PlanView, PreviewView, EditorView, DiscoverView, Com
|
|||||||
import { ViControlView } from './components/ViControlView';
|
import { ViControlView } from './components/ViControlView';
|
||||||
import { TabId, OrchestratorState, GlobalMode } from './types';
|
import { TabId, OrchestratorState, GlobalMode } from './types';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
import { LoginGate } from './components/UserAuth';
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const { state } = useOrchestrator();
|
const { state } = useOrchestrator();
|
||||||
@@ -66,10 +67,13 @@ const MainLayout = () => {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<OrchestratorProvider>
|
<LoginGate>
|
||||||
<ErrorBoundary>
|
<OrchestratorProvider>
|
||||||
<MainLayout />
|
<ErrorBoundary>
|
||||||
</ErrorBoundary>
|
<MainLayout />
|
||||||
</OrchestratorProvider>
|
</ErrorBoundary>
|
||||||
|
</OrchestratorProvider>
|
||||||
|
</LoginGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Icons } from '../constants';
|
|||||||
import { GlobalMode, OrchestratorState, TabId } from '../types';
|
import { GlobalMode, OrchestratorState, TabId } from '../types';
|
||||||
import { applyPlanToExistingHtml, deleteProjectFromDisk, generateMockFiles, compilePlanToCode, MODERN_TEMPLATE_PROMPT, FRAMEWORK_TEMPLATE_PROMPT, loadProjectFilesFromDisk, writeLastActiveProjectId, ensureProjectOnDisk, loadProjectMemories, deleteMemory, updateMemory, MemoryRecord, formatMemoriesForPrompt, retrieveRelevantMemories } from '../services/automationService';
|
import { applyPlanToExistingHtml, deleteProjectFromDisk, generateMockFiles, compilePlanToCode, MODERN_TEMPLATE_PROMPT, FRAMEWORK_TEMPLATE_PROMPT, loadProjectFilesFromDisk, writeLastActiveProjectId, ensureProjectOnDisk, loadProjectMemories, deleteMemory, updateMemory, MemoryRecord, formatMemoriesForPrompt, retrieveRelevantMemories } from '../services/automationService';
|
||||||
import { classifyIntent, enhancePromptWithContext, loadProjectManifest, initializeProjectContext, IntentAnalysis, ProjectManifest, CLIE_VERSION } from '../services/ContextEngine';
|
import { classifyIntent, enhancePromptWithContext, loadProjectManifest, initializeProjectContext, IntentAnalysis, ProjectManifest, CLIE_VERSION } from '../services/ContextEngine';
|
||||||
|
import { useUser, LogoutDialog } from './UserAuth';
|
||||||
|
import QwenAuthDialog from './QwenAuthDialog';
|
||||||
|
|
||||||
// --- Shared Constants ---
|
// --- Shared Constants ---
|
||||||
const FRAMEWORK_KEYWORDS = ['react', 'vue', 'svelte', 'bootstrap', 'jquery', 'three.js', 'p5.js', 'angular', 'alpine'];
|
const FRAMEWORK_KEYWORDS = ['react', 'vue', 'svelte', 'bootstrap', 'jquery', 'three.js', 'p5.js', 'angular', 'alpine'];
|
||||||
@@ -463,10 +465,44 @@ const StatusDot = ({ active, label }: { active: boolean, label: string }) => (
|
|||||||
|
|
||||||
export const Sidebar = () => {
|
export const Sidebar = () => {
|
||||||
const { state, dispatch } = useOrchestrator();
|
const { state, dispatch } = useOrchestrator();
|
||||||
|
const { session, logout } = useUser();
|
||||||
if (!state.sidebarOpen) return null;
|
if (!state.sidebarOpen) return null;
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState('');
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||||
|
const [showQwenAuthDialog, setShowQwenAuthDialog] = useState(false);
|
||||||
|
const [qwenAuthStatus, setQwenAuthStatus] = useState<{ isAuthenticated: boolean; isValid: boolean }>({ isAuthenticated: false, isValid: false });
|
||||||
|
const [userStats, setUserStats] = useState({ projectCount: 0, chatCount: 0, totalSizeBytes: 0 });
|
||||||
|
|
||||||
|
// Check Qwen auth status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkQwenAuth = async () => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (electron?.qwenAuth?.getStatus) {
|
||||||
|
const status = await electron.qwenAuth.getStatus();
|
||||||
|
setQwenAuthStatus(status);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkQwenAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load user stats when logout dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (showLogoutDialog && session?.userId) {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (electron?.user?.getStats) {
|
||||||
|
electron.user.getStats(session.userId).then(setUserStats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showLogoutDialog, session?.userId]);
|
||||||
|
|
||||||
|
const handleLogout = async (cleanData: boolean) => {
|
||||||
|
setShowLogoutDialog(false);
|
||||||
|
await logout(cleanData);
|
||||||
|
// Force page reload to show login screen
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
const handleSelectProject = async (projectId: string) => {
|
const handleSelectProject = async (projectId: string) => {
|
||||||
dispatch({ type: 'SELECT_PROJECT', projectId });
|
dispatch({ type: 'SELECT_PROJECT', projectId });
|
||||||
@@ -716,24 +752,27 @@ export const Sidebar = () => {
|
|||||||
|
|
||||||
{/* Qwen OAuth Status */}
|
{/* Qwen OAuth Status */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg mx-2 group"
|
className={`flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-white/5 rounded-lg mx-2 group ${qwenAuthStatus.isAuthenticated ? '' : 'opacity-70'}`}
|
||||||
onClick={() => {
|
onClick={() => setShowQwenAuthDialog(true)}
|
||||||
// Trigger Qwen OAuth flow
|
title={qwenAuthStatus.isAuthenticated ? "Qwen Cloud connected • Click to refresh" : "Click to authenticate with Qwen"}
|
||||||
const electron = (window as any).electron;
|
|
||||||
if (electron?.openQwenAuth) {
|
|
||||||
electron.openQwenAuth();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Click to authenticate with Qwen"
|
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 rounded-lg bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30">
|
<div className={`w-6 h-6 rounded-lg flex items-center justify-center border ${qwenAuthStatus.isAuthenticated
|
||||||
<span className="text-emerald-400 font-bold text-xs">Q</span>
|
? 'bg-emerald-500/20 border-emerald-500/30'
|
||||||
|
: 'bg-zinc-800 border-white/10'
|
||||||
|
}`}>
|
||||||
|
<span className={`font-bold text-xs ${qwenAuthStatus.isAuthenticated ? 'text-emerald-400' : 'text-zinc-500'}`}>Q</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-zinc-300 text-xs font-medium">Qwen Cloud</div>
|
<div className="text-zinc-300 text-xs font-medium">Qwen Cloud</div>
|
||||||
<div className="text-[9px] text-emerald-500">Connected • Free Tier</div>
|
<div className={`text-[9px] ${qwenAuthStatus.isAuthenticated ? 'text-emerald-500' : 'text-zinc-600'}`}>
|
||||||
|
{qwenAuthStatus.isAuthenticated ? (qwenAuthStatus.isValid ? 'Connected • Ready' : 'Token expired') : 'Click to authenticate'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Icons.CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
{qwenAuthStatus.isAuthenticated ? (
|
||||||
|
<Icons.CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Icons.Plus className="w-3.5 h-3.5 text-zinc-500 group-hover:text-white transition-colors" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ollama Cloud Status */}
|
{/* Ollama Cloud Status */}
|
||||||
@@ -789,8 +828,54 @@ export const Sidebar = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* User Session Section */}
|
||||||
|
{session && (
|
||||||
|
<div className="mt-auto pt-4 border-t border-white/5">
|
||||||
|
<div className="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mb-3 px-2 flex items-center gap-2">
|
||||||
|
<Icons.User className="w-3 h-3" /> Account
|
||||||
|
</div>
|
||||||
|
<div className="px-2 mb-2">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-3">
|
||||||
|
<div className="text-xs text-white font-medium truncate">{session.displayName}</div>
|
||||||
|
<div className="text-[9px] text-zinc-500 mt-1">
|
||||||
|
Logged in since {new Date(session.loginAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
onClick={() => setShowLogoutDialog(true)}
|
||||||
|
className="flex items-center gap-2 text-xs cursor-pointer transition-colors p-2 hover:bg-rose-500/10 rounded-lg mx-2 group text-zinc-400 hover:text-rose-400"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<Icons.X className="w-3.5 h-3.5" />
|
||||||
|
<span>Logout</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div >
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<LogoutDialog
|
||||||
|
isOpen={showLogoutDialog}
|
||||||
|
onClose={() => setShowLogoutDialog(false)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
userName={session?.displayName || 'User'}
|
||||||
|
stats={userStats}
|
||||||
|
/>
|
||||||
|
<QwenAuthDialog
|
||||||
|
isOpen={showQwenAuthDialog}
|
||||||
|
onClose={() => setShowQwenAuthDialog(false)}
|
||||||
|
onSuccess={async () => {
|
||||||
|
// Refresh auth status after successful auth
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (electron?.qwenAuth?.getStatus) {
|
||||||
|
const status = await electron.qwenAuth.getStatus();
|
||||||
|
setQwenAuthStatus(status);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2422,7 +2507,7 @@ Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "To
|
|||||||
}, 45000);
|
}, 45000);
|
||||||
|
|
||||||
let systemPrompt = '';
|
let systemPrompt = '';
|
||||||
const isModificationMode = state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing;
|
const isModificationMode = (state.state === OrchestratorState.PreviewReady || state.state === OrchestratorState.Editing);
|
||||||
let requestKind: 'chat' | 'plan' | 'code' = (isChatMode || isBrainstormMode) ? 'chat' : 'plan';
|
let requestKind: 'chat' | 'plan' | 'code' = (isChatMode || isBrainstormMode) ? 'chat' : 'plan';
|
||||||
|
|
||||||
// SMART ROUTING: REMOVED CONCIERGE (F4: Plan First Enforcement)
|
// SMART ROUTING: REMOVED CONCIERGE (F4: Plan First Enforcement)
|
||||||
@@ -2430,6 +2515,7 @@ Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "To
|
|||||||
if (isChatMode) {
|
if (isChatMode) {
|
||||||
const sysP = personaSystem(state, state.chatPersona, state.customChatPersonaPrompt);
|
const sysP = personaSystem(state, state.chatPersona, state.customChatPersonaPrompt);
|
||||||
systemPrompt = `[SYSTEM INSTRUCTION]: ${sysP}\n\n[CONTEXT]: This is CHAT mode (not building). Do not generate code unless explicitly asked. If user asks for a change to an existing project, propose a plan starting with '[PLAN]' and wait for approval. Use PLAIN PROSE for conversation, no markdown code blocks for speech.\n\n[IMAGE GENERATION]: You CAN generate images! If the user asks for an image, painting, illustration, or visual content, acknowledge that you're generating it. The system will handle the actual generation. Say something like "I'm creating that image for you now, please wait a moment...".`;
|
systemPrompt = `[SYSTEM INSTRUCTION]: ${sysP}\n\n[CONTEXT]: This is CHAT mode (not building). Do not generate code unless explicitly asked. If user asks for a change to an existing project, propose a plan starting with '[PLAN]' and wait for approval. Use PLAIN PROSE for conversation, no markdown code blocks for speech.\n\n[IMAGE GENERATION]: You CAN generate images! If the user asks for an image, painting, illustration, or visual content, acknowledge that you're generating it. The system will handle the actual generation. Say something like "I'm creating that image for you now, please wait a moment...".`;
|
||||||
|
|
||||||
} else if (isBrainstormMode) {
|
} else if (isBrainstormMode) {
|
||||||
const lower = userPrompt.toLowerCase();
|
const lower = userPrompt.toLowerCase();
|
||||||
const wantsPlan = lower.includes('plan') || lower.includes('formalize') || lower.includes('blueprint');
|
const wantsPlan = lower.includes('plan') || lower.includes('formalize') || lower.includes('blueprint');
|
||||||
@@ -2470,6 +2556,7 @@ Format: { "ideas": [{ "title": "Short Title", "subtitle": "One line", "tag": "To
|
|||||||
(window as any)._redesignApprovedSessions[state.activeProject.id] = true;
|
(window as any)._redesignApprovedSessions[state.activeProject.id] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (isQaFailureArtifact) {
|
if (isQaFailureArtifact) {
|
||||||
// --- REPAIR MODE (F3: Retention & Match) ---
|
// --- REPAIR MODE (F3: Retention & Match) ---
|
||||||
// "Broken frontend is treated as a REPAIR task"
|
// "Broken frontend is treated as a REPAIR task"
|
||||||
|
|||||||
481
bin/goose-ultra-final/src/components/QwenAuthDialog.tsx
Normal file
481
bin/goose-ultra-final/src/components/QwenAuthDialog.tsx
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
/**
|
||||||
|
* Qwen OAuth Dialog for Goose Ultra
|
||||||
|
*
|
||||||
|
* Provides an inline OAuth flow using the Device Authorization Grant.
|
||||||
|
* Eliminates the need for external Qwen CLI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// ===== TYPES =====
|
||||||
|
|
||||||
|
interface AuthProgress {
|
||||||
|
status: 'awaiting_auth' | 'polling';
|
||||||
|
url?: string;
|
||||||
|
userCode?: string;
|
||||||
|
expiresIn?: number;
|
||||||
|
attempt?: number;
|
||||||
|
maxAttempts?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthStatus = 'idle' | 'starting' | 'awaiting' | 'polling' | 'success' | 'error';
|
||||||
|
|
||||||
|
interface QwenAuthDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ICONS =====
|
||||||
|
|
||||||
|
const Icons = {
|
||||||
|
Globe: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"></circle>
|
||||||
|
<line x1="2" x2="22" y1="12" y2="12"></line>
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Check: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
X: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" x2="6" y1="6" y2="18"></line>
|
||||||
|
<line x1="6" x2="18" y1="6" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Loader: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="animate-spin">
|
||||||
|
<line x1="12" x2="12" y1="2" y2="6"></line>
|
||||||
|
<line x1="12" x2="12" y1="18" y2="22"></line>
|
||||||
|
<line x1="4.93" x2="7.76" y1="4.93" y2="7.76"></line>
|
||||||
|
<line x1="16.24" x2="19.07" y1="16.24" y2="19.07"></line>
|
||||||
|
<line x1="2" x2="6" y1="12" y2="12"></line>
|
||||||
|
<line x1="18" x2="22" y1="12" y2="12"></line>
|
||||||
|
<line x1="4.93" x2="7.76" y1="19.07" y2="16.24"></line>
|
||||||
|
<line x1="16.24" x2="19.07" y1="7.76" y2="4.93"></line>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Copy: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
ExternalLink: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||||
|
<polyline points="15 3 21 3 21 9"></polyline>
|
||||||
|
<line x1="10" x2="21" y1="14" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== STYLES =====
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
overlay: {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.85)',
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10000
|
||||||
|
},
|
||||||
|
dialog: {
|
||||||
|
background: 'linear-gradient(180deg, rgba(28, 28, 35, 0.98) 0%, rgba(18, 18, 22, 0.98) 100%)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
padding: '32px',
|
||||||
|
maxWidth: '440px',
|
||||||
|
width: '100%',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||||
|
position: 'relative' as const
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
position: 'absolute' as const,
|
||||||
|
top: '16px',
|
||||||
|
right: '16px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
color: '#71717a',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '22px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#fff',
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px'
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#71717a',
|
||||||
|
marginBottom: '24px'
|
||||||
|
},
|
||||||
|
codeBox: {
|
||||||
|
background: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
border: '2px solid rgba(34, 211, 238, 0.4)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginBottom: '20px'
|
||||||
|
},
|
||||||
|
userCode: {
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '36px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#22d3ee',
|
||||||
|
letterSpacing: '4px',
|
||||||
|
marginBottom: '12px'
|
||||||
|
},
|
||||||
|
copyButton: {
|
||||||
|
background: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '13px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px',
|
||||||
|
background: 'linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
},
|
||||||
|
secondaryButton: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: '#a1a1aa',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
width: '48px',
|
||||||
|
height: '48px',
|
||||||
|
margin: '0 auto 20px',
|
||||||
|
border: '3px solid rgba(34, 211, 238, 0.2)',
|
||||||
|
borderTopColor: '#22d3ee',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite'
|
||||||
|
},
|
||||||
|
pollingInfo: {
|
||||||
|
background: 'rgba(34, 211, 238, 0.1)',
|
||||||
|
border: '1px solid rgba(34, 211, 238, 0.2)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: '13px',
|
||||||
|
color: '#22d3ee',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginTop: '16px'
|
||||||
|
},
|
||||||
|
successIcon: {
|
||||||
|
width: '64px',
|
||||||
|
height: '64px',
|
||||||
|
margin: '0 auto 16px',
|
||||||
|
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
color: '#fff'
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
textAlign: 'center' as const
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add keyframes for spinner animation
|
||||||
|
const spinnerKeyframes = `
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ===== COMPONENT =====
|
||||||
|
|
||||||
|
export const QwenAuthDialog: React.FC<QwenAuthDialogProps> = ({ isOpen, onClose, onSuccess }) => {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>('idle');
|
||||||
|
const [authUrl, setAuthUrl] = useState('');
|
||||||
|
const [userCode, setUserCode] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [pollAttempt, setPollAttempt] = useState(0);
|
||||||
|
const [maxAttempts, setMaxAttempts] = useState(0);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Setup listeners on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.qwenAuth) return;
|
||||||
|
|
||||||
|
electron.qwenAuth.onProgress((data: AuthProgress) => {
|
||||||
|
if (data.status === 'awaiting_auth') {
|
||||||
|
setStatus('awaiting');
|
||||||
|
setAuthUrl(data.url || '');
|
||||||
|
setUserCode(data.userCode || '');
|
||||||
|
if (data.expiresIn) {
|
||||||
|
setMaxAttempts(Math.ceil(data.expiresIn / 2)); // 2 second intervals
|
||||||
|
}
|
||||||
|
} else if (data.status === 'polling') {
|
||||||
|
setStatus('polling');
|
||||||
|
setPollAttempt(data.attempt || 0);
|
||||||
|
setMaxAttempts(data.maxAttempts || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
electron.qwenAuth.onSuccess(() => {
|
||||||
|
setStatus('success');
|
||||||
|
setTimeout(() => {
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
electron.qwenAuth.onError((err: string) => {
|
||||||
|
setStatus('error');
|
||||||
|
setError(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
electron.qwenAuth.removeListeners();
|
||||||
|
};
|
||||||
|
}, [onClose, onSuccess]);
|
||||||
|
|
||||||
|
// Reset state when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setStatus('idle');
|
||||||
|
setAuthUrl('');
|
||||||
|
setUserCode('');
|
||||||
|
setError('');
|
||||||
|
setPollAttempt(0);
|
||||||
|
setMaxAttempts(0);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const startAuth = () => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.qwenAuth) return;
|
||||||
|
|
||||||
|
setStatus('starting');
|
||||||
|
setError('');
|
||||||
|
electron.qwenAuth.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAuth = () => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (electron?.qwenAuth) {
|
||||||
|
electron.qwenAuth.cancel();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(userCode);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to copy:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAuthUrl = () => {
|
||||||
|
if (authUrl) {
|
||||||
|
window.open(authUrl, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{spinnerKeyframes}</style>
|
||||||
|
<div style={styles.overlay}>
|
||||||
|
<div style={styles.dialog}>
|
||||||
|
<button style={styles.closeButton} onClick={cancelAuth}>
|
||||||
|
<Icons.X />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* IDLE STATE - Start Auth */}
|
||||||
|
{status === 'idle' && (
|
||||||
|
<>
|
||||||
|
<div style={styles.title}>
|
||||||
|
<span style={{ fontSize: '28px' }}>🔐</span>
|
||||||
|
Connect to Qwen
|
||||||
|
</div>
|
||||||
|
<p style={styles.subtitle}>
|
||||||
|
Authenticate with your Qwen account to access powerful AI models.
|
||||||
|
No external tools required!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button style={styles.linkButton} onClick={startAuth}>
|
||||||
|
<Icons.Globe />
|
||||||
|
Sign in with Qwen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button style={styles.secondaryButton} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* STARTING STATE */}
|
||||||
|
{status === 'starting' && (
|
||||||
|
<>
|
||||||
|
<div style={styles.title}>Connecting...</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '32px 0' }}>
|
||||||
|
<div style={styles.spinner} />
|
||||||
|
<p style={{ color: '#71717a' }}>Initializing device authorization...</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AWAITING AUTHORIZATION */}
|
||||||
|
{status === 'awaiting' && (
|
||||||
|
<>
|
||||||
|
<div style={styles.title}>Complete in Browser</div>
|
||||||
|
<p style={styles.subtitle}>
|
||||||
|
A browser window should have opened. Enter this code when prompted:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={styles.codeBox}>
|
||||||
|
<div style={styles.userCode}>{userCode}</div>
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...styles.copyButton,
|
||||||
|
background: copied ? 'rgba(34, 197, 94, 0.2)' : styles.copyButton.background,
|
||||||
|
borderColor: copied ? '#22c55e' : 'rgba(255, 255, 255, 0.2)'
|
||||||
|
}}
|
||||||
|
onClick={copyCode}
|
||||||
|
>
|
||||||
|
{copied ? <Icons.Check /> : <Icons.Copy />}
|
||||||
|
{copied ? 'Copied!' : 'Copy Code'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button style={styles.linkButton} onClick={openAuthUrl}>
|
||||||
|
<Icons.ExternalLink />
|
||||||
|
Open Authorization Page
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={styles.pollingInfo}>
|
||||||
|
Waiting for authorization...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{ ...styles.secondaryButton, marginTop: '16px' }}
|
||||||
|
onClick={cancelAuth}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* POLLING STATE */}
|
||||||
|
{status === 'polling' && (
|
||||||
|
<>
|
||||||
|
<div style={styles.title}>Waiting for Authorization...</div>
|
||||||
|
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||||
|
<div style={styles.spinner} />
|
||||||
|
<p style={{ color: '#71717a', marginBottom: '8px' }}>
|
||||||
|
Complete the sign-in in your browser
|
||||||
|
</p>
|
||||||
|
<p style={{ color: '#52525b', fontSize: '13px' }}>
|
||||||
|
Check {pollAttempt} / {maxAttempts}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{userCode && (
|
||||||
|
<div style={styles.codeBox}>
|
||||||
|
<div style={{ ...styles.userCode, fontSize: '28px' }}>{userCode}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button style={styles.secondaryButton} onClick={cancelAuth}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SUCCESS STATE */}
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<div style={styles.successIcon}>
|
||||||
|
<Icons.Check />
|
||||||
|
</div>
|
||||||
|
<div style={{ ...styles.title, justifyContent: 'center' }}>
|
||||||
|
Connected!
|
||||||
|
</div>
|
||||||
|
<p style={{ ...styles.subtitle, textAlign: 'center' }}>
|
||||||
|
You're now authenticated with Qwen.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ERROR STATE */}
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<div style={styles.title}>
|
||||||
|
<span style={{ fontSize: '28px' }}>❌</span>
|
||||||
|
Authentication Failed
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.errorBox}>
|
||||||
|
{error || 'An unknown error occurred'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button style={styles.linkButton} onClick={startAuth}>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button style={styles.secondaryButton} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QwenAuthDialog;
|
||||||
800
bin/goose-ultra-final/src/components/UserAuth.tsx
Normal file
800
bin/goose-ultra-final/src/components/UserAuth.tsx
Normal file
@@ -0,0 +1,800 @@
|
|||||||
|
/**
|
||||||
|
* User Authentication Components for Goose Ultra
|
||||||
|
*
|
||||||
|
* Components:
|
||||||
|
* - LoginGate: Full-screen wrapper that enforces authentication
|
||||||
|
* - UserOnboarding: Name + secret question wizard
|
||||||
|
* - SecretCodeReveal: Shows code once with copy button
|
||||||
|
* - LogoutDialog: Confirmation with clean data option
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
||||||
|
|
||||||
|
// ===== TYPES =====
|
||||||
|
|
||||||
|
interface GooseUser {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
secretQuestionId: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastLoginAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSession {
|
||||||
|
userId: string;
|
||||||
|
displayName: string;
|
||||||
|
loginAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecretQuestion {
|
||||||
|
id: string;
|
||||||
|
question: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserContextType {
|
||||||
|
session: UserSession | null;
|
||||||
|
user: GooseUser | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
logout: (cleanData?: boolean) => Promise<void>;
|
||||||
|
refreshSession: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CONTEXT =====
|
||||||
|
|
||||||
|
const UserContext = createContext<UserContextType>({
|
||||||
|
session: null,
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
logout: async () => { },
|
||||||
|
refreshSession: async () => { }
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUser = () => useContext(UserContext);
|
||||||
|
|
||||||
|
// ===== ICONS =====
|
||||||
|
|
||||||
|
const Icons = {
|
||||||
|
User: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path>
|
||||||
|
<circle cx="12" cy="7" r="4"></circle>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Key: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Check: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="20 6 9 17 4 12"></polyline>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Copy: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
|
||||||
|
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Alert: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path>
|
||||||
|
<path d="M12 9v4"></path>
|
||||||
|
<path d="M12 17h.01"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Logout: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" x2="9" y1="12" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Trash: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 6h18"></path>
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
ArrowRight: () => (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M5 12h14"></path>
|
||||||
|
<path d="m12 5 7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== STYLES =====
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
inset: 0,
|
||||||
|
background: 'linear-gradient(135deg, #030304 0%, #0a0a0f 50%, #030304 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 9999,
|
||||||
|
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif"
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
background: 'rgba(20, 20, 25, 0.95)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '24px',
|
||||||
|
padding: '48px',
|
||||||
|
maxWidth: '480px',
|
||||||
|
width: '100%',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||||
|
backdropFilter: 'blur(20px)'
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
width: '80px',
|
||||||
|
height: '80px',
|
||||||
|
margin: '0 auto 24px',
|
||||||
|
display: 'block',
|
||||||
|
borderRadius: '20px',
|
||||||
|
background: 'linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%)',
|
||||||
|
padding: '16px'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginBottom: '8px',
|
||||||
|
background: 'linear-gradient(135deg, #fff 0%, #a1a1aa 100%)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent'
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: '15px',
|
||||||
|
color: '#71717a',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginBottom: '32px'
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px 16px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '16px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
marginBottom: '16px',
|
||||||
|
boxSizing: 'border-box' as const
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px 16px',
|
||||||
|
background: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '16px',
|
||||||
|
outline: 'none',
|
||||||
|
marginBottom: '16px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxSizing: 'border-box' as const
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'linear-gradient(135deg, #22d3ee 0%, #3b82f6 100%)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
},
|
||||||
|
buttonSecondary: {
|
||||||
|
width: '100%',
|
||||||
|
padding: '16px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
marginTop: '12px'
|
||||||
|
},
|
||||||
|
codeBox: {
|
||||||
|
background: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
border: '2px solid rgba(34, 211, 238, 0.3)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
marginBottom: '24px'
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
fontSize: '28px',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#22d3ee',
|
||||||
|
letterSpacing: '2px'
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
background: 'rgba(234, 179, 8, 0.1)',
|
||||||
|
border: '1px solid rgba(234, 179, 8, 0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: '24px',
|
||||||
|
color: '#eab308'
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#a1a1aa'
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
background: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
color: '#ef4444',
|
||||||
|
fontSize: '14px',
|
||||||
|
marginBottom: '16px',
|
||||||
|
textAlign: 'center' as const
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== COMPONENTS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Welcome Screen - First screen user sees
|
||||||
|
*/
|
||||||
|
const WelcomeScreen: React.FC<{
|
||||||
|
onNewUser: () => void;
|
||||||
|
onHasCode: () => void;
|
||||||
|
}> = ({ onNewUser, onHasCode }) => (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={styles.logo}>
|
||||||
|
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M24 4L4 14v20l20 10 20-10V14L24 4z" fill="#000" fillOpacity="0.3" />
|
||||||
|
<path d="M24 8L8 16v16l16 8 16-8V16L24 8z" stroke="#fff" strokeWidth="2" />
|
||||||
|
<circle cx="24" cy="24" r="8" fill="#fff" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 style={styles.title}>Welcome to Goose Ultra</h1>
|
||||||
|
<p style={styles.subtitle}>Your personal AI-powered development environment</p>
|
||||||
|
|
||||||
|
<button style={styles.button} onClick={onNewUser}>
|
||||||
|
<Icons.User />
|
||||||
|
I'm new here
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button style={styles.buttonSecondary} onClick={onHasCode}>
|
||||||
|
<Icons.Key />
|
||||||
|
I have a secret code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login Screen - Enter secret code
|
||||||
|
*/
|
||||||
|
const LoginScreen: React.FC<{
|
||||||
|
onLogin: (code: string) => Promise<boolean>;
|
||||||
|
onBack: () => void;
|
||||||
|
}> = ({ onLogin, onBack }) => {
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!code.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const success = await onLogin(code.trim());
|
||||||
|
if (!success) {
|
||||||
|
setError('Invalid secret code. Please check and try again.');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<h1 style={styles.title}>Welcome Back</h1>
|
||||||
|
<p style={styles.subtitle}>Enter your secret code to continue</p>
|
||||||
|
|
||||||
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="GU-XXXX-XXXX-XXXX"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
style={{
|
||||||
|
...styles.input,
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
letterSpacing: '1px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: '18px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{ ...styles.button, opacity: loading ? 0.7 : 1 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Verifying...' : 'Login'}
|
||||||
|
<Icons.ArrowRight />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button style={styles.buttonSecondary} onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding Step 1 - Enter Name
|
||||||
|
*/
|
||||||
|
const OnboardingName: React.FC<{
|
||||||
|
onNext: (name: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}> = ({ onNext, onBack }) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (name.trim().length >= 2) {
|
||||||
|
onNext(name.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<h1 style={styles.title}>What's your name?</h1>
|
||||||
|
<p style={styles.subtitle}>This will be displayed in your profile</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your display name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
autoFocus
|
||||||
|
minLength={2}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{ ...styles.button, opacity: name.length < 2 ? 0.5 : 1 }}
|
||||||
|
disabled={name.length < 2}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<Icons.ArrowRight />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button style={styles.buttonSecondary} onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding Step 2 - Secret Question
|
||||||
|
*/
|
||||||
|
const OnboardingQuestion: React.FC<{
|
||||||
|
questions: SecretQuestion[];
|
||||||
|
onNext: (questionId: string, answer: string) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}> = ({ questions, onNext, onBack }) => {
|
||||||
|
const [questionId, setQuestionId] = useState('');
|
||||||
|
const [answer, setAnswer] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (questionId && answer.trim().length >= 2) {
|
||||||
|
onNext(questionId, answer.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<h1 style={styles.title}>Set a Security Question</h1>
|
||||||
|
<p style={styles.subtitle}>This helps generate your unique secret code</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<select
|
||||||
|
value={questionId}
|
||||||
|
onChange={(e) => setQuestionId(e.target.value)}
|
||||||
|
style={styles.select}
|
||||||
|
>
|
||||||
|
<option value="">Select a question...</option>
|
||||||
|
{questions.map(q => (
|
||||||
|
<option key={q.id} value={q.id}>{q.question}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your answer"
|
||||||
|
value={answer}
|
||||||
|
onChange={(e) => setAnswer(e.target.value)}
|
||||||
|
style={styles.input}
|
||||||
|
minLength={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{ ...styles.button, opacity: (!questionId || answer.length < 2) ? 0.5 : 1 }}
|
||||||
|
disabled={!questionId || answer.length < 2}
|
||||||
|
>
|
||||||
|
Generate Secret Code
|
||||||
|
<Icons.Key />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button style={styles.buttonSecondary} onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret Code Reveal
|
||||||
|
*/
|
||||||
|
const SecretCodeReveal: React.FC<{
|
||||||
|
code: string;
|
||||||
|
userName: string;
|
||||||
|
onContinue: () => void;
|
||||||
|
}> = ({ code, userName, onContinue }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to copy:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.card}>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
|
||||||
|
<span style={{ fontSize: '48px' }}>🎉</span>
|
||||||
|
</div>
|
||||||
|
<h1 style={styles.title}>Welcome, {userName}!</h1>
|
||||||
|
<p style={styles.subtitle}>Your secret code is ready</p>
|
||||||
|
|
||||||
|
<div style={styles.codeBox}>
|
||||||
|
<div style={styles.code}>{code}</div>
|
||||||
|
<button
|
||||||
|
onClick={copyCode}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: copied ? 'rgba(34, 197, 94, 0.2)' : 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: `1px solid ${copied ? '#22c55e' : 'rgba(255, 255, 255, 0.2)'}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: copied ? '#22c55e' : '#fff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Icons.Check /> : <Icons.Copy />}
|
||||||
|
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.warning}>
|
||||||
|
<Icons.Alert />
|
||||||
|
<div>
|
||||||
|
<strong style={{ display: 'block', marginBottom: '4px' }}>SAVE THIS CODE OFFLINE!</strong>
|
||||||
|
<span style={{ fontSize: '13px', opacity: 0.9 }}>
|
||||||
|
This is the ONLY way to log back in. We cannot recover it if you lose it.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={styles.checkbox} onClick={() => setConfirmed(!confirmed)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => setConfirmed(e.target.checked)}
|
||||||
|
style={{ width: '18px', height: '18px', cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
<span>I have saved my code securely</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{ ...styles.button, opacity: confirmed ? 1 : 0.5 }}
|
||||||
|
onClick={onContinue}
|
||||||
|
disabled={!confirmed}
|
||||||
|
>
|
||||||
|
Continue to Goose Ultra
|
||||||
|
<Icons.ArrowRight />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout Dialog
|
||||||
|
*/
|
||||||
|
export const LogoutDialog: React.FC<{
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLogout: (cleanData: boolean) => void;
|
||||||
|
userName: string;
|
||||||
|
stats: { projectCount: number; chatCount: number; totalSizeBytes: number };
|
||||||
|
}> = ({ isOpen, onClose, onLogout, userName, stats }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 10000
|
||||||
|
}}>
|
||||||
|
<div style={{ ...styles.card, maxWidth: '420px' }}>
|
||||||
|
<h2 style={{ ...styles.title, fontSize: '22px' }}>Logging Out</h2>
|
||||||
|
<p style={{ ...styles.subtitle, marginBottom: '24px' }}>
|
||||||
|
Goodbye, {userName}!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
marginBottom: '24px'
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '13px', color: '#a1a1aa', marginBottom: '12px' }}>
|
||||||
|
Your data on this device:
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
|
||||||
|
<span>Projects</span>
|
||||||
|
<span>{stats.projectCount}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
|
||||||
|
<span>Chat History</span>
|
||||||
|
<span>{stats.chatCount}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#fff', fontSize: '14px' }}>
|
||||||
|
<span>Total Size</span>
|
||||||
|
<span>{formatSize(stats.totalSizeBytes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={styles.button}
|
||||||
|
onClick={() => onLogout(false)}
|
||||||
|
>
|
||||||
|
<Icons.Logout />
|
||||||
|
Keep Data & Logout
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{
|
||||||
|
...styles.buttonSecondary,
|
||||||
|
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||||
|
color: '#ef4444'
|
||||||
|
}}
|
||||||
|
onClick={() => onLogout(true)}
|
||||||
|
>
|
||||||
|
<Icons.Trash />
|
||||||
|
Clean Data & Logout
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
style={{ ...styles.buttonSecondary, marginTop: '8px' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== MAIN LOGIN GATE =====
|
||||||
|
|
||||||
|
type Screen = 'welcome' | 'login' | 'onboarding-name' | 'onboarding-question' | 'code-reveal';
|
||||||
|
|
||||||
|
export const LoginGate: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [screen, setScreen] = useState<Screen>('welcome');
|
||||||
|
const [session, setSession] = useState<UserSession | null>(null);
|
||||||
|
const [user, setUser] = useState<GooseUser | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [questions, setQuestions] = useState<SecretQuestion[]>([]);
|
||||||
|
|
||||||
|
// Onboarding state
|
||||||
|
const [onboardingName, setOnboardingName] = useState('');
|
||||||
|
const [generatedCode, setGeneratedCode] = useState('');
|
||||||
|
|
||||||
|
// Check for existing session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkSession = async () => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.user) {
|
||||||
|
// No electron API (web mode) - skip auth
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingSession = await electron.user.getSession();
|
||||||
|
if (existingSession) {
|
||||||
|
setSession(existingSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
const questionsList = await electron.user.getSecretQuestions();
|
||||||
|
setQuestions(questionsList || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to check session:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Login handler
|
||||||
|
const handleLogin = async (code: string): Promise<boolean> => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.user) return false;
|
||||||
|
|
||||||
|
const result = await electron.user.login(code);
|
||||||
|
if (result.success) {
|
||||||
|
setSession(result.session);
|
||||||
|
setUser(result.user);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create user handler
|
||||||
|
const handleCreateUser = async (questionId: string, answer: string) => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.user) return;
|
||||||
|
|
||||||
|
const result = await electron.user.create(onboardingName, questionId, answer);
|
||||||
|
if (result.success) {
|
||||||
|
setGeneratedCode(result.secretCode);
|
||||||
|
setUser(result.user);
|
||||||
|
setSession(result.session);
|
||||||
|
setScreen('code-reveal');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logout handler
|
||||||
|
const handleLogout = async (cleanData = false) => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.user) return;
|
||||||
|
|
||||||
|
await electron.user.logout(cleanData);
|
||||||
|
setSession(null);
|
||||||
|
setUser(null);
|
||||||
|
setScreen('welcome');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh session
|
||||||
|
const refreshSession = async () => {
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.user) return;
|
||||||
|
|
||||||
|
const existingSession = await electron.user.getSession();
|
||||||
|
setSession(existingSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={{ color: '#fff', fontSize: '18px' }}>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no electron (web mode) or has session, render children
|
||||||
|
const electron = (window as any).electron;
|
||||||
|
if (!electron?.user || session) {
|
||||||
|
return (
|
||||||
|
<UserContext.Provider value={{ session, user, isLoading, logout: handleLogout, refreshSession }}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render login/onboarding screens
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
{screen === 'welcome' && (
|
||||||
|
<WelcomeScreen
|
||||||
|
onNewUser={() => setScreen('onboarding-name')}
|
||||||
|
onHasCode={() => setScreen('login')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'login' && (
|
||||||
|
<LoginScreen
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onBack={() => setScreen('welcome')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'onboarding-name' && (
|
||||||
|
<OnboardingName
|
||||||
|
onNext={(name) => {
|
||||||
|
setOnboardingName(name);
|
||||||
|
setScreen('onboarding-question');
|
||||||
|
}}
|
||||||
|
onBack={() => setScreen('welcome')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'onboarding-question' && (
|
||||||
|
<OnboardingQuestion
|
||||||
|
questions={questions}
|
||||||
|
onNext={handleCreateUser}
|
||||||
|
onBack={() => setScreen('onboarding-name')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === 'code-reveal' && (
|
||||||
|
<SecretCodeReveal
|
||||||
|
code={generatedCode}
|
||||||
|
userName={onboardingName}
|
||||||
|
onContinue={() => {
|
||||||
|
// Session already set, just clear screens
|
||||||
|
setScreen('welcome');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginGate;
|
||||||
@@ -1445,41 +1445,50 @@ Start with "# 🔧 Repair Plan" and be concise.`;
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (confirm('Generate a Node.js/Express backend for this frontend?')) {
|
if (confirm('Export this frontend as a UX Package for external backend development?')) {
|
||||||
const htmlContent = state.files['index.html'] || '';
|
const htmlContent = state.files['index.html'] || '';
|
||||||
|
const cssContent = state.files['style.css'] || '';
|
||||||
|
const jsContent = state.files['script.js'] || '';
|
||||||
|
const projectId = state.activeProject?.id || 'unknown_project';
|
||||||
|
|
||||||
if (!htmlContent) {
|
if (!htmlContent) {
|
||||||
alert('No frontend code found to analyze.');
|
alert('No frontend code found to export.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to Chat to show progress
|
const uxPackage = {
|
||||||
dispatch({ type: 'SET_MODE', mode: GlobalMode.Chat });
|
meta: {
|
||||||
dispatch({ type: 'SET_TAB', tab: TabId.Plan });
|
project: state.activeProject?.name || "Vibe Project",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
generator: "Goose Ultra Vibe Engine",
|
||||||
|
description: "Frontend UX Package for Backend Integration"
|
||||||
|
},
|
||||||
|
files: {
|
||||||
|
"index.html": htmlContent,
|
||||||
|
"style.css": cssContent,
|
||||||
|
"script.js": jsContent
|
||||||
|
},
|
||||||
|
instructions: "This package contains the complete frontend UI. Use this to build the backend logic."
|
||||||
|
};
|
||||||
|
|
||||||
const prompt = `[BACKEND_REQUEST]
|
const blob = new Blob([JSON.stringify(uxPackage, null, 2)], { type: 'application/json' });
|
||||||
I need a backend for my frontend.
|
const url = URL.createObjectURL(blob);
|
||||||
Here is the current HTML/JS code:
|
const a = document.createElement('a');
|
||||||
\`\`\`html
|
a.href = url;
|
||||||
${htmlContent.substring(0, 15000)}... (truncated)
|
a.download = `ux_package_${projectId}.json`;
|
||||||
\`\`\`
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
Please build a 'server.js' file using Express that:
|
dispatch({ type: 'ADD_LOG', log: { id: Date.now().toString(), timestamp: Date.now(), type: 'system', message: "UX Package exported successfully." } });
|
||||||
1. Serves this static file.
|
|
||||||
2. Implements any API endpoints found in the fetch() calls.
|
|
||||||
3. Provides mock data for those endpoints.`;
|
|
||||||
|
|
||||||
dispatch({ type: 'START_REQUEST', sessionId: Date.now().toString(), messageDraft: prompt });
|
|
||||||
|
|
||||||
// We rely on the LayoutComponents handler to pick this up,
|
|
||||||
// but we need to ensure the system prompt knows how to handle it.
|
|
||||||
// Ideally, we'd invoke the automationService directly, but flowing through chat maintains state consistency.
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="ml-2 px-3 py-2 rounded-xl text-xs font-bold border border-white/10 bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/20 hover:text-white transition-colors flex items-center gap-2"
|
className="ml-2 px-3 py-2 rounded-xl text-xs font-bold border border-white/10 bg-cyan-500/10 text-cyan-300 hover:bg-cyan-500/20 hover:text-white transition-colors flex items-center gap-2"
|
||||||
title="Generate Backend Service"
|
title="Download Frontend for Backend Dev"
|
||||||
>
|
>
|
||||||
<Icons.Server className="w-3.5 h-3.5" />
|
<Icons.Package className="w-3.5 h-3.5" />
|
||||||
Build Backend
|
Generate UX Package
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -126,4 +126,5 @@ export const Icons = {
|
|||||||
Minimize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="4 14 10 14 10 20" /><polyline points="20 10 14 10 14 4" /><line x1="14" y1="10" x2="21" y2="3" /><line x1="3" y1="21" x2="10" y2="14" /></svg>,
|
Minimize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="4 14 10 14 10 20" /><polyline points="20 10 14 10 14 4" /><line x1="14" y1="10" x2="21" y2="3" /><line x1="3" y1="21" x2="10" y2="14" /></svg>,
|
||||||
Maximize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>,
|
Maximize2: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><polyline points="15 3 21 3 21 9" /><polyline points="9 21 3 21 3 15" /><line x1="21" y1="3" x2="14" y2="10" /><line x1="3" y1="21" x2="10" y2="14" /></svg>,
|
||||||
Brain: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" /><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" /><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" /><path d="M17.599 6.5a3 3 0 0 0 .399-1.375" /><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" /><path d="M3.477 10.896a4 4 0 0 1 .585-.396" /><path d="M19.938 10.5a4 4 0 0 1 .585.396" /><path d="M6 18a4 4 0 0 1-1.967-.516" /><path d="M19.967 17.484A4 4 0 0 1 18 18" /></svg>,
|
Brain: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z" /><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z" /><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4" /><path d="M17.599 6.5a3 3 0 0 0 .399-1.375" /><path d="M6.003 5.125A3 3 0 0 0 6.401 6.5" /><path d="M3.477 10.896a4 4 0 0 1 .585-.396" /><path d="M19.938 10.5a4 4 0 0 1 .585.396" /><path d="M6 18a4 4 0 0 1-1.967-.516" /><path d="M19.967 17.484A4 4 0 0 1 18 18" /></svg>,
|
||||||
|
Package: (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M16.5 9.4 7.5 4.21" /><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" /></svg>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -83,26 +83,6 @@ Example Output:
|
|||||||
</artifact_payload>
|
</artifact_payload>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const BACKEND_GENERATOR_PROMPT = `
|
|
||||||
You are an expert Backend Engineer. Your task is to build a robust Node.js/Express backend for the provided frontend code.
|
|
||||||
|
|
||||||
### ANALYSIS PHASE
|
|
||||||
1. **Endpoint Extraction**: Scan the frontend code for all 'fetch', 'axios', or 'XMLHttpRequest' calls. Identify the methods (GET, POST, etc.) and paths (e.g., /api/users).
|
|
||||||
2. **Data Modeling**: Infer the data structure expected by these endpoints based on how the frontend uses the response (e.g., if it maps over 'response.data.users', create a 'users' array).
|
|
||||||
|
|
||||||
### IMPLEMENTATION REQUIREMENTS
|
|
||||||
1. **Single File**: Output a single 'server.js' file.
|
|
||||||
2. **Express.js**: Use Express as the framework.
|
|
||||||
3. **Mock Data**: Create realistic mock data to populate the endpoints.
|
|
||||||
4. **CORS**: Enable CORS to allow the frontend to connect (if running separately) or serve the static frontend file if requested.
|
|
||||||
5. **Static Serving**: Include code to serve 'index.html' from the current directory.
|
|
||||||
|
|
||||||
### OUTPUT FORMAT
|
|
||||||
- Return ONLY the raw JavaScript code for 'server.js'.
|
|
||||||
- Start with 'const express = require("express");'.
|
|
||||||
- Do NOT wrap in markdown blocks.
|
|
||||||
`;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const MockComputerDriver: GooseUltraComputerDriver = {
|
export const MockComputerDriver: GooseUltraComputerDriver = {
|
||||||
|
|||||||
1
qwen-code-reference
Submodule
1
qwen-code-reference
Submodule
Submodule qwen-code-reference added at a92be72e88
Reference in New Issue
Block a user