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 { execFile } from 'child_process';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
|
||||||
|
// User Authentication & Qwen OAuth
|
||||||
|
import * as userData from './user-data.js';
|
||||||
|
import * as qwenOAuth from './qwen-oauth.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
@@ -703,4 +707,168 @@ ipcMain.handle('vi-open-browser', async (_, { url }) => {
|
|||||||
return await viAutomation.openBrowser(url);
|
return await viAutomation.openBrowser(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// USER AUTHENTICATION SYSTEM
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Get secret questions list
|
||||||
|
ipcMain.handle('user-get-secret-questions', () => {
|
||||||
|
return userData.SECRET_QUESTIONS;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new user account
|
||||||
|
ipcMain.handle('user-create', async (_, { displayName, questionId, answer }) => {
|
||||||
|
try {
|
||||||
|
const { user, secretCode } = userData.createUser(displayName, questionId, answer);
|
||||||
|
// Auto-start session for new user
|
||||||
|
const session = userData.startSession(user);
|
||||||
|
return { success: true, user, secretCode, session };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAuth] Create user failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticate user with secret code
|
||||||
|
ipcMain.handle('user-login', async (_, { secretCode }) => {
|
||||||
|
try {
|
||||||
|
const user = userData.authenticateUser(secretCode);
|
||||||
|
if (user) {
|
||||||
|
const session = userData.startSession(user);
|
||||||
|
return { success: true, user, session };
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Invalid secret code' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAuth] Login failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current session
|
||||||
|
ipcMain.handle('user-get-session', () => {
|
||||||
|
return userData.getCurrentSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout (end session)
|
||||||
|
ipcMain.handle('user-logout', async (_, { cleanData }) => {
|
||||||
|
try {
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
if (cleanData && session?.userId) {
|
||||||
|
userData.cleanUserData(session.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
userData.endSession();
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAuth] Logout failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
ipcMain.handle('user-get-stats', async (_, { userId }) => {
|
||||||
|
try {
|
||||||
|
return userData.getUserStats(userId);
|
||||||
|
} catch (error) {
|
||||||
|
return { projectCount: 0, chatCount: 0, totalSizeBytes: 0, hasQwenTokens: false };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean user data without logout
|
||||||
|
ipcMain.handle('user-clean-data', async (_, { userId }) => {
|
||||||
|
try {
|
||||||
|
userData.cleanUserData(userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user's projects directory
|
||||||
|
ipcMain.handle('user-get-projects-dir', (_, { userId }) => {
|
||||||
|
return userData.getUserProjectsDir(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// QWEN OAUTH (INLINE DEVICE FLOW)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Start Qwen OAuth device flow
|
||||||
|
ipcMain.on('qwen-auth-start', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!window || window.isDestroyed()) return;
|
||||||
|
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
await qwenOAuth.startDeviceFlow(
|
||||||
|
// onProgress
|
||||||
|
(progress) => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('qwen-auth-progress', progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// onSuccess
|
||||||
|
(credentials) => {
|
||||||
|
// Save tokens to user-specific location if logged in
|
||||||
|
if (session?.userId) {
|
||||||
|
qwenOAuth.saveUserTokens(session.userId, app.getPath('userData'), credentials);
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy location for backward compatibility
|
||||||
|
qwenOAuth.saveLegacyTokens(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('qwen-auth-success', credentials);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// onError
|
||||||
|
(error) => {
|
||||||
|
if (!window.isDestroyed()) {
|
||||||
|
window.webContents.send('qwen-auth-error', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel ongoing Qwen OAuth
|
||||||
|
ipcMain.on('qwen-auth-cancel', () => {
|
||||||
|
qwenOAuth.cancelAuth();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Qwen auth status for current user
|
||||||
|
ipcMain.handle('qwen-get-auth-status', () => {
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
let tokens = null;
|
||||||
|
if (session?.userId) {
|
||||||
|
tokens = qwenOAuth.loadUserTokens(session.userId, app.getPath('userData'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy tokens if not logged in or no user tokens
|
||||||
|
if (!tokens) {
|
||||||
|
tokens = qwenOAuth.loadLegacyTokens();
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = tokens?.access_token &&
|
||||||
|
(!tokens.expiry_date || tokens.expiry_date > Date.now() + 30000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuthenticated: !!tokens?.access_token,
|
||||||
|
isValid,
|
||||||
|
expiresAt: tokens?.expiry_date || null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear Qwen tokens for current user
|
||||||
|
ipcMain.handle('qwen-clear-tokens', () => {
|
||||||
|
const session = userData.getCurrentSession();
|
||||||
|
|
||||||
|
if (session?.userId) {
|
||||||
|
qwenOAuth.clearUserTokens(session.userId, app.getPath('userData'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
console.log('Goose Ultra Electron Main Process Started');
|
console.log('Goose Ultra Electron Main Process Started');
|
||||||
|
|||||||
@@ -97,5 +97,64 @@ contextBridge.exposeInMainWorld('electron', {
|
|||||||
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
|
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
|
||||||
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
|
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
|
||||||
getModels: () => ipcRenderer.invoke('ollama-get-models')
|
getModels: () => ipcRenderer.invoke('ollama-get-models')
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// USER AUTHENTICATION SYSTEM
|
||||||
|
// ==========================================
|
||||||
|
user: {
|
||||||
|
// Get list of secret questions
|
||||||
|
getSecretQuestions: () => ipcRenderer.invoke('user-get-secret-questions'),
|
||||||
|
|
||||||
|
// Create a new account (returns { success, user, secretCode, session } or { success: false, error })
|
||||||
|
create: (displayName, questionId, answer) =>
|
||||||
|
ipcRenderer.invoke('user-create', { displayName, questionId, answer }),
|
||||||
|
|
||||||
|
// Login with secret code (returns { success, user, session } or { success: false, error })
|
||||||
|
login: (secretCode) => ipcRenderer.invoke('user-login', { secretCode }),
|
||||||
|
|
||||||
|
// Get current session (returns session object or null)
|
||||||
|
getSession: () => ipcRenderer.invoke('user-get-session'),
|
||||||
|
|
||||||
|
// Logout (optionally clean data)
|
||||||
|
logout: (cleanData = false) => ipcRenderer.invoke('user-logout', { cleanData }),
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
getStats: (userId) => ipcRenderer.invoke('user-get-stats', { userId }),
|
||||||
|
|
||||||
|
// Clean all user data (projects, chats, keys)
|
||||||
|
cleanData: (userId) => ipcRenderer.invoke('user-clean-data', { userId }),
|
||||||
|
|
||||||
|
// Get user's projects directory path
|
||||||
|
getProjectsDir: (userId) => ipcRenderer.invoke('user-get-projects-dir', { userId })
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// QWEN OAUTH (INLINE DEVICE FLOW)
|
||||||
|
// ==========================================
|
||||||
|
qwenAuth: {
|
||||||
|
// Start the device authorization flow (triggers browser open)
|
||||||
|
start: () => ipcRenderer.send('qwen-auth-start'),
|
||||||
|
|
||||||
|
// Cancel ongoing authorization
|
||||||
|
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
|
||||||
|
|
||||||
|
// Get current auth status
|
||||||
|
getStatus: () => ipcRenderer.invoke('qwen-get-auth-status'),
|
||||||
|
|
||||||
|
// Clear saved tokens
|
||||||
|
clearTokens: () => ipcRenderer.invoke('qwen-clear-tokens'),
|
||||||
|
|
||||||
|
// Event listeners for auth flow
|
||||||
|
onProgress: (callback) => ipcRenderer.on('qwen-auth-progress', (_, data) => callback(data)),
|
||||||
|
onSuccess: (callback) => ipcRenderer.on('qwen-auth-success', (_, creds) => callback(creds)),
|
||||||
|
onError: (callback) => ipcRenderer.on('qwen-auth-error', (_, error) => callback(error)),
|
||||||
|
|
||||||
|
// Cleanup listeners
|
||||||
|
removeListeners: () => {
|
||||||
|
ipcRenderer.removeAllListeners('qwen-auth-progress');
|
||||||
|
ipcRenderer.removeAllListeners('qwen-auth-success');
|
||||||
|
ipcRenderer.removeAllListeners('qwen-auth-error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
360
bin/goose-ultra-final/electron/qwen-oauth.js
Normal file
360
bin/goose-ultra-final/electron/qwen-oauth.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
/**
|
||||||
|
* Qwen OAuth2 Device Flow for Goose Ultra
|
||||||
|
*
|
||||||
|
* Implements RFC 8628 OAuth 2.0 Device Authorization Grant
|
||||||
|
* with PKCE (Proof Key for Code Exchange)
|
||||||
|
*
|
||||||
|
* Based on: qwen-code/packages/core/src/qwen/qwenOAuth2.ts
|
||||||
|
* License: Apache-2.0 (Qwen)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { shell } from 'electron';
|
||||||
|
|
||||||
|
// ===== OAUTH CONFIGURATION =====
|
||||||
|
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
|
||||||
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
|
||||||
|
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
||||||
|
|
||||||
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
||||||
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
||||||
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
// ===== PKCE UTILITIES (RFC 7636) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random code verifier for PKCE
|
||||||
|
* @returns A random string of 43-128 characters
|
||||||
|
*/
|
||||||
|
export function generateCodeVerifier() {
|
||||||
|
return crypto.randomBytes(32).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a code challenge from a code verifier using SHA-256
|
||||||
|
* @param {string} codeVerifier
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function generateCodeChallenge(codeVerifier) {
|
||||||
|
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate PKCE code verifier and challenge pair
|
||||||
|
*/
|
||||||
|
export function generatePKCEPair() {
|
||||||
|
const code_verifier = generateCodeVerifier();
|
||||||
|
const code_challenge = generateCodeChallenge(code_verifier);
|
||||||
|
return { code_verifier, code_challenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HELPERS =====
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectToUrlEncoded(data) {
|
||||||
|
return Object.keys(data)
|
||||||
|
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CANCELLATION =====
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
|
||||||
|
export function cancelAuth() {
|
||||||
|
isCancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MAIN DEVICE FLOW =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the OAuth Device Authorization Flow
|
||||||
|
*
|
||||||
|
* @param {Function} onProgress - Callback for progress updates
|
||||||
|
* @param {Function} onSuccess - Callback with credentials on success
|
||||||
|
* @param {Function} onError - Callback with error message on failure
|
||||||
|
*/
|
||||||
|
export async function startDeviceFlow(onProgress, onSuccess, onError) {
|
||||||
|
isCancelled = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Generate PKCE pair
|
||||||
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||||
|
console.log('[QwenOAuth] Starting device flow with PKCE...');
|
||||||
|
|
||||||
|
// 2. Request device code
|
||||||
|
const deviceAuthBody = objectToUrlEncoded({
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
scope: QWEN_OAUTH_SCOPE,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'x-request-id': crypto.randomUUID()
|
||||||
|
},
|
||||||
|
body: deviceAuthBody
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deviceAuthResponse.ok) {
|
||||||
|
const errorText = await deviceAuthResponse.text();
|
||||||
|
throw new Error(`Device authorization failed: ${deviceAuthResponse.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceAuth = await deviceAuthResponse.json();
|
||||||
|
console.log('[QwenOAuth] Device auth response:', {
|
||||||
|
user_code: deviceAuth.user_code,
|
||||||
|
expires_in: deviceAuth.expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) {
|
||||||
|
throw new Error('Invalid device authorization response');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Notify UI and open browser
|
||||||
|
onProgress({
|
||||||
|
status: 'awaiting_auth',
|
||||||
|
url: deviceAuth.verification_uri_complete,
|
||||||
|
userCode: deviceAuth.user_code,
|
||||||
|
expiresIn: deviceAuth.expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-open browser
|
||||||
|
try {
|
||||||
|
await shell.openExternal(deviceAuth.verification_uri_complete);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[QwenOAuth] Failed to open browser:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Poll for token
|
||||||
|
let pollInterval = 2000; // 2 seconds
|
||||||
|
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
if (isCancelled) {
|
||||||
|
onError('Authentication cancelled by user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(pollInterval);
|
||||||
|
|
||||||
|
const tokenBody = objectToUrlEncoded({
|
||||||
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
device_code: deviceAuth.device_code,
|
||||||
|
code_verifier
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: tokenBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
// Success case
|
||||||
|
if (tokenData.access_token) {
|
||||||
|
console.log('[QwenOAuth] Token obtained successfully!');
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
refresh_token: tokenData.refresh_token || null,
|
||||||
|
token_type: tokenData.token_type || 'Bearer',
|
||||||
|
resource_url: tokenData.resource_url || null,
|
||||||
|
expiry_date: tokenData.expires_in
|
||||||
|
? Date.now() + (tokenData.expires_in * 1000)
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
|
||||||
|
onSuccess(credentials);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending case (user hasn't authorized yet)
|
||||||
|
if (tokenData.error === 'authorization_pending') {
|
||||||
|
onProgress({
|
||||||
|
status: 'polling',
|
||||||
|
attempt: attempt + 1,
|
||||||
|
maxAttempts
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow down case
|
||||||
|
if (tokenData.error === 'slow_down') {
|
||||||
|
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
||||||
|
console.log('[QwenOAuth] Server requested slow_down, interval now:', pollInterval);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access denied
|
||||||
|
if (tokenData.error === 'access_denied') {
|
||||||
|
onError('Access denied. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other error
|
||||||
|
if (tokenData.error) {
|
||||||
|
onError(tokenData.error_description || tokenData.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (pollError) {
|
||||||
|
console.error('[QwenOAuth] Poll error:', pollError.message);
|
||||||
|
// Continue polling on network errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
onError('Authorization timed out. Please try again.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[QwenOAuth] Device flow failed:', error);
|
||||||
|
onError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh an access token using a refresh token
|
||||||
|
*
|
||||||
|
* @param {string} refreshToken
|
||||||
|
* @returns {Promise<Object>} New credentials
|
||||||
|
*/
|
||||||
|
export async function refreshAccessToken(refreshToken) {
|
||||||
|
const body = objectToUrlEncoded({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error_description || data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: data.access_token,
|
||||||
|
refresh_token: data.refresh_token || refreshToken,
|
||||||
|
token_type: data.token_type || 'Bearer',
|
||||||
|
resource_url: data.resource_url || null,
|
||||||
|
expiry_date: data.expires_in ? Date.now() + (data.expires_in * 1000) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TOKEN PERSISTENCE (User-Isolated) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the token storage path for a specific user
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} userDataPath - app.getPath('userData')
|
||||||
|
*/
|
||||||
|
export function getUserTokenPath(userId, userDataPath) {
|
||||||
|
return path.join(userDataPath, 'user_data', userId, 'qwen_tokens.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tokens for a specific user
|
||||||
|
*/
|
||||||
|
export async function saveUserTokens(userId, userDataPath, credentials) {
|
||||||
|
const tokenPath = getUserTokenPath(userId, userDataPath);
|
||||||
|
const dir = path.dirname(tokenPath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(tokenPath, JSON.stringify(credentials, null, 2));
|
||||||
|
console.log('[QwenOAuth] Tokens saved for user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tokens for a specific user
|
||||||
|
*/
|
||||||
|
export function loadUserTokens(userId, userDataPath) {
|
||||||
|
const tokenPath = getUserTokenPath(userId, userDataPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[QwenOAuth] Failed to load user tokens:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear tokens for a specific user
|
||||||
|
*/
|
||||||
|
export function clearUserTokens(userId, userDataPath) {
|
||||||
|
const tokenPath = getUserTokenPath(userId, userDataPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
fs.unlinkSync(tokenPath);
|
||||||
|
console.log('[QwenOAuth] Tokens cleared for user:', userId);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[QwenOAuth] Failed to clear tokens:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== LEGACY SUPPORT (Global tokens for backward compatibility) =====
|
||||||
|
|
||||||
|
const LEGACY_TOKEN_PATH = path.join(os.homedir(), '.qwen', 'oauth_creds.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load tokens from legacy location (used when no user session)
|
||||||
|
*/
|
||||||
|
export function loadLegacyTokens() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(LEGACY_TOKEN_PATH, 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[QwenOAuth] Failed to load legacy tokens:', e.message);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save tokens to legacy location (for backward compatibility)
|
||||||
|
*/
|
||||||
|
export function saveLegacyTokens(credentials) {
|
||||||
|
const dir = path.dirname(LEGACY_TOKEN_PATH);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(LEGACY_TOKEN_PATH, JSON.stringify(credentials, null, 2));
|
||||||
|
}
|
||||||
474
bin/goose-ultra-final/electron/user-data.js
Normal file
474
bin/goose-ultra-final/electron/user-data.js
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
/**
|
||||||
|
* User Data Service for Goose Ultra
|
||||||
|
*
|
||||||
|
* Manages user authentication, session, and data isolation.
|
||||||
|
* Each user has their own isolated environment with separate:
|
||||||
|
* - Projects
|
||||||
|
* - Chat history
|
||||||
|
* - API keys (Qwen, Ollama)
|
||||||
|
* - Custom personas
|
||||||
|
* - Settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
|
// ===== USER DATA STRUCTURE =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} GooseUser
|
||||||
|
* @property {string} userId - UUID
|
||||||
|
* @property {string} displayName - User's chosen display name
|
||||||
|
* @property {string} secretCodeHash - SHA256 hash of the secret code
|
||||||
|
* @property {string} secretQuestionId - ID of the secret question used
|
||||||
|
* @property {number} createdAt - Timestamp
|
||||||
|
* @property {number} lastLoginAt - Timestamp
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} UserSession
|
||||||
|
* @property {string} userId
|
||||||
|
* @property {string} displayName
|
||||||
|
* @property {number} loginAt
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===== FILE PATHS =====
|
||||||
|
|
||||||
|
const getSystemDir = () => path.join(app.getPath('userData'), 'system');
|
||||||
|
const getUsersFile = () => path.join(getSystemDir(), 'users.json');
|
||||||
|
const getSessionFile = () => path.join(getSystemDir(), 'current_session.json');
|
||||||
|
const getUserDataDir = () => path.join(app.getPath('userData'), 'user_data');
|
||||||
|
|
||||||
|
// ===== SECRET QUESTIONS =====
|
||||||
|
|
||||||
|
export const SECRET_QUESTIONS = [
|
||||||
|
{ id: 'mother_maiden', question: "What is your mother's maiden name?" },
|
||||||
|
{ id: 'first_pet', question: "What was your first pet's name?" },
|
||||||
|
{ id: 'favorite_teacher', question: "What was your favorite teacher's name?" },
|
||||||
|
{ id: 'birth_city', question: "In what city were you born?" },
|
||||||
|
{ id: 'first_car', question: "What was the make of your first car?" },
|
||||||
|
{ id: 'childhood_nickname', question: "What was your childhood nickname?" },
|
||||||
|
{ id: 'custom', question: "Custom question (user-defined)" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// ===== SECRET CODE GENERATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique secret code for a new user
|
||||||
|
* Format: GU-XXXX-XXXX-XXXX (16 alphanumeric chars)
|
||||||
|
*
|
||||||
|
* @param {string} displayName
|
||||||
|
* @param {string} questionId
|
||||||
|
* @param {string} answer
|
||||||
|
* @returns {string} The secret code
|
||||||
|
*/
|
||||||
|
export function generateSecretCode(displayName, questionId, answer) {
|
||||||
|
const timestamp = Date.now().toString();
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const raw = `${displayName}|${questionId}|${answer}|${timestamp}|${salt}`;
|
||||||
|
|
||||||
|
// Create a hash and take 12 bytes
|
||||||
|
const hash = crypto.createHash('sha256').update(raw).digest();
|
||||||
|
const encoded = hash.slice(0, 12).toString('base64url').toUpperCase();
|
||||||
|
|
||||||
|
// Format as GU-XXXX-XXXX-XXXX
|
||||||
|
const formatted = `GU-${encoded.slice(0, 4)}-${encoded.slice(4, 8)}-${encoded.slice(8, 12)}`;
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a secret code for secure storage
|
||||||
|
* @param {string} secretCode
|
||||||
|
* @returns {string} SHA256 hash
|
||||||
|
*/
|
||||||
|
export function hashSecretCode(secretCode) {
|
||||||
|
// Normalize the code (remove dashes, uppercase)
|
||||||
|
const normalized = secretCode.replace(/-/g, '').toUpperCase();
|
||||||
|
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== USER MANAGEMENT =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure system directories exist
|
||||||
|
*/
|
||||||
|
function ensureSystemDirs() {
|
||||||
|
const systemDir = getSystemDir();
|
||||||
|
const userDataDir = getUserDataDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(systemDir)) {
|
||||||
|
fs.mkdirSync(systemDir, { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(userDataDir)) {
|
||||||
|
fs.mkdirSync(userDataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all registered users
|
||||||
|
* @returns {GooseUser[]}
|
||||||
|
*/
|
||||||
|
export function loadUsers() {
|
||||||
|
ensureSystemDirs();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getUsersFile())) {
|
||||||
|
return JSON.parse(fs.readFileSync(getUsersFile(), 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to load users:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save users list
|
||||||
|
* @param {GooseUser[]} users
|
||||||
|
*/
|
||||||
|
function saveUsers(users) {
|
||||||
|
ensureSystemDirs();
|
||||||
|
fs.writeFileSync(getUsersFile(), JSON.stringify(users, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new user account
|
||||||
|
*
|
||||||
|
* @param {string} displayName
|
||||||
|
* @param {string} questionId
|
||||||
|
* @param {string} answer
|
||||||
|
* @returns {{ user: GooseUser, secretCode: string }}
|
||||||
|
*/
|
||||||
|
export function createUser(displayName, questionId, answer) {
|
||||||
|
ensureSystemDirs();
|
||||||
|
|
||||||
|
const userId = crypto.randomUUID();
|
||||||
|
const secretCode = generateSecretCode(displayName, questionId, answer);
|
||||||
|
const secretCodeHash = hashSecretCode(secretCode);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
secretCodeHash,
|
||||||
|
secretQuestionId: questionId,
|
||||||
|
createdAt: now,
|
||||||
|
lastLoginAt: now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to users list
|
||||||
|
const users = loadUsers();
|
||||||
|
users.push(user);
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
// Create user's data directory
|
||||||
|
const userDir = path.join(getUserDataDir(), userId);
|
||||||
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create subdirectories
|
||||||
|
fs.mkdirSync(path.join(userDir, 'projects'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(userDir, 'chats'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(userDir, 'vault'), { recursive: true });
|
||||||
|
|
||||||
|
// Initialize settings
|
||||||
|
const defaultSettings = {
|
||||||
|
preferredFramework: null,
|
||||||
|
chatPersona: 'assistant',
|
||||||
|
theme: 'dark',
|
||||||
|
createdAt: now
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(userDir, 'settings.json'),
|
||||||
|
JSON.stringify(defaultSettings, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[UserData] Created new user:', userId, displayName);
|
||||||
|
|
||||||
|
return { user, secretCode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a user with their secret code
|
||||||
|
*
|
||||||
|
* @param {string} secretCode
|
||||||
|
* @returns {GooseUser | null}
|
||||||
|
*/
|
||||||
|
export function authenticateUser(secretCode) {
|
||||||
|
const hash = hashSecretCode(secretCode);
|
||||||
|
const users = loadUsers();
|
||||||
|
|
||||||
|
const user = users.find(u => u.secretCodeHash === hash);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update last login
|
||||||
|
user.lastLoginAt = Date.now();
|
||||||
|
saveUsers(users);
|
||||||
|
console.log('[UserData] User authenticated:', user.userId);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserData] Authentication failed: invalid secret code');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SESSION MANAGEMENT =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a user session
|
||||||
|
* @param {GooseUser} user
|
||||||
|
*/
|
||||||
|
export function startSession(user) {
|
||||||
|
const session = {
|
||||||
|
userId: user.userId,
|
||||||
|
displayName: user.displayName,
|
||||||
|
loginAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
ensureSystemDirs();
|
||||||
|
fs.writeFileSync(getSessionFile(), JSON.stringify(session, null, 2));
|
||||||
|
console.log('[UserData] Session started for:', user.displayName);
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current active session
|
||||||
|
* @returns {UserSession | null}
|
||||||
|
*/
|
||||||
|
export function getCurrentSession() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getSessionFile())) {
|
||||||
|
return JSON.parse(fs.readFileSync(getSessionFile(), 'utf8'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to load session:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the current session (logout)
|
||||||
|
*/
|
||||||
|
export function endSession() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(getSessionFile())) {
|
||||||
|
fs.unlinkSync(getSessionFile());
|
||||||
|
console.log('[UserData] Session ended');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to end session:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== USER DATA PATHS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data directory for a specific user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserDirectory(userId) {
|
||||||
|
return path.join(getUserDataDir(), userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the projects directory for a user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserProjectsDir(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'projects');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the chats directory for a user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserChatsDir(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'chats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the vault directory for a user
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserVaultDir(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'vault');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user settings path
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserSettingsPath(userId) {
|
||||||
|
return path.join(getUserDirectory(userId), 'settings.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DATA CLEANUP =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean all data for a specific user
|
||||||
|
* This removes:
|
||||||
|
* - All projects
|
||||||
|
* - All chats
|
||||||
|
* - All saved credentials
|
||||||
|
* - Custom personas
|
||||||
|
* - Settings
|
||||||
|
*
|
||||||
|
* Note: The user account itself remains intact
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function cleanUserData(userId) {
|
||||||
|
const userDir = getUserDirectory(userId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(userDir)) {
|
||||||
|
console.log('[UserData] No data to clean for user:', userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all contents but keep the directory structure
|
||||||
|
const removeContents = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const items = fs.readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dir, item);
|
||||||
|
const stat = fs.statSync(itemPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.rmSync(itemPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(itemPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean each subdirectory
|
||||||
|
removeContents(path.join(userDir, 'projects'));
|
||||||
|
removeContents(path.join(userDir, 'chats'));
|
||||||
|
removeContents(path.join(userDir, 'vault'));
|
||||||
|
|
||||||
|
// Reset settings to default
|
||||||
|
const defaultSettings = {
|
||||||
|
preferredFramework: null,
|
||||||
|
chatPersona: 'assistant',
|
||||||
|
theme: 'dark',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
cleanedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(userDir, 'settings.json'),
|
||||||
|
JSON.stringify(defaultSettings, null, 2)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[UserData] Failed to reset settings:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove Qwen tokens
|
||||||
|
const tokenPath = path.join(userDir, 'qwen_tokens.json');
|
||||||
|
if (fs.existsSync(tokenPath)) {
|
||||||
|
fs.unlinkSync(tokenPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserData] Cleaned all data for user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a user account completely
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function deleteUser(userId) {
|
||||||
|
// Remove user data
|
||||||
|
const userDir = getUserDirectory(userId);
|
||||||
|
if (fs.existsSync(userDir)) {
|
||||||
|
fs.rmSync(userDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from users list
|
||||||
|
let users = loadUsers();
|
||||||
|
users = users.filter(u => u.userId !== userId);
|
||||||
|
saveUsers(users);
|
||||||
|
|
||||||
|
console.log('[UserData] Deleted user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== MIGRATION =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate legacy global data to a user's isolated environment
|
||||||
|
* This is called when:
|
||||||
|
* 1. First user is created and old data exists
|
||||||
|
* 2. Explicitly requested by user
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} legacyProjectsDir - Old global projects directory
|
||||||
|
*/
|
||||||
|
export function migrateGlobalDataToUser(userId, legacyProjectsDir) {
|
||||||
|
const userProjectsDir = getUserProjectsDir(userId);
|
||||||
|
|
||||||
|
if (!fs.existsSync(legacyProjectsDir)) {
|
||||||
|
console.log('[UserData] No legacy data to migrate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy all projects
|
||||||
|
const projects = fs.readdirSync(legacyProjectsDir);
|
||||||
|
for (const project of projects) {
|
||||||
|
const src = path.join(legacyProjectsDir, project);
|
||||||
|
const dest = path.join(userProjectsDir, project);
|
||||||
|
|
||||||
|
if (fs.statSync(src).isDirectory()) {
|
||||||
|
fs.cpSync(src, dest, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UserData] Migrated', projects.length, 'projects to user:', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATISTICS =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics about a user's data
|
||||||
|
* @param {string} userId
|
||||||
|
*/
|
||||||
|
export function getUserStats(userId) {
|
||||||
|
const userDir = getUserDirectory(userId);
|
||||||
|
|
||||||
|
const countItems = (dir) => {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDirSize = (dir) => {
|
||||||
|
if (!fs.existsSync(dir)) return 0;
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const itemPath = path.join(dir, item.name);
|
||||||
|
if (item.isDirectory()) {
|
||||||
|
size += getDirSize(itemPath);
|
||||||
|
} else {
|
||||||
|
size += fs.statSync(itemPath).size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectCount: countItems(getUserProjectsDir(userId)),
|
||||||
|
chatCount: countItems(getUserChatsDir(userId)),
|
||||||
|
totalSizeBytes: getDirSize(userDir),
|
||||||
|
hasQwenTokens: fs.existsSync(path.join(userDir, 'qwen_tokens.json'))
|
||||||
|
};
|
||||||
|
}
|
||||||
511
bin/goose-ultra-final/implementation_plan_user_auth.md
Normal file
511
bin/goose-ultra-final/implementation_plan_user_auth.md
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
# Implementation Plan: Secret Key User System & Inline Qwen OAuth
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This plan outlines the implementation of:
|
||||||
|
1. **Secret Key User Authentication** - Users create accounts with a name + secret question, receive a unique key
|
||||||
|
2. **Isolated User Environments** - Each user has separate data (API keys, chats, sessions, projects)
|
||||||
|
3. **Inline Qwen OAuth** - Replace external CLI dependency with native device flow authentication
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: User Identity & Secret Key System
|
||||||
|
|
||||||
|
### 1.1 Secret Code Generation
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
```
|
||||||
|
SecretCode = Base64(SHA256(userName + secretQuestion + answer + timestamp + randomSalt))[:24]
|
||||||
|
```
|
||||||
|
|
||||||
|
Example output: `GU-AXBY12-CDWZ34-EFGH56`
|
||||||
|
|
||||||
|
**Security Properties:**
|
||||||
|
- One-way derivation (cannot reverse-engineer original answer)
|
||||||
|
- Time-salted to prevent duplicate codes
|
||||||
|
- 24-character code is memorable yet secure (144 bits of entropy)
|
||||||
|
|
||||||
|
### 1.2 User Data Model
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GooseUser {
|
||||||
|
userId: string; // UUID
|
||||||
|
displayName: string;
|
||||||
|
secretCodeHash: string; // SHA256 hash of the secret code (for verification)
|
||||||
|
createdAt: number;
|
||||||
|
lastLoginAt: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Files & Storage Structure
|
||||||
|
|
||||||
|
**Location:** `%AppData%/GooseUltra/` (Windows) or `~/.config/GooseUltra/` (Linux/Mac)
|
||||||
|
|
||||||
|
```
|
||||||
|
GooseUltra/
|
||||||
|
├── system/
|
||||||
|
│ ├── users.json # Array of GooseUser (stores hashes, not codes)
|
||||||
|
│ └── current_session.json # { userId, loginAt }
|
||||||
|
└── user_data/
|
||||||
|
└── {userId}/
|
||||||
|
├── settings.json # User-specific settings
|
||||||
|
├── qwen_tokens.json # User's Qwen OAuth credentials
|
||||||
|
├── ollama_key.enc # User's Ollama API key
|
||||||
|
├── projects/ # User's projects
|
||||||
|
├── chats/ # User's chat history
|
||||||
|
└── vault/ # User's credential vault
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 New Components
|
||||||
|
|
||||||
|
| Component | Location | Purpose |
|
||||||
|
|-----------|----------|---------|
|
||||||
|
| `LoginGate.tsx` | `src/components/` | Full-screen intro/login component |
|
||||||
|
| `UserOnboarding.tsx` | `src/components/` | Name + secret question wizard |
|
||||||
|
| `SecretCodeReveal.tsx` | `src/components/` | Shows code once with copy button |
|
||||||
|
| `UserContext.tsx` | `src/` | React context for current user |
|
||||||
|
|
||||||
|
### 1.5 Onboarding Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Welcome to Goose Ultra │
|
||||||
|
│ │
|
||||||
|
│ ○ I'm new here (Create Account) │
|
||||||
|
│ ○ I have a secret code (Login) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓ "New User"
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Step 1: What's your name? │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ [Your Display Name ] │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Step 2: Set Your Secret Question │
|
||||||
|
│ │
|
||||||
|
│ Pick a question (dropdown): │
|
||||||
|
│ • Mother's maiden name? │
|
||||||
|
│ • First pet's name? │
|
||||||
|
│ • Favorite teacher's name? │
|
||||||
|
│ • City you were born in? │
|
||||||
|
│ • Your custom question... │
|
||||||
|
│ │
|
||||||
|
│ Your answer: [______________] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ 🎉 Your Secret Code is Ready! │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────┐ │
|
||||||
|
│ │ GU-AXBY12-CDWZ34-EFGH56 │ │
|
||||||
|
│ └──────────────────────────────────┘ │
|
||||||
|
│ [📋 Copy to Clipboard] │
|
||||||
|
│ │
|
||||||
|
│ ⚠️ SAVE THIS CODE OFFLINE! │
|
||||||
|
│ This is the ONLY way to log in. │
|
||||||
|
│ We cannot recover it. │
|
||||||
|
│ │
|
||||||
|
│ [ ] I have saved my code securely │
|
||||||
|
│ │
|
||||||
|
│ [Continue to Goose Ultra →] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Data Isolation
|
||||||
|
|
||||||
|
### 2.1 Data Isolation Layer
|
||||||
|
|
||||||
|
**New Service:** `src/services/userDataService.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class UserDataService {
|
||||||
|
private userId: string | null = null;
|
||||||
|
|
||||||
|
setCurrentUser(userId: string) { ... }
|
||||||
|
|
||||||
|
getUserDataPath(): string {
|
||||||
|
// Returns: userData/user_data/{userId}/
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadUserSettings(): Promise<UserSettings> { ... }
|
||||||
|
async saveUserSettings(settings: UserSettings): Promise<void> { ... }
|
||||||
|
|
||||||
|
async loadQwenTokens(): Promise<QwenCredentials | null> { ... }
|
||||||
|
async saveQwenTokens(tokens: QwenCredentials): Promise<void> { ... }
|
||||||
|
|
||||||
|
async getProjectsPath(): string { ... }
|
||||||
|
async getChatsPath(): string { ... }
|
||||||
|
|
||||||
|
async cleanUserData(): Promise<void> {
|
||||||
|
// Wipes all user data (projects, chats, keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Logout & Clean Data
|
||||||
|
|
||||||
|
**Logout Flow:**
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Logging Out... │
|
||||||
|
│ │
|
||||||
|
│ Would you like to clean your data? │
|
||||||
|
│ │
|
||||||
|
│ This will permanently delete: │
|
||||||
|
│ • All your projects │
|
||||||
|
│ • All chat history │
|
||||||
|
│ • Saved API keys │
|
||||||
|
│ • Custom personas │
|
||||||
|
│ │
|
||||||
|
│ Your account will remain intact. │
|
||||||
|
│ You can log in again with your code. │
|
||||||
|
│ │
|
||||||
|
│ [Keep Data & Logout] [Clean & Logout] │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Clean Data" Explanation (to show users):**
|
||||||
|
|
||||||
|
> **What does "Clean Data" mean?**
|
||||||
|
>
|
||||||
|
> Cleaning your data removes all personal information from this device, including:
|
||||||
|
> - **Projects:** All HTML, CSS, and JavaScript you've created
|
||||||
|
> - **Chat History:** All conversations with the AI
|
||||||
|
> - **API Keys:** Any Qwen or Ollama credentials you've entered
|
||||||
|
> - **Personas:** Custom AI personalities you've configured
|
||||||
|
>
|
||||||
|
> **Why clean?**
|
||||||
|
> - You're using a shared or public computer
|
||||||
|
> - You want to free up disk space
|
||||||
|
> - You're troubleshooting issues
|
||||||
|
> - You want a fresh start
|
||||||
|
>
|
||||||
|
> **Note:** Your account code will still work. Cleaning only affects data on THIS device.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Inline Qwen OAuth (No External CLI)
|
||||||
|
|
||||||
|
### 3.1 Current vs. New Architecture
|
||||||
|
|
||||||
|
**Current Flow (Requires External CLI):**
|
||||||
|
```
|
||||||
|
User clicks "Auth" → Electron opens external Qwen CLI → CLI does OAuth → Writes ~/.qwen/oauth_creds.json → Goose reads it
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Flow (Fully Inline):**
|
||||||
|
```
|
||||||
|
User clicks "Auth" → Electron starts Device Flow → Opens browser for authorization → Polls for token → Saves per-user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 New Electron Module: `qwen-oauth.js`
|
||||||
|
|
||||||
|
**Based on:** `qwen-code-reference/packages/core/src/qwen/qwenOAuth2.ts`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// electron/qwen-oauth.js
|
||||||
|
|
||||||
|
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/device/code';
|
||||||
|
const QWEN_OAUTH_TOKEN_ENDPOINT = 'https://chat.qwen.ai/api/v1/oauth2/token';
|
||||||
|
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
|
||||||
|
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
|
||||||
|
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
||||||
|
|
||||||
|
// PKCE Helpers
|
||||||
|
function generateCodeVerifier() { ... }
|
||||||
|
function generateCodeChallenge(verifier) { ... }
|
||||||
|
|
||||||
|
// Main OAuth Flow
|
||||||
|
export async function startDeviceFlow(onProgress, onSuccess, onError) {
|
||||||
|
// 1. Generate PKCE pair
|
||||||
|
const { code_verifier, code_challenge } = generatePKCEPair();
|
||||||
|
|
||||||
|
// 2. Request device code from Qwen
|
||||||
|
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
scope: QWEN_OAUTH_SCOPE,
|
||||||
|
code_challenge,
|
||||||
|
code_challenge_method: 'S256'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const { device_code, user_code, verification_uri_complete, expires_in } = await deviceAuthResponse.json();
|
||||||
|
|
||||||
|
// 3. Notify UI with authorization URL
|
||||||
|
onProgress({
|
||||||
|
status: 'awaiting_auth',
|
||||||
|
url: verification_uri_complete,
|
||||||
|
userCode: user_code,
|
||||||
|
expiresIn: expires_in
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Open browser automatically
|
||||||
|
shell.openExternal(verification_uri_complete);
|
||||||
|
|
||||||
|
// 5. Poll for token
|
||||||
|
const pollInterval = 2000;
|
||||||
|
const maxAttempts = Math.ceil(expires_in / (pollInterval / 1000));
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
await sleep(pollInterval);
|
||||||
|
|
||||||
|
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
||||||
|
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||||
|
device_code,
|
||||||
|
code_verifier
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (tokenData.access_token) {
|
||||||
|
// SUCCESS!
|
||||||
|
const credentials = {
|
||||||
|
access_token: tokenData.access_token,
|
||||||
|
refresh_token: tokenData.refresh_token,
|
||||||
|
token_type: tokenData.token_type,
|
||||||
|
resource_url: tokenData.resource_url,
|
||||||
|
expiry_date: Date.now() + (tokenData.expires_in * 1000)
|
||||||
|
};
|
||||||
|
onSuccess(credentials);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.error === 'authorization_pending') {
|
||||||
|
onProgress({ status: 'polling', attempt, maxAttempts });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenData.error === 'slow_down') {
|
||||||
|
pollInterval = Math.min(pollInterval * 1.5, 10000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other error
|
||||||
|
onError(tokenData.error_description || tokenData.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onError('Authorization timed out');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(refreshToken) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 IPC Bridge Updates
|
||||||
|
|
||||||
|
**New handlers in `main.js`:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import * as qwenOAuth from './qwen-oauth.js';
|
||||||
|
|
||||||
|
// Start Device Authorization Flow
|
||||||
|
ipcMain.on('qwen-auth-start', async (event) => {
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
|
||||||
|
await qwenOAuth.startDeviceFlow(
|
||||||
|
(progress) => window.webContents.send('qwen-auth-progress', progress),
|
||||||
|
(credentials) => {
|
||||||
|
// Save to user-specific location
|
||||||
|
const userId = getCurrentUserId(); // From session
|
||||||
|
userDataService.saveQwenTokens(userId, credentials);
|
||||||
|
window.webContents.send('qwen-auth-success', credentials);
|
||||||
|
},
|
||||||
|
(error) => window.webContents.send('qwen-auth-error', error)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel ongoing auth
|
||||||
|
ipcMain.on('qwen-auth-cancel', () => {
|
||||||
|
qwenOAuth.cancelAuth();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Preload Updates
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// preload.js - add to existing
|
||||||
|
|
||||||
|
qwenAuth: {
|
||||||
|
start: () => ipcRenderer.send('qwen-auth-start'),
|
||||||
|
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
|
||||||
|
onProgress: (cb) => ipcRenderer.on('qwen-auth-progress', (_, data) => cb(data)),
|
||||||
|
onSuccess: (cb) => ipcRenderer.on('qwen-auth-success', (_, creds) => cb(creds)),
|
||||||
|
onError: (cb) => ipcRenderer.on('qwen-auth-error', (_, err) => cb(err)),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 UI Component: Inline Auth Dialog
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/components/QwenAuthDialog.tsx
|
||||||
|
|
||||||
|
export const QwenAuthDialog = ({ onComplete }: { onComplete: () => void }) => {
|
||||||
|
const [status, setStatus] = useState<'idle' | 'awaiting' | 'polling' | 'success' | 'error'>('idle');
|
||||||
|
const [authUrl, setAuthUrl] = useState('');
|
||||||
|
const [userCode, setUserCode] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.electron?.qwenAuth) return;
|
||||||
|
|
||||||
|
window.electron.qwenAuth.onProgress((data) => {
|
||||||
|
if (data.status === 'awaiting_auth') {
|
||||||
|
setStatus('awaiting');
|
||||||
|
setAuthUrl(data.url);
|
||||||
|
setUserCode(data.userCode);
|
||||||
|
} else if (data.status === 'polling') {
|
||||||
|
setStatus('polling');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron.qwenAuth.onSuccess(() => {
|
||||||
|
setStatus('success');
|
||||||
|
setTimeout(onComplete, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron.qwenAuth.onError((err) => {
|
||||||
|
setStatus('error');
|
||||||
|
setError(err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startAuth = () => {
|
||||||
|
setStatus('awaiting');
|
||||||
|
window.electron?.qwenAuth?.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-zinc-900 rounded-2xl p-8 max-w-md w-full border border-white/10">
|
||||||
|
{status === 'idle' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Connect to Qwen</h2>
|
||||||
|
<p className="text-zinc-400 mb-6">
|
||||||
|
Authenticate with your Qwen account to access AI models.
|
||||||
|
</p>
|
||||||
|
<button onClick={startAuth} className="w-full py-3 bg-primary text-black font-bold rounded-xl">
|
||||||
|
Sign in with Qwen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'awaiting' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Complete in Browser</h2>
|
||||||
|
<p className="text-zinc-400 mb-4">
|
||||||
|
A browser window should have opened. Enter this code:
|
||||||
|
</p>
|
||||||
|
<div className="bg-black p-4 rounded-xl text-center mb-4">
|
||||||
|
<span className="font-mono text-3xl text-primary">{userCode}</span>
|
||||||
|
</div>
|
||||||
|
<a href={authUrl} target="_blank" className="text-primary underline text-sm">
|
||||||
|
Click here if browser didn't open
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'polling' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold mb-4">Waiting for Authorization...</h2>
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full mx-auto" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold text-primary mb-4">✓ Connected!</h2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<>
|
||||||
|
<h2 className="text-2xl font-bold text-red-500 mb-4">Authentication Failed</h2>
|
||||||
|
<p className="text-zinc-400 mb-6">{error}</p>
|
||||||
|
<button onClick={startAuth} className="w-full py-3 bg-zinc-800 text-white font-bold rounded-xl">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Implementation Order
|
||||||
|
|
||||||
|
### Step 1: Foundation (Electron Main)
|
||||||
|
1. Create `userDataService.js` in `electron/`
|
||||||
|
2. Create `qwen-oauth.js` in `electron/`
|
||||||
|
3. Update `main.js` with new IPC handlers
|
||||||
|
4. Update `preload.js` with new bridges
|
||||||
|
|
||||||
|
### Step 2: User System (React)
|
||||||
|
1. Create `UserContext.tsx`
|
||||||
|
2. Create `LoginGate.tsx`
|
||||||
|
3. Create `UserOnboarding.tsx`
|
||||||
|
4. Create `SecretCodeReveal.tsx`
|
||||||
|
5. Wrap `App.tsx` with `LoginGate`
|
||||||
|
|
||||||
|
### Step 3: Data Migration
|
||||||
|
1. Migrate existing global data to first user
|
||||||
|
2. Update all file paths in services to use `userDataService`
|
||||||
|
|
||||||
|
### Step 4: Qwen OAuth UI
|
||||||
|
1. Create `QwenAuthDialog.tsx`
|
||||||
|
2. Update `AISettingsModal` to use inline auth
|
||||||
|
3. Remove references to external CLI
|
||||||
|
|
||||||
|
### Step 5: Logout & Cleanup
|
||||||
|
1. Add logout button to sidebar
|
||||||
|
2. Create cleanup dialog with explanation
|
||||||
|
3. Implement `cleanUserData()` function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `electron/main.js` | Add user session management, new IPC handlers |
|
||||||
|
| `electron/preload.js` | Expose user and auth bridges |
|
||||||
|
| `electron/qwen-api.js` | Load tokens from user-specific path |
|
||||||
|
| `src/App.tsx` | Wrap with LoginGate and UserContext |
|
||||||
|
| `src/orchestrator.ts` | Make project loading user-aware |
|
||||||
|
| `src/services/automationService.ts` | Update file paths |
|
||||||
|
| `src/components/LayoutComponents.tsx` | Add logout button, update auth UI |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
1. **Secret Code Storage**: Only SHA256 hash is stored; actual code never persisted
|
||||||
|
2. **Credential Isolation**: Each user's Qwen/Ollama tokens are in separate directories
|
||||||
|
3. **Clean Data**: Complete wipe of user-specific folder
|
||||||
|
4. **No Recovery**: By design, secret codes cannot be recovered (offline storage is essential)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estimated Effort
|
||||||
|
|
||||||
|
| Phase | Effort |
|
||||||
|
|-------|--------|
|
||||||
|
| Phase 1: User Identity | 4-6 hours |
|
||||||
|
| Phase 2: Data Isolation | 3-4 hours |
|
||||||
|
| Phase 3: Inline OAuth | 4-5 hours |
|
||||||
|
| Phase 4: Integration | 2-3 hours |
|
||||||
|
| **Total** | **13-18 hours** |
|
||||||
@@ -5,6 +5,7 @@ import { TabNav, StartView, PlanView, PreviewView, EditorView, DiscoverView, Com
|
|||||||
import { ViControlView } from './components/ViControlView';
|
import { ViControlView } from './components/ViControlView';
|
||||||
import { TabId, OrchestratorState, GlobalMode } from './types';
|
import { TabId, OrchestratorState, GlobalMode } from './types';
|
||||||
import { ErrorBoundary } from './ErrorBoundary';
|
import { ErrorBoundary } from './ErrorBoundary';
|
||||||
|
import { LoginGate } from './components/UserAuth';
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const { state } = useOrchestrator();
|
const { state } = useOrchestrator();
|
||||||
@@ -66,10 +67,13 @@ const MainLayout = () => {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<LoginGate>
|
||||||
<OrchestratorProvider>
|
<OrchestratorProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</OrchestratorProvider>
|
</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