diff --git a/App.tsx b/App.tsx index 0d43b03..d085c3b 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { GitHubUser, Repository, Issue, AppRoute } from './types'; +import { GitHubUser, Repository, Issue, AppRoute, Account } from './types'; import { TokenGate } from './views/TokenGate'; import { Dashboard } from './views/Dashboard'; import { RepoDetail } from './views/RepoDetail'; @@ -7,16 +7,29 @@ import { IssueDetail } from './views/IssueDetail'; import { signOutFromFirebase, handleRedirectResult } from './services/firebaseService'; import { validateToken } from './services/githubService'; import { ThemeProvider } from './contexts/ThemeContext'; +import { + getAllAccounts, + addAccount, + removeAccount, + getActiveAccount, + setActiveAccount, + migrateLegacyAccount, + clearAllAccounts, +} from './services/accountService'; +import { clearCache } from './services/cacheService'; const App: React.FC = () => { - const [token, setToken] = useState(localStorage.getItem('gh_token')); - const [user, setUser] = useState( - localStorage.getItem('gh_user') ? JSON.parse(localStorage.getItem('gh_user')!) : null - ); + // Migrate legacy single account on first load + useEffect(() => { + migrateLegacyAccount(); + }, []); + + const [accounts, setAccounts] = useState(() => getAllAccounts()); + const [activeAccount, setActiveAccountState] = useState(() => getActiveAccount()); const [checkingRedirect, setCheckingRedirect] = useState(true); const [currentRoute, setCurrentRoute] = useState( - token && user ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT + activeAccount ? AppRoute.REPO_LIST : AppRoute.TOKEN_INPUT ); const [selectedRepo, setSelectedRepo] = useState(null); const [selectedIssue, setSelectedIssue] = useState(null); @@ -42,13 +55,41 @@ const App: React.FC = () => { }, []); const handleLogin = (newToken: string, newUser: GitHubUser) => { - setToken(newToken); - setUser(newUser); - localStorage.setItem('gh_token', newToken); - localStorage.setItem('gh_user', JSON.stringify(newUser)); + const account = addAccount(newToken, newUser); + setActiveAccount(account.id); + setAccounts(getAllAccounts()); + setActiveAccountState(account); setCurrentRoute(AppRoute.REPO_LIST); }; + const handleSwitchAccount = (accountId: string) => { + setActiveAccount(accountId); + const account = getActiveAccount(); + setActiveAccountState(account); + setCurrentRoute(AppRoute.REPO_LIST); + // Reset navigation state when switching accounts + setSelectedRepo(null); + setSelectedIssue(null); + }; + + const handleRemoveAccount = (accountId: string) => { + // Clear all cached data for this account + clearCache(undefined, accountId); + + removeAccount(accountId); + const updatedAccounts = getAllAccounts(); + setAccounts(updatedAccounts); + + // If we removed the active account, switch to another or logout + if (activeAccount?.id === accountId) { + if (updatedAccounts.length > 0) { + handleSwitchAccount(updatedAccounts[0].id); + } else { + handleLogout(); + } + } + }; + const handleLogout = async () => { // Sign out from Firebase try { @@ -57,10 +98,12 @@ const App: React.FC = () => { console.error('Firebase sign out error:', err); } - setToken(null); - setUser(null); - localStorage.removeItem('gh_token'); - localStorage.removeItem('gh_user'); + // Clear all accounts and cached data + clearAllAccounts(); + clearCache(); + + setAccounts([]); + setActiveAccountState(null); setCurrentRoute(AppRoute.TOKEN_INPUT); setSelectedRepo(null); }; @@ -95,14 +138,14 @@ const App: React.FC = () => { ); } - if (currentRoute === AppRoute.TOKEN_INPUT || !token || !user) { + if (currentRoute === AppRoute.TOKEN_INPUT || !activeAccount) { return ; } if (currentRoute === AppRoute.ISSUE_DETAIL && selectedRepo && selectedIssue) { return ( { if (currentRoute === AppRoute.REPO_DETAIL && selectedRepo) { return ( { return ( setCurrentRoute(AppRoute.TOKEN_INPUT)} + onRemoveAccount={handleRemoveAccount} /> ); }; diff --git a/components/AccountSwitcher.tsx b/components/AccountSwitcher.tsx new file mode 100644 index 0000000..6920082 --- /dev/null +++ b/components/AccountSwitcher.tsx @@ -0,0 +1,133 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Account } from '../types'; +import { ChevronDown, UserPlus, LogOut, Check } from 'lucide-react'; + +interface AccountSwitcherProps { + accounts: Account[]; + activeAccount: Account; + onSwitchAccount: (accountId: string) => void; + onAddAccount: () => void; + onRemoveAccount: (accountId: string) => void; +} + +export const AccountSwitcher: React.FC = ({ + accounts, + activeAccount, + onSwitchAccount, + onAddAccount, + onRemoveAccount, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSwitchAccount = (accountId: string) => { + onSwitchAccount(accountId); + setIsOpen(false); + }; + + const handleRemoveAccount = (e: React.MouseEvent, accountId: string) => { + e.stopPropagation(); + onRemoveAccount(accountId); + // Don't close the dropdown if there are still accounts + if (accounts.length <= 1) { + setIsOpen(false); + } + }; + + return ( +
+ + + {isOpen && ( +
+ {/* Account List */} +
+
+ ACCOUNTS +
+ {accounts.map((account) => ( +
+
handleSwitchAccount(account.id)} + className="flex items-center gap-2 flex-1" + > + {account.user.login} +
+
+ {account.user.name || account.user.login} +
+
+ @{account.user.login} +
+
+ {account.id === activeAccount.id && ( + + )} +
+ {accounts.length > 1 && ( + + )} +
+ ))} +
+ + {/* Add Account */} +
+ +
+
+ )} +
+ ); +}; diff --git a/services/accountService.ts b/services/accountService.ts new file mode 100644 index 0000000..0ac7222 --- /dev/null +++ b/services/accountService.ts @@ -0,0 +1,124 @@ +import { Account, GitHubUser } from '../types'; + +const ACCOUNTS_KEY = 'gh_accounts'; +const ACTIVE_ACCOUNT_KEY = 'gh_active_account_id'; + +/** + * Get all stored accounts + */ +export function getAllAccounts(): Account[] { + try { + const raw = localStorage.getItem(ACCOUNTS_KEY); + if (!raw) return []; + return JSON.parse(raw); + } catch { + return []; + } +} + +/** + * Add a new account or update existing one + */ +export function addAccount(token: string, user: GitHubUser): Account { + const accounts = getAllAccounts(); + const accountId = user.login; + + // Check if account already exists + const existingIndex = accounts.findIndex(acc => acc.id === accountId); + + const account: Account = { + id: accountId, + token, + user, + addedAt: Date.now(), + }; + + if (existingIndex >= 0) { + // Update existing account + accounts[existingIndex] = account; + } else { + // Add new account + accounts.push(account); + } + + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); + return account; +} + +/** + * Remove an account + */ +export function removeAccount(accountId: string): void { + const accounts = getAllAccounts(); + const filtered = accounts.filter(acc => acc.id !== accountId); + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(filtered)); + + // If the removed account was active, clear active account + const activeId = getActiveAccountId(); + if (activeId === accountId) { + localStorage.removeItem(ACTIVE_ACCOUNT_KEY); + } +} + +/** + * Get the active account ID + */ +export function getActiveAccountId(): string | null { + return localStorage.getItem(ACTIVE_ACCOUNT_KEY); +} + +/** + * Set the active account + */ +export function setActiveAccount(accountId: string): void { + localStorage.setItem(ACTIVE_ACCOUNT_KEY, accountId); +} + +/** + * Get the active account + */ +export function getActiveAccount(): Account | null { + const activeId = getActiveAccountId(); + if (!activeId) return null; + + const accounts = getAllAccounts(); + return accounts.find(acc => acc.id === activeId) || null; +} + +/** + * Clear all accounts (for logout all) + */ +export function clearAllAccounts(): void { + localStorage.removeItem(ACCOUNTS_KEY); + localStorage.removeItem(ACTIVE_ACCOUNT_KEY); +} + +/** + * Migrate legacy single account to new multi-account system + * This ensures users who were already logged in don't lose their session + */ +export function migrateLegacyAccount(): void { + // Check if we already have accounts + const accounts = getAllAccounts(); + if (accounts.length > 0) { + return; // Already migrated or has accounts + } + + // Check for legacy single account + const legacyToken = localStorage.getItem('gh_token'); + const legacyUserRaw = localStorage.getItem('gh_user'); + + if (legacyToken && legacyUserRaw) { + try { + const legacyUser = JSON.parse(legacyUserRaw); + const account = addAccount(legacyToken, legacyUser); + setActiveAccount(account.id); + + // Clean up legacy storage + localStorage.removeItem('gh_token'); + localStorage.removeItem('gh_user'); + } catch { + // If migration fails, just ignore + } + } +} diff --git a/services/cacheService.ts b/services/cacheService.ts index d862ba9..abccd0d 100644 --- a/services/cacheService.ts +++ b/services/cacheService.ts @@ -4,6 +4,12 @@ const CACHE_PREFIX = 'vibe_github_cache_'; const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes - data considered "fresh" within this time +// Get the current account ID for cache scoping +function getCurrentAccountId(): string { + const activeAccountId = localStorage.getItem('gh_active_account_id'); + return activeAccountId || '__no_account__'; +} + interface CacheEntry { data: T; timestamp: number; @@ -11,7 +17,9 @@ interface CacheEntry { export function getCached(key: string): T | null { try { - const raw = localStorage.getItem(CACHE_PREFIX + key); + const accountId = getCurrentAccountId(); + const scopedKey = `${accountId}_${key}`; + const raw = localStorage.getItem(CACHE_PREFIX + scopedKey); if (!raw) return null; const entry: CacheEntry = JSON.parse(raw); return entry.data; @@ -22,11 +30,13 @@ export function getCached(key: string): T | null { export function setCache(key: string, data: T): void { try { + const accountId = getCurrentAccountId(); + const scopedKey = `${accountId}_${key}`; const entry: CacheEntry = { data, timestamp: Date.now(), }; - localStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry)); + localStorage.setItem(CACHE_PREFIX + scopedKey, JSON.stringify(entry)); } catch { // Ignore storage errors (quota exceeded, etc.) } @@ -34,7 +44,9 @@ export function setCache(key: string, data: T): void { export function isCacheFresh(key: string, ttl = DEFAULT_TTL): boolean { try { - const raw = localStorage.getItem(CACHE_PREFIX + key); + const accountId = getCurrentAccountId(); + const scopedKey = `${accountId}_${key}`; + const raw = localStorage.getItem(CACHE_PREFIX + scopedKey); if (!raw) return false; const entry = JSON.parse(raw); return Date.now() - entry.timestamp < ttl; @@ -43,9 +55,27 @@ export function isCacheFresh(key: string, ttl = DEFAULT_TTL): boolean { } } -export function clearCache(key?: string): void { - if (key) { - localStorage.removeItem(CACHE_PREFIX + key); +export function clearCache(key?: string, accountId?: string): void { + if (key && accountId) { + // Clear specific key for specific account + const scopedKey = `${accountId}_${key}`; + localStorage.removeItem(CACHE_PREFIX + scopedKey); + } else if (key) { + // Clear specific key for current account + const accId = getCurrentAccountId(); + const scopedKey = `${accId}_${key}`; + localStorage.removeItem(CACHE_PREFIX + scopedKey); + } else if (accountId) { + // Clear all keys for specific account + const prefix = CACHE_PREFIX + accountId + '_'; + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith(prefix)) { + keysToRemove.push(k); + } + } + keysToRemove.forEach(k => localStorage.removeItem(k)); } else { // Clear all cache entries const keysToRemove: string[] = []; diff --git a/types.ts b/types.ts index bcccf3d..efe2f6c 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,13 @@ export interface GitHubUser { name: string; } +export interface Account { + id: string; + token: string; + user: GitHubUser; + addedAt: number; +} + export interface Repository { id: number; name: string; diff --git a/views/Dashboard.tsx b/views/Dashboard.tsx index af3d4d0..ab8dd06 100644 --- a/views/Dashboard.tsx +++ b/views/Dashboard.tsx @@ -1,21 +1,37 @@ import React, { useEffect, useState, useRef } from 'react'; -import { Repository, GitHubUser, RepoDraft, Issue } from '../types'; +import { Repository, GitHubUser, RepoDraft, Issue, Account } from '../types'; import { fetchRepositories, createRepository, deleteRepository, setRepositorySecret } from '../services/githubService'; import { RepoCard } from '../components/RepoCard'; import { Button } from '../components/Button'; import { ToastContainer, useToast } from '../components/Toast'; import { ThemeToggle } from '../components/ThemeToggle'; +import { AccountSwitcher } from '../components/AccountSwitcher'; import { LogOut, RefreshCw, Plus, X, Lock, Globe, AlertTriangle, Key } from 'lucide-react'; import { getCached, setCache, CacheKeys } from '../services/cacheService'; interface DashboardProps { token: string; user: GitHubUser; + accounts: Account[]; + activeAccount: Account; onRepoSelect: (repo: Repository) => void; onLogout: () => void | Promise; + onSwitchAccount: (accountId: string) => void; + onAddAccount: () => void; + onRemoveAccount: (accountId: string) => void; } -export const Dashboard: React.FC = ({ token, user, onRepoSelect, onLogout }) => { +export const Dashboard: React.FC = ({ + token, + user, + accounts, + activeAccount, + onRepoSelect, + onLogout, + onSwitchAccount, + onAddAccount, + onRemoveAccount, +}) => { const { toasts, dismissToast, showError } = useToast(); // Initialize from cache for instant display @@ -222,14 +238,17 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, {/* Header */}
-
- {user.login} - {user.login} -
+