From ff979be9923f381d00475003abac7d19c4fced25 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 09:41:46 +0000 Subject: [PATCH] feat: Add support for multiple accounts This commit introduces support for multiple GitHub accounts. Key changes: - Refactored the application state to manage a list of accounts instead of a single user. - Implemented an account switcher UI in the dashboard to allow users to switch between accounts, add new accounts, and log out. - Updated the login flow to support adding new accounts. - Ensured that user-specific data, such as pinned repositories and API caches, is stored on a per-account basis. --- App.tsx | 100 +++++++++++++++++------ types.ts | 5 ++ views/Dashboard.tsx | 189 ++++++++++++++++++++++++-------------------- views/TokenGate.tsx | 52 +++++++----- 4 files changed, 216 insertions(+), 130 deletions(-) diff --git a/App.tsx b/App.tsx index 0d43b03..b9a17d6 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'; @@ -9,25 +9,44 @@ import { validateToken } from './services/githubService'; import { ThemeProvider } from './contexts/ThemeContext'; 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 - ); + const [accounts, setAccounts] = useState(() => { + const savedAccounts = localStorage.getItem('gh_accounts'); + return savedAccounts ? JSON.parse(savedAccounts) : []; + }); + + const [activeAccount, setActiveAccount] = useState(() => { + const savedAccounts = localStorage.getItem('gh_accounts'); + if (!savedAccounts) return null; + const accountsList: Account[] = JSON.parse(savedAccounts); + const activeLogin = localStorage.getItem('gh_active_account_login'); + if (activeLogin) { + return accountsList.find(acc => acc.user.login === activeLogin) || accountsList[0] || null; + } + return accountsList[0] || null; + }); + 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); - // Handle redirect result from Firebase OAuth (for popup-blocked fallback) + useEffect(() => { + localStorage.setItem('gh_accounts', JSON.stringify(accounts)); + if (activeAccount) { + localStorage.setItem('gh_active_account_login', activeAccount.user.login); + } else { + localStorage.removeItem('gh_active_account_login'); + } + }, [accounts, activeAccount]); + useEffect(() => { const checkRedirectResult = async () => { try { const result = await handleRedirectResult(); if (result) { - // Validate token and get user data from GitHub API const ghUser = await validateToken(result.accessToken); handleLogin(result.accessToken, ghUser); } @@ -42,29 +61,59 @@ 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 newAccount: Account = { user: newUser, token: newToken }; + + setAccounts(prevAccounts => { + const existingAccountIndex = prevAccounts.findIndex(acc => acc.user.login === newUser.login); + if (existingAccountIndex > -1) { + const updatedAccounts = [...prevAccounts]; + updatedAccounts[existingAccountIndex] = newAccount; + return updatedAccounts; + } else { + return [...prevAccounts, newAccount]; + } + }); + + setActiveAccount(newAccount); setCurrentRoute(AppRoute.REPO_LIST); }; const handleLogout = async () => { - // Sign out from Firebase + if (!activeAccount) return; + try { await signOutFromFirebase(); } catch (err) { console.error('Firebase sign out error:', err); } - setToken(null); - setUser(null); - localStorage.removeItem('gh_token'); - localStorage.removeItem('gh_user'); - setCurrentRoute(AppRoute.TOKEN_INPUT); + const newAccounts = accounts.filter(acc => acc.user.login !== activeAccount.user.login); + setAccounts(newAccounts); + + if (newAccounts.length > 0) { + setActiveAccount(newAccounts[0]); + } else { + setActiveAccount(null); + setCurrentRoute(AppRoute.TOKEN_INPUT); + } + setSelectedRepo(null); }; + const switchAccount = (login: string) => { + const accountToSwitch = accounts.find(acc => acc.user.login === login); + if (accountToSwitch) { + setActiveAccount(accountToSwitch); + setSelectedRepo(null); + setSelectedIssue(null); + setCurrentRoute(AppRoute.REPO_LIST); + } + }; + + const addNewAccount = () => { + setCurrentRoute(AppRoute.TOKEN_INPUT); + }; + const navigateToRepo = (repo: Repository) => { setSelectedRepo(repo); setCurrentRoute(AppRoute.REPO_DETAIL); @@ -86,7 +135,6 @@ const App: React.FC = () => { setCurrentRoute(AppRoute.REPO_DETAIL); }; - // Render Logic if (checkingRedirect) { return (
@@ -95,14 +143,14 @@ const App: React.FC = () => { ); } - if (currentRoute === AppRoute.TOKEN_INPUT || !token || !user) { - return ; + if (currentRoute === AppRoute.TOKEN_INPUT || !activeAccount) { + return 0} onBack={() => setCurrentRoute(AppRoute.REPO_LIST)} />; } if (currentRoute === AppRoute.ISSUE_DETAIL && selectedRepo && selectedIssue) { return ( { if (currentRoute === AppRoute.REPO_DETAIL && selectedRepo) { return ( { return ( ); }; diff --git a/types.ts b/types.ts index bcccf3d..0886488 100644 --- a/types.ts +++ b/types.ts @@ -4,6 +4,11 @@ export interface GitHubUser { name: string; } +export interface Account { + user: GitHubUser; + token: string; +} + export interface Repository { id: number; name: string; diff --git a/views/Dashboard.tsx b/views/Dashboard.tsx index af3d4d0..2833903 100644 --- a/views/Dashboard.tsx +++ b/views/Dashboard.tsx @@ -1,89 +1,90 @@ import React, { useEffect, useState, useRef } from 'react'; -import { Repository, GitHubUser, RepoDraft, Issue } from '../types'; +import { Repository, 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 { LogOut, RefreshCw, Plus, X, Lock, Globe, AlertTriangle, Key } from 'lucide-react'; +import { LogOut, RefreshCw, Plus, X, Lock, Globe, AlertTriangle, Key, UserPlus, Users, Check, ChevronDown } from 'lucide-react'; import { getCached, setCache, CacheKeys } from '../services/cacheService'; interface DashboardProps { - token: string; - user: GitHubUser; + activeAccount: Account; + accounts: Account[]; onRepoSelect: (repo: Repository) => void; onLogout: () => void | Promise; + onSwitchAccount: (login: string) => void; + onAddNewAccount: () => void; } -export const Dashboard: React.FC = ({ token, user, onRepoSelect, onLogout }) => { +export const Dashboard: React.FC = ({ + activeAccount, + accounts, + onRepoSelect, + onLogout, + onSwitchAccount, + onAddNewAccount +}) => { const { toasts, dismissToast, showError } = useToast(); - - // Initialize from cache for instant display - const [repos, setRepos] = useState(() => { - return getCached(CacheKeys.repos()) || []; - }); - const [loading, setLoading] = useState(() => { - // Only show loading if no cached data - return !getCached(CacheKeys.repos()); - }); + const token = activeAccount.token; + const user = activeAccount.user; + + const [repos, setRepos] = useState([]); + const [loading, setLoading] = useState(true); const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(''); const [pinnedRepoIds, setPinnedRepoIds] = useState>(() => { - const saved = localStorage.getItem('pinnedRepos'); + const saved = localStorage.getItem(`pinnedRepos_${user.login}`); return saved ? new Set(JSON.parse(saved)) : new Set(); }); - // Initialize issues from cache for instant display - const [repoIssues, setRepoIssues] = useState>(() => { - const cachedRepos = getCached(CacheKeys.repos()); - if (!cachedRepos) return {}; - - const issuesMap: Record = {}; - for (const repo of cachedRepos.slice(0, 4)) { - const cachedIssues = getCached(CacheKeys.repoIssues(repo.owner.login, repo.name)); - if (cachedIssues) { - issuesMap[repo.id] = cachedIssues.filter(issue => !issue.pull_request).slice(0, 3); - } - } - return issuesMap; - }); - const isInitialMount = useRef(true); + const [repoIssues, setRepoIssues] = useState>({}); - // Create Repo Modal State const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isCreating, setIsCreating] = useState(false); - const [newRepo, setNewRepo] = useState({ - name: '', - description: '', - private: false, - auto_init: true - }); + const [newRepo, setNewRepo] = useState({ name: '', description: '', private: false, auto_init: true }); const [autoSetOAuthToken, setAutoSetOAuthToken] = useState(true); - // Delete Repo Modal State const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [repoToDelete, setRepoToDelete] = useState(null); - // Close modals on Escape key + const [isAccountSwitcherOpen, setAccountSwitcherOpen] = useState(false); + const accountSwitcherRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (accountSwitcherRef.current && !accountSwitcherRef.current.contains(event.target as Node)) { + setAccountSwitcherOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (isDeleteModalOpen) closeDeleteModal(); if (isCreateModalOpen) setIsCreateModalOpen(false); + if (isAccountSwitcherOpen) setAccountSwitcherOpen(false); } }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); - }, [isDeleteModalOpen, isCreateModalOpen]); + }, [isDeleteModalOpen, isCreateModalOpen, isAccountSwitcherOpen]); const loadRepos = React.useCallback(async (isManualRefresh = false) => { - const hasCachedData = repos.length > 0; + const cacheKey = CacheKeys.repos(user.login); + const cachedRepos = getCached(cacheKey); - // Show full loading only on first load with no cache - // Otherwise show subtle refresh indicator - if (!hasCachedData) { + if (cachedRepos) { + setRepos(cachedRepos); + setLoading(false); + } else { setLoading(true); - } else if (isManualRefresh) { + } + + if (isManualRefresh) { setIsRefreshing(true); } @@ -91,42 +92,35 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, try { const data = await fetchRepositories(token); setRepos(data); - // Cache the repos for instant display on next visit - setCache(CacheKeys.repos(), data); + setCache(cacheKey, data); - // Load issues for first 4 repos - reuse cache when available const reposToShow = data.slice(0, 4); const issuesMap: Record = {}; for (const repo of reposToShow) { - const cacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name); - const cachedIssues = getCached(cacheKey); - + const issuesCacheKey = CacheKeys.repoIssues(repo.owner.login, repo.name); + const cachedIssues = getCached(issuesCacheKey); if (cachedIssues) { - // Reuse cached issues - filter to actual issues (not PRs) and take first 3 - const actualIssues = cachedIssues.filter(issue => !issue.pull_request).slice(0, 3); - issuesMap[repo.id] = actualIssues; + issuesMap[repo.id] = cachedIssues.filter(issue => !issue.pull_request).slice(0, 3); } - // If not cached, leave empty - issues will be cached when user visits repo detail } - setRepoIssues(issuesMap); } catch (err) { - // Only show error if we don't have cached data to display - if (!hasCachedData) { + if (!cachedRepos) { setError('Failed to load repositories.'); } } finally { setLoading(false); setIsRefreshing(false); } - }, [token, repos.length]); + }, [token, user.login]); useEffect(() => { - // Always fetch fresh data on mount, but show cached immediately loadRepos(false); - isInitialMount.current = false; - }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Reset pinned repos for the new user + const saved = localStorage.getItem(`pinnedRepos_${user.login}`); + setPinnedRepoIds(saved ? new Set(JSON.parse(saved)) : new Set()); + }, [user.login, loadRepos]); const handleCreateRepo = async (e: React.FormEvent) => { e.preventDefault(); @@ -135,26 +129,18 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, setIsCreating(true); try { const createdRepo = await createRepository(token, newRepo); - - // Auto-set OAUTH_TOKEN secret if checkbox is checked if (autoSetOAuthToken) { try { - // Small delay to ensure repo is fully created await new Promise(resolve => setTimeout(resolve, 500)); await setRepositorySecret(token, createdRepo.owner.login, createdRepo.name, 'OAUTH_TOKEN', token); } catch (secretErr) { console.warn('Failed to auto-set OAUTH_TOKEN:', secretErr); - // Don't fail the whole operation if secret setting fails } } - setIsCreateModalOpen(false); setNewRepo({ name: '', description: '', private: false, auto_init: true }); setAutoSetOAuthToken(true); - - // Manually add the new repo to the top of the list setRepos(prev => [createdRepo, ...prev]); - } catch (err) { showError("Failed to create repository. Note: You need 'repo' scope token permissions."); } finally { @@ -175,10 +161,7 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, await deleteRepository(token, repoToDelete.owner.login, repoToDelete.name); setIsDeleteModalOpen(false); setRepoToDelete(null); - - // Remove the repo from the list setRepos(prev => prev.filter(r => r.id !== repoToDelete.id)); - } catch (err) { showError("Failed to delete repository. Note: You need 'delete_repo' scope token permissions."); } finally { @@ -199,12 +182,11 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect, } else { newSet.add(repo.id); } - localStorage.setItem('pinnedRepos', JSON.stringify([...newSet])); + localStorage.setItem(`pinnedRepos_${user.login}`, JSON.stringify([...newSet])); return newSet; }); }; - // Sort repos with pinned ones first const sortedRepos = React.useMemo(() => { return [...repos].sort((a, b) => { const aPinned = pinnedRepoIds.has(a.id); @@ -219,18 +201,57 @@ export const Dashboard: React.FC = ({ token, user, onRepoSelect,
- {/* Header */} -
-
-
- {user.login} - {user.login} +
+
+
+ + {isAccountSwitcherOpen && ( +
+
+ Signed in as +
+ {user.login} + {user.login} +
+
+
+ Switch Account +
+ {accounts.map(acc => ( + + ))} +
+
+
+ + +
+
+ )}
-
diff --git a/views/TokenGate.tsx b/views/TokenGate.tsx index f5e251f..3711f3f 100644 --- a/views/TokenGate.tsx +++ b/views/TokenGate.tsx @@ -3,13 +3,15 @@ import { validateToken } from '../services/githubService'; import { signInWithGitHub } from '../services/firebaseService'; import { GitHubUser } from '../types'; import { Button } from '../components/Button'; -import { Github } from 'lucide-react'; +import { Github, ArrowLeft } from 'lucide-react'; interface TokenGateProps { onSuccess: (token: string, user: GitHubUser) => void; + hasAccounts?: boolean; + onBack?: () => void; } -export const TokenGate: React.FC = ({ onSuccess }) => { +export const TokenGate: React.FC = ({ onSuccess, hasAccounts, onBack }) => { const [error, setError] = useState(''); const [loading, setLoading] = useState(false); @@ -18,22 +20,17 @@ export const TokenGate: React.FC = ({ onSuccess }) => { setLoading(true); try { - // Sign in with GitHub via Firebase const { accessToken } = await signInWithGitHub(); - - // Validate token and get user data from GitHub API const user = await validateToken(accessToken); onSuccess(accessToken, user); } catch (err: unknown) { console.error('GitHub login error:', err); const message = err instanceof Error ? err.message : 'Failed to sign in with GitHub'; - // Handle common Firebase auth errors if (message.includes('popup-closed-by-user')) { setError('Sign in was cancelled. Please try again.'); } else if (message.includes('account-exists-with-different-credential')) { setError('An account already exists with the same email. Try signing in with a different method.'); } else if (message.includes('Redirecting')) { - // Popup was blocked, redirecting to GitHub - don't show error setError(''); return; } else if (message.includes('popup-blocked') || message.includes('popup_blocked')) { @@ -54,9 +51,13 @@ export const TokenGate: React.FC = ({ onSuccess }) => {
-

Welcome to GitGenius

+

+ {hasAccounts ? 'Add another account' : 'Welcome to GitGenius'} +

- Sign in with your GitHub account to manage your repositories with AI assistance. + {hasAccounts + ? 'Sign in with a different GitHub account to add it to your profile.' + : 'Sign in with your GitHub account to manage your repositories with AI assistance.'}

@@ -67,19 +68,28 @@ export const TokenGate: React.FC = ({ onSuccess }) => {
)} - +
+ {hasAccounts && onBack && ( + + )} + +
-

- By signing in, you grant access to your public and private repositories. -

+ {!hasAccounts && ( +

+ By signing in, you grant access to your public and private repositories. +

+ )}