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:
Gemini AI
2025-12-20 18:31:50 +04:00
Unverified
parent f35f7bd6c5
commit b6f2c68243
9 changed files with 2863 additions and 5 deletions

View File

@@ -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');

View File

@@ -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');
}
}
});

View 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));
}

View 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'))
};
}

View 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** |

View File

@@ -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>
);
}

View 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;

View 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

Submodule qwen-code-reference added at a92be72e88