Feature: Secret Key User System & Inline Qwen OAuth
Phase 1: User Authentication System - Added user-data.js: Secret code generation, user creation, session management - Added UserAuth.tsx: LoginGate, onboarding wizard, secret code reveal dialog - Users get isolated environments (projects, chats, API keys) Phase 2: Inline Qwen OAuth (No External CLI) - Added qwen-oauth.js: Device Authorization Grant with PKCE - Added QwenAuthDialog.tsx: Full inline auth flow with user code display - Tokens saved per-user with legacy fallback Phase 3: Integration - Updated main.js with IPC handlers for user auth and Qwen OAuth - Updated preload.js with electron.user and electron.qwenAuth bridges - Wrapped App.tsx with LoginGate for authentication enforcement Based on analysis of qwen-code repository OAuth implementation.
This commit is contained in:
@@ -9,6 +9,10 @@ import * as viAutomation from './vi-automation.js';
|
||||
import { execFile } from 'child_process';
|
||||
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 __dirname = path.dirname(__filename);
|
||||
|
||||
@@ -703,4 +707,168 @@ ipcMain.handle('vi-open-browser', async (_, { 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');
|
||||
|
||||
@@ -97,5 +97,64 @@ contextBridge.exposeInMainWorld('electron', {
|
||||
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
|
||||
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
|
||||
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 { TabId, OrchestratorState, GlobalMode } from './types';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { LoginGate } from './components/UserAuth';
|
||||
|
||||
const MainLayout = () => {
|
||||
const { state } = useOrchestrator();
|
||||
@@ -66,10 +67,13 @@ const MainLayout = () => {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<OrchestratorProvider>
|
||||
<ErrorBoundary>
|
||||
<MainLayout />
|
||||
</ErrorBoundary>
|
||||
</OrchestratorProvider>
|
||||
<LoginGate>
|
||||
<OrchestratorProvider>
|
||||
<ErrorBoundary>
|
||||
<MainLayout />
|
||||
</ErrorBoundary>
|
||||
</OrchestratorProvider>
|
||||
</LoginGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
1
qwen-code-reference
Submodule
1
qwen-code-reference
Submodule
Submodule qwen-code-reference added at a92be72e88
Reference in New Issue
Block a user