Feature: Secret Key User System & Inline Qwen OAuth

Phase 1: User Authentication System
- Added user-data.js: Secret code generation, user creation, session management
- Added UserAuth.tsx: LoginGate, onboarding wizard, secret code reveal dialog
- Users get isolated environments (projects, chats, API keys)

Phase 2: Inline Qwen OAuth (No External CLI)
- Added qwen-oauth.js: Device Authorization Grant with PKCE
- Added QwenAuthDialog.tsx: Full inline auth flow with user code display
- Tokens saved per-user with legacy fallback

Phase 3: Integration
- Updated main.js with IPC handlers for user auth and Qwen OAuth
- Updated preload.js with electron.user and electron.qwenAuth bridges
- Wrapped App.tsx with LoginGate for authentication enforcement

Based on analysis of qwen-code repository OAuth implementation.
This commit is contained in:
Gemini AI
2025-12-20 18:31:50 +04:00
Unverified
parent f35f7bd6c5
commit b6f2c68243
9 changed files with 2863 additions and 5 deletions

View File

@@ -9,6 +9,10 @@ import * as viAutomation from './vi-automation.js';
import { execFile } from 'child_process';
import { promisify } from 'util';
// User Authentication & Qwen OAuth
import * as userData from './user-data.js';
import * as qwenOAuth from './qwen-oauth.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -703,4 +707,168 @@ ipcMain.handle('vi-open-browser', async (_, { url }) => {
return await viAutomation.openBrowser(url);
});
// ============================================
// USER AUTHENTICATION SYSTEM
// ============================================
// Get secret questions list
ipcMain.handle('user-get-secret-questions', () => {
return userData.SECRET_QUESTIONS;
});
// Create a new user account
ipcMain.handle('user-create', async (_, { displayName, questionId, answer }) => {
try {
const { user, secretCode } = userData.createUser(displayName, questionId, answer);
// Auto-start session for new user
const session = userData.startSession(user);
return { success: true, user, secretCode, session };
} catch (error) {
console.error('[UserAuth] Create user failed:', error);
return { success: false, error: error.message };
}
});
// Authenticate user with secret code
ipcMain.handle('user-login', async (_, { secretCode }) => {
try {
const user = userData.authenticateUser(secretCode);
if (user) {
const session = userData.startSession(user);
return { success: true, user, session };
}
return { success: false, error: 'Invalid secret code' };
} catch (error) {
console.error('[UserAuth] Login failed:', error);
return { success: false, error: error.message };
}
});
// Get current session
ipcMain.handle('user-get-session', () => {
return userData.getCurrentSession();
});
// Logout (end session)
ipcMain.handle('user-logout', async (_, { cleanData }) => {
try {
const session = userData.getCurrentSession();
if (cleanData && session?.userId) {
userData.cleanUserData(session.userId);
}
userData.endSession();
return { success: true };
} catch (error) {
console.error('[UserAuth] Logout failed:', error);
return { success: false, error: error.message };
}
});
// Get user statistics
ipcMain.handle('user-get-stats', async (_, { userId }) => {
try {
return userData.getUserStats(userId);
} catch (error) {
return { projectCount: 0, chatCount: 0, totalSizeBytes: 0, hasQwenTokens: false };
}
});
// Clean user data without logout
ipcMain.handle('user-clean-data', async (_, { userId }) => {
try {
userData.cleanUserData(userId);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// Get user's projects directory
ipcMain.handle('user-get-projects-dir', (_, { userId }) => {
return userData.getUserProjectsDir(userId);
});
// ============================================
// QWEN OAUTH (INLINE DEVICE FLOW)
// ============================================
// Start Qwen OAuth device flow
ipcMain.on('qwen-auth-start', async (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
if (!window || window.isDestroyed()) return;
const session = userData.getCurrentSession();
await qwenOAuth.startDeviceFlow(
// onProgress
(progress) => {
if (!window.isDestroyed()) {
window.webContents.send('qwen-auth-progress', progress);
}
},
// onSuccess
(credentials) => {
// Save tokens to user-specific location if logged in
if (session?.userId) {
qwenOAuth.saveUserTokens(session.userId, app.getPath('userData'), credentials);
} else {
// Fallback to legacy location for backward compatibility
qwenOAuth.saveLegacyTokens(credentials);
}
if (!window.isDestroyed()) {
window.webContents.send('qwen-auth-success', credentials);
}
},
// onError
(error) => {
if (!window.isDestroyed()) {
window.webContents.send('qwen-auth-error', error);
}
}
);
});
// Cancel ongoing Qwen OAuth
ipcMain.on('qwen-auth-cancel', () => {
qwenOAuth.cancelAuth();
});
// Get Qwen auth status for current user
ipcMain.handle('qwen-get-auth-status', () => {
const session = userData.getCurrentSession();
let tokens = null;
if (session?.userId) {
tokens = qwenOAuth.loadUserTokens(session.userId, app.getPath('userData'));
}
// Fallback to legacy tokens if not logged in or no user tokens
if (!tokens) {
tokens = qwenOAuth.loadLegacyTokens();
}
const isValid = tokens?.access_token &&
(!tokens.expiry_date || tokens.expiry_date > Date.now() + 30000);
return {
isAuthenticated: !!tokens?.access_token,
isValid,
expiresAt: tokens?.expiry_date || null
};
});
// Clear Qwen tokens for current user
ipcMain.handle('qwen-clear-tokens', () => {
const session = userData.getCurrentSession();
if (session?.userId) {
qwenOAuth.clearUserTokens(session.userId, app.getPath('userData'));
}
return { success: true };
});
console.log('Goose Ultra Electron Main Process Started');

View File

@@ -97,5 +97,64 @@ contextBridge.exposeInMainWorld('electron', {
getKeyStatus: () => ipcRenderer.invoke('ollama-get-key-status'),
saveKey: (key) => ipcRenderer.invoke('ollama-save-key', { key }),
getModels: () => ipcRenderer.invoke('ollama-get-models')
},
// ==========================================
// USER AUTHENTICATION SYSTEM
// ==========================================
user: {
// Get list of secret questions
getSecretQuestions: () => ipcRenderer.invoke('user-get-secret-questions'),
// Create a new account (returns { success, user, secretCode, session } or { success: false, error })
create: (displayName, questionId, answer) =>
ipcRenderer.invoke('user-create', { displayName, questionId, answer }),
// Login with secret code (returns { success, user, session } or { success: false, error })
login: (secretCode) => ipcRenderer.invoke('user-login', { secretCode }),
// Get current session (returns session object or null)
getSession: () => ipcRenderer.invoke('user-get-session'),
// Logout (optionally clean data)
logout: (cleanData = false) => ipcRenderer.invoke('user-logout', { cleanData }),
// Get user statistics
getStats: (userId) => ipcRenderer.invoke('user-get-stats', { userId }),
// Clean all user data (projects, chats, keys)
cleanData: (userId) => ipcRenderer.invoke('user-clean-data', { userId }),
// Get user's projects directory path
getProjectsDir: (userId) => ipcRenderer.invoke('user-get-projects-dir', { userId })
},
// ==========================================
// QWEN OAUTH (INLINE DEVICE FLOW)
// ==========================================
qwenAuth: {
// Start the device authorization flow (triggers browser open)
start: () => ipcRenderer.send('qwen-auth-start'),
// Cancel ongoing authorization
cancel: () => ipcRenderer.send('qwen-auth-cancel'),
// Get current auth status
getStatus: () => ipcRenderer.invoke('qwen-get-auth-status'),
// Clear saved tokens
clearTokens: () => ipcRenderer.invoke('qwen-clear-tokens'),
// Event listeners for auth flow
onProgress: (callback) => ipcRenderer.on('qwen-auth-progress', (_, data) => callback(data)),
onSuccess: (callback) => ipcRenderer.on('qwen-auth-success', (_, creds) => callback(creds)),
onError: (callback) => ipcRenderer.on('qwen-auth-error', (_, error) => callback(error)),
// Cleanup listeners
removeListeners: () => {
ipcRenderer.removeAllListeners('qwen-auth-progress');
ipcRenderer.removeAllListeners('qwen-auth-success');
ipcRenderer.removeAllListeners('qwen-auth-error');
}
}
});

View File

@@ -0,0 +1,360 @@
/**
* Qwen OAuth2 Device Flow for Goose Ultra
*
* Implements RFC 8628 OAuth 2.0 Device Authorization Grant
* with PKCE (Proof Key for Code Exchange)
*
* Based on: qwen-code/packages/core/src/qwen/qwenOAuth2.ts
* License: Apache-2.0 (Qwen)
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { shell } from 'electron';
// ===== OAUTH CONFIGURATION =====
const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56';
const QWEN_OAUTH_SCOPE = 'openid profile email model.completion';
const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
// ===== PKCE UTILITIES (RFC 7636) =====
/**
* Generate a random code verifier for PKCE
* @returns A random string of 43-128 characters
*/
export function generateCodeVerifier() {
return crypto.randomBytes(32).toString('base64url');
}
/**
* Generate a code challenge from a code verifier using SHA-256
* @param {string} codeVerifier
* @returns {string}
*/
export function generateCodeChallenge(codeVerifier) {
return crypto.createHash('sha256').update(codeVerifier).digest('base64url');
}
/**
* Generate PKCE code verifier and challenge pair
*/
export function generatePKCEPair() {
const code_verifier = generateCodeVerifier();
const code_challenge = generateCodeChallenge(code_verifier);
return { code_verifier, code_challenge };
}
// ===== HELPERS =====
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function objectToUrlEncoded(data) {
return Object.keys(data)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
.join('&');
}
// ===== CANCELLATION =====
let isCancelled = false;
export function cancelAuth() {
isCancelled = true;
}
// ===== MAIN DEVICE FLOW =====
/**
* Start the OAuth Device Authorization Flow
*
* @param {Function} onProgress - Callback for progress updates
* @param {Function} onSuccess - Callback with credentials on success
* @param {Function} onError - Callback with error message on failure
*/
export async function startDeviceFlow(onProgress, onSuccess, onError) {
isCancelled = false;
try {
// 1. Generate PKCE pair
const { code_verifier, code_challenge } = generatePKCEPair();
console.log('[QwenOAuth] Starting device flow with PKCE...');
// 2. Request device code
const deviceAuthBody = objectToUrlEncoded({
client_id: QWEN_OAUTH_CLIENT_ID,
scope: QWEN_OAUTH_SCOPE,
code_challenge,
code_challenge_method: 'S256'
});
const deviceAuthResponse = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'x-request-id': crypto.randomUUID()
},
body: deviceAuthBody
});
if (!deviceAuthResponse.ok) {
const errorText = await deviceAuthResponse.text();
throw new Error(`Device authorization failed: ${deviceAuthResponse.status} - ${errorText}`);
}
const deviceAuth = await deviceAuthResponse.json();
console.log('[QwenOAuth] Device auth response:', {
user_code: deviceAuth.user_code,
expires_in: deviceAuth.expires_in
});
if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) {
throw new Error('Invalid device authorization response');
}
// 3. Notify UI and open browser
onProgress({
status: 'awaiting_auth',
url: deviceAuth.verification_uri_complete,
userCode: deviceAuth.user_code,
expiresIn: deviceAuth.expires_in
});
// Auto-open browser
try {
await shell.openExternal(deviceAuth.verification_uri_complete);
} catch (e) {
console.warn('[QwenOAuth] Failed to open browser:', e.message);
}
// 4. Poll for token
let pollInterval = 2000; // 2 seconds
const maxAttempts = Math.ceil(deviceAuth.expires_in / (pollInterval / 1000));
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (isCancelled) {
onError('Authentication cancelled by user');
return;
}
await sleep(pollInterval);
const tokenBody = objectToUrlEncoded({
grant_type: QWEN_OAUTH_GRANT_TYPE,
client_id: QWEN_OAUTH_CLIENT_ID,
device_code: deviceAuth.device_code,
code_verifier
});
try {
const tokenResponse = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: tokenBody
});
const tokenData = await tokenResponse.json();
// Success case
if (tokenData.access_token) {
console.log('[QwenOAuth] Token obtained successfully!');
const credentials = {
access_token: tokenData.access_token,
refresh_token: tokenData.refresh_token || null,
token_type: tokenData.token_type || 'Bearer',
resource_url: tokenData.resource_url || null,
expiry_date: tokenData.expires_in
? Date.now() + (tokenData.expires_in * 1000)
: null
};
onSuccess(credentials);
return;
}
// Pending case (user hasn't authorized yet)
if (tokenData.error === 'authorization_pending') {
onProgress({
status: 'polling',
attempt: attempt + 1,
maxAttempts
});
continue;
}
// Slow down case
if (tokenData.error === 'slow_down') {
pollInterval = Math.min(pollInterval * 1.5, 10000);
console.log('[QwenOAuth] Server requested slow_down, interval now:', pollInterval);
continue;
}
// Access denied
if (tokenData.error === 'access_denied') {
onError('Access denied. Please try again.');
return;
}
// Other error
if (tokenData.error) {
onError(tokenData.error_description || tokenData.error);
return;
}
} catch (pollError) {
console.error('[QwenOAuth] Poll error:', pollError.message);
// Continue polling on network errors
}
}
// Timeout
onError('Authorization timed out. Please try again.');
} catch (error) {
console.error('[QwenOAuth] Device flow failed:', error);
onError(error.message);
}
}
/**
* Refresh an access token using a refresh token
*
* @param {string} refreshToken
* @returns {Promise<Object>} New credentials
*/
export async function refreshAccessToken(refreshToken) {
const body = objectToUrlEncoded({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: QWEN_OAUTH_CLIENT_ID
});
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} - ${errorText}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error_description || data.error);
}
return {
access_token: data.access_token,
refresh_token: data.refresh_token || refreshToken,
token_type: data.token_type || 'Bearer',
resource_url: data.resource_url || null,
expiry_date: data.expires_in ? Date.now() + (data.expires_in * 1000) : null
};
}
// ===== TOKEN PERSISTENCE (User-Isolated) =====
/**
* Get the token storage path for a specific user
* @param {string} userId
* @param {string} userDataPath - app.getPath('userData')
*/
export function getUserTokenPath(userId, userDataPath) {
return path.join(userDataPath, 'user_data', userId, 'qwen_tokens.json');
}
/**
* Save tokens for a specific user
*/
export async function saveUserTokens(userId, userDataPath, credentials) {
const tokenPath = getUserTokenPath(userId, userDataPath);
const dir = path.dirname(tokenPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(tokenPath, JSON.stringify(credentials, null, 2));
console.log('[QwenOAuth] Tokens saved for user:', userId);
}
/**
* Load tokens for a specific user
*/
export function loadUserTokens(userId, userDataPath) {
const tokenPath = getUserTokenPath(userId, userDataPath);
try {
if (fs.existsSync(tokenPath)) {
return JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
}
} catch (e) {
console.error('[QwenOAuth] Failed to load user tokens:', e.message);
}
return null;
}
/**
* Clear tokens for a specific user
*/
export function clearUserTokens(userId, userDataPath) {
const tokenPath = getUserTokenPath(userId, userDataPath);
try {
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath);
console.log('[QwenOAuth] Tokens cleared for user:', userId);
}
} catch (e) {
console.warn('[QwenOAuth] Failed to clear tokens:', e.message);
}
}
// ===== LEGACY SUPPORT (Global tokens for backward compatibility) =====
const LEGACY_TOKEN_PATH = path.join(os.homedir(), '.qwen', 'oauth_creds.json');
/**
* Load tokens from legacy location (used when no user session)
*/
export function loadLegacyTokens() {
try {
if (fs.existsSync(LEGACY_TOKEN_PATH)) {
return JSON.parse(fs.readFileSync(LEGACY_TOKEN_PATH, 'utf8'));
}
} catch (e) {
console.error('[QwenOAuth] Failed to load legacy tokens:', e.message);
}
return null;
}
/**
* Save tokens to legacy location (for backward compatibility)
*/
export function saveLegacyTokens(credentials) {
const dir = path.dirname(LEGACY_TOKEN_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(LEGACY_TOKEN_PATH, JSON.stringify(credentials, null, 2));
}

View File

@@ -0,0 +1,474 @@
/**
* User Data Service for Goose Ultra
*
* Manages user authentication, session, and data isolation.
* Each user has their own isolated environment with separate:
* - Projects
* - Chat history
* - API keys (Qwen, Ollama)
* - Custom personas
* - Settings
*/
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { app } from 'electron';
// ===== USER DATA STRUCTURE =====
/**
* @typedef {Object} GooseUser
* @property {string} userId - UUID
* @property {string} displayName - User's chosen display name
* @property {string} secretCodeHash - SHA256 hash of the secret code
* @property {string} secretQuestionId - ID of the secret question used
* @property {number} createdAt - Timestamp
* @property {number} lastLoginAt - Timestamp
*/
/**
* @typedef {Object} UserSession
* @property {string} userId
* @property {string} displayName
* @property {number} loginAt
*/
// ===== FILE PATHS =====
const getSystemDir = () => path.join(app.getPath('userData'), 'system');
const getUsersFile = () => path.join(getSystemDir(), 'users.json');
const getSessionFile = () => path.join(getSystemDir(), 'current_session.json');
const getUserDataDir = () => path.join(app.getPath('userData'), 'user_data');
// ===== SECRET QUESTIONS =====
export const SECRET_QUESTIONS = [
{ id: 'mother_maiden', question: "What is your mother's maiden name?" },
{ id: 'first_pet', question: "What was your first pet's name?" },
{ id: 'favorite_teacher', question: "What was your favorite teacher's name?" },
{ id: 'birth_city', question: "In what city were you born?" },
{ id: 'first_car', question: "What was the make of your first car?" },
{ id: 'childhood_nickname', question: "What was your childhood nickname?" },
{ id: 'custom', question: "Custom question (user-defined)" }
];
// ===== SECRET CODE GENERATION =====
/**
* Generate a unique secret code for a new user
* Format: GU-XXXX-XXXX-XXXX (16 alphanumeric chars)
*
* @param {string} displayName
* @param {string} questionId
* @param {string} answer
* @returns {string} The secret code
*/
export function generateSecretCode(displayName, questionId, answer) {
const timestamp = Date.now().toString();
const salt = crypto.randomBytes(16).toString('hex');
const raw = `${displayName}|${questionId}|${answer}|${timestamp}|${salt}`;
// Create a hash and take 12 bytes
const hash = crypto.createHash('sha256').update(raw).digest();
const encoded = hash.slice(0, 12).toString('base64url').toUpperCase();
// Format as GU-XXXX-XXXX-XXXX
const formatted = `GU-${encoded.slice(0, 4)}-${encoded.slice(4, 8)}-${encoded.slice(8, 12)}`;
return formatted;
}
/**
* Hash a secret code for secure storage
* @param {string} secretCode
* @returns {string} SHA256 hash
*/
export function hashSecretCode(secretCode) {
// Normalize the code (remove dashes, uppercase)
const normalized = secretCode.replace(/-/g, '').toUpperCase();
return crypto.createHash('sha256').update(normalized).digest('hex');
}
// ===== USER MANAGEMENT =====
/**
* Ensure system directories exist
*/
function ensureSystemDirs() {
const systemDir = getSystemDir();
const userDataDir = getUserDataDir();
if (!fs.existsSync(systemDir)) {
fs.mkdirSync(systemDir, { recursive: true });
}
if (!fs.existsSync(userDataDir)) {
fs.mkdirSync(userDataDir, { recursive: true });
}
}
/**
* Load all registered users
* @returns {GooseUser[]}
*/
export function loadUsers() {
ensureSystemDirs();
try {
if (fs.existsSync(getUsersFile())) {
return JSON.parse(fs.readFileSync(getUsersFile(), 'utf8'));
}
} catch (e) {
console.error('[UserData] Failed to load users:', e.message);
}
return [];
}
/**
* Save users list
* @param {GooseUser[]} users
*/
function saveUsers(users) {
ensureSystemDirs();
fs.writeFileSync(getUsersFile(), JSON.stringify(users, null, 2));
}
/**
* Create a new user account
*
* @param {string} displayName
* @param {string} questionId
* @param {string} answer
* @returns {{ user: GooseUser, secretCode: string }}
*/
export function createUser(displayName, questionId, answer) {
ensureSystemDirs();
const userId = crypto.randomUUID();
const secretCode = generateSecretCode(displayName, questionId, answer);
const secretCodeHash = hashSecretCode(secretCode);
const now = Date.now();
const user = {
userId,
displayName,
secretCodeHash,
secretQuestionId: questionId,
createdAt: now,
lastLoginAt: now
};
// Add to users list
const users = loadUsers();
users.push(user);
saveUsers(users);
// Create user's data directory
const userDir = path.join(getUserDataDir(), userId);
fs.mkdirSync(userDir, { recursive: true });
// Create subdirectories
fs.mkdirSync(path.join(userDir, 'projects'), { recursive: true });
fs.mkdirSync(path.join(userDir, 'chats'), { recursive: true });
fs.mkdirSync(path.join(userDir, 'vault'), { recursive: true });
// Initialize settings
const defaultSettings = {
preferredFramework: null,
chatPersona: 'assistant',
theme: 'dark',
createdAt: now
};
fs.writeFileSync(
path.join(userDir, 'settings.json'),
JSON.stringify(defaultSettings, null, 2)
);
console.log('[UserData] Created new user:', userId, displayName);
return { user, secretCode };
}
/**
* Authenticate a user with their secret code
*
* @param {string} secretCode
* @returns {GooseUser | null}
*/
export function authenticateUser(secretCode) {
const hash = hashSecretCode(secretCode);
const users = loadUsers();
const user = users.find(u => u.secretCodeHash === hash);
if (user) {
// Update last login
user.lastLoginAt = Date.now();
saveUsers(users);
console.log('[UserData] User authenticated:', user.userId);
return user;
}
console.log('[UserData] Authentication failed: invalid secret code');
return null;
}
// ===== SESSION MANAGEMENT =====
/**
* Start a user session
* @param {GooseUser} user
*/
export function startSession(user) {
const session = {
userId: user.userId,
displayName: user.displayName,
loginAt: Date.now()
};
ensureSystemDirs();
fs.writeFileSync(getSessionFile(), JSON.stringify(session, null, 2));
console.log('[UserData] Session started for:', user.displayName);
return session;
}
/**
* Get the current active session
* @returns {UserSession | null}
*/
export function getCurrentSession() {
try {
if (fs.existsSync(getSessionFile())) {
return JSON.parse(fs.readFileSync(getSessionFile(), 'utf8'));
}
} catch (e) {
console.error('[UserData] Failed to load session:', e.message);
}
return null;
}
/**
* End the current session (logout)
*/
export function endSession() {
try {
if (fs.existsSync(getSessionFile())) {
fs.unlinkSync(getSessionFile());
console.log('[UserData] Session ended');
}
} catch (e) {
console.error('[UserData] Failed to end session:', e.message);
}
}
// ===== USER DATA PATHS =====
/**
* Get the data directory for a specific user
* @param {string} userId
*/
export function getUserDirectory(userId) {
return path.join(getUserDataDir(), userId);
}
/**
* Get the projects directory for a user
* @param {string} userId
*/
export function getUserProjectsDir(userId) {
return path.join(getUserDirectory(userId), 'projects');
}
/**
* Get the chats directory for a user
* @param {string} userId
*/
export function getUserChatsDir(userId) {
return path.join(getUserDirectory(userId), 'chats');
}
/**
* Get the vault directory for a user
* @param {string} userId
*/
export function getUserVaultDir(userId) {
return path.join(getUserDirectory(userId), 'vault');
}
/**
* Get user settings path
* @param {string} userId
*/
export function getUserSettingsPath(userId) {
return path.join(getUserDirectory(userId), 'settings.json');
}
// ===== DATA CLEANUP =====
/**
* Clean all data for a specific user
* This removes:
* - All projects
* - All chats
* - All saved credentials
* - Custom personas
* - Settings
*
* Note: The user account itself remains intact
*
* @param {string} userId
*/
export function cleanUserData(userId) {
const userDir = getUserDirectory(userId);
if (!fs.existsSync(userDir)) {
console.log('[UserData] No data to clean for user:', userId);
return;
}
// Remove all contents but keep the directory structure
const removeContents = (dir) => {
if (!fs.existsSync(dir)) return;
const items = fs.readdirSync(dir);
for (const item of items) {
const itemPath = path.join(dir, item);
const stat = fs.statSync(itemPath);
if (stat.isDirectory()) {
fs.rmSync(itemPath, { recursive: true, force: true });
} else {
fs.unlinkSync(itemPath);
}
}
};
// Clean each subdirectory
removeContents(path.join(userDir, 'projects'));
removeContents(path.join(userDir, 'chats'));
removeContents(path.join(userDir, 'vault'));
// Reset settings to default
const defaultSettings = {
preferredFramework: null,
chatPersona: 'assistant',
theme: 'dark',
createdAt: Date.now(),
cleanedAt: Date.now()
};
try {
fs.writeFileSync(
path.join(userDir, 'settings.json'),
JSON.stringify(defaultSettings, null, 2)
);
} catch (e) {
console.error('[UserData] Failed to reset settings:', e.message);
}
// Remove Qwen tokens
const tokenPath = path.join(userDir, 'qwen_tokens.json');
if (fs.existsSync(tokenPath)) {
fs.unlinkSync(tokenPath);
}
console.log('[UserData] Cleaned all data for user:', userId);
}
/**
* Delete a user account completely
* @param {string} userId
*/
export function deleteUser(userId) {
// Remove user data
const userDir = getUserDirectory(userId);
if (fs.existsSync(userDir)) {
fs.rmSync(userDir, { recursive: true, force: true });
}
// Remove from users list
let users = loadUsers();
users = users.filter(u => u.userId !== userId);
saveUsers(users);
console.log('[UserData] Deleted user:', userId);
}
// ===== MIGRATION =====
/**
* Migrate legacy global data to a user's isolated environment
* This is called when:
* 1. First user is created and old data exists
* 2. Explicitly requested by user
*
* @param {string} userId
* @param {string} legacyProjectsDir - Old global projects directory
*/
export function migrateGlobalDataToUser(userId, legacyProjectsDir) {
const userProjectsDir = getUserProjectsDir(userId);
if (!fs.existsSync(legacyProjectsDir)) {
console.log('[UserData] No legacy data to migrate');
return;
}
// Copy all projects
const projects = fs.readdirSync(legacyProjectsDir);
for (const project of projects) {
const src = path.join(legacyProjectsDir, project);
const dest = path.join(userProjectsDir, project);
if (fs.statSync(src).isDirectory()) {
fs.cpSync(src, dest, { recursive: true });
}
}
console.log('[UserData] Migrated', projects.length, 'projects to user:', userId);
}
// ===== STATISTICS =====
/**
* Get statistics about a user's data
* @param {string} userId
*/
export function getUserStats(userId) {
const userDir = getUserDirectory(userId);
const countItems = (dir) => {
try {
return fs.existsSync(dir) ? fs.readdirSync(dir).length : 0;
} catch {
return 0;
}
};
const getDirSize = (dir) => {
if (!fs.existsSync(dir)) return 0;
let size = 0;
const items = fs.readdirSync(dir, { withFileTypes: true });
for (const item of items) {
const itemPath = path.join(dir, item.name);
if (item.isDirectory()) {
size += getDirSize(itemPath);
} else {
size += fs.statSync(itemPath).size;
}
}
return size;
};
return {
projectCount: countItems(getUserProjectsDir(userId)),
chatCount: countItems(getUserChatsDir(userId)),
totalSizeBytes: getDirSize(userDir),
hasQwenTokens: fs.existsSync(path.join(userDir, 'qwen_tokens.json'))
};
}