diff --git a/backend/routes/auth.js b/backend/routes/auth.js index b3dec1b..1086158 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -64,6 +64,15 @@ function makeAuthRouter({ authService, storageService }) { }); }); + + function getOctokit (clients){ + const octokit = clients.githubUserClient || clients.githubClient; + if (!octokit) { + return githubAccessRevoked(res); + } + return octokit; + } + router.get('/storages', requireAuth, async (req, res) => { try { const provider = req.query.provider; @@ -74,11 +83,10 @@ function makeAuthRouter({ authService, storageService }) { } const clients = await authService.clientsFor(req.user); - if (!clients.githubClient) { - return res.status(400).json({ error: 'GitHub client is not available' }); - } - const repos = await storageService.listGitHubRepos(clients.githubClient); + const octokit = getOctokit(clients); + + const repos = await storageService.listGitHubRepos(octokit); return res.json({ provider: 'github', storages: repos.map((repo) => ({ @@ -90,6 +98,9 @@ function makeAuthRouter({ authService, storageService }) { })), }); } catch (err) { + if (err.status === 401) { + return githubAccessRevoked(res); + } console.error(err); return res.status(500).json({ error: 'Failed to list storages' }); } @@ -109,9 +120,8 @@ function makeAuthRouter({ authService, storageService }) { } const clients = await authService.clientsFor(req.user); - if (!clients.githubUserClient && !clients.githubClient) { - return res.status(400).json({ error: 'GitHub client is not available' }); - } + + const octokit = getOctokit(clients); const result = await storageService.checkGitHubRepoNameAvailability( name, @@ -267,6 +277,13 @@ function makeAuthRouter({ authService, storageService }) { return router; } +function githubAccessRevoked(res) { + return res.status(401).json({ + error: 'GitHub access was revoked. Sign in with GitHub again.', + code: 'GITHUB_AUTH_REVOKED', + }); +} + /** @param {import('express').Request} req */ function requireAuth(req, res, next) { if (req.isAuthenticated()) { diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index 36b0998..be4c5e7 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -318,6 +318,25 @@ test('GET /api/auth/storages returns mapped GitHub repos', async () => { assert.equal(res.body.storages[0].id, 'R_kg'); }); +test('GET /api/auth/storages returns GITHUB_AUTH_REVOKED when GitHub rejects the token', async () => { + const app = createAuthedApp({ + user: AUTHED_USER, + authService: { + clientsFor: async () => ({ githubClient: { mock: true } }), + }, + storageService: { + listGitHubRepos: async () => { + const err = new Error('Bad credentials'); + err.status = 401; + throw err; + }, + }, + }); + const res = await request(app).get('/api/auth/storages?provider=github'); + assert.equal(res.status, 401); + assert.equal(res.body.code, 'GITHUB_AUTH_REVOKED'); +}); + test('GET /api/auth/storage/name-availability returns availability result', async () => { const app = createAuthedApp({ user: AUTHED_USER, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b8e9a1a..5971e85 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -332,6 +332,16 @@ function AppRoutes() { navigate(PATHS.dashboard) } + const reconnectGitHub = async () => { + try { + await apiClient.logout() + } catch { + // Clear local state even if the network call fails. + } + setUser(null) + apiClient.githubLogin() + } + const signOut = async () => { try { await apiClient.logout() @@ -342,6 +352,18 @@ function AppRoutes() { navigate(PATHS.landing) } + const redirectToSignIn = () => { + navigate(PATHS.signin) + setUser(null) + setAuthLoading(false) + setScan(null) + setProblem(null) + setSavedScans(null) + setProvider(null) + setAuthed(false) + setStorageReady(false) + } + const route = routeKeyFor(location.pathname) useEffect(() => { @@ -363,7 +385,8 @@ function AppRoutes() { navigate(PATHS.signin)} + onCancel={redirectToSignIn} + onReconnect={reconnectGitHub} storageError={storageError} /> ) @@ -397,9 +420,7 @@ function AppRoutes() { element={ authed && storageReady ? - : authed - ? - : + : } /> } /> diff --git a/frontend/src/__tests__/connectView.test.jsx b/frontend/src/__tests__/connectView.test.jsx index 3d91315..f430ea1 100644 --- a/frontend/src/__tests__/connectView.test.jsx +++ b/frontend/src/__tests__/connectView.test.jsx @@ -193,6 +193,30 @@ describe('ConnectView', () => { expect(screen.getByRole('alert')).toHaveTextContent('GitHub sign-in failed') }) + it('offers reconnect when GitHub access was revoked', async () => { + const onReconnect = vi.fn() + const err = new Error('GitHub client is not available') + err.status = 400 + const client = mockClient({ + listStorages: vi.fn().mockRejectedValue(err), + }) + + render( + , + ) + + expect(await screen.findByText(/GitHub access was revoked/i)).toBeInTheDocument() + expect(screen.getByText(/GitHub access revoked/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /reconnect github/i })) + await waitFor(() => expect(onReconnect).toHaveBeenCalled()) + }) + it('creates a new repository then validates for init', async () => { const created = { id: 'R_kgNew', diff --git a/frontend/src/views/ConnectView.jsx b/frontend/src/views/ConnectView.jsx index 1e74e5f..8adf838 100644 --- a/frontend/src/views/ConnectView.jsx +++ b/frontend/src/views/ConnectView.jsx @@ -43,6 +43,14 @@ const STATUS_UI = { }, } +function isGitHubAccessLost(err) { + return ( + err?.code === 'GITHUB_AUTH_REVOKED' || + err?.status === 401 || + /GitHub client is not available/i.test(err?.message || '') + ) +} + function reasonMessage(reason) { switch (reason) { case 'malformed_manifest': @@ -159,6 +167,7 @@ function ConnectOption({ active, onSelect, icon, title, desc, children }) { * @param {'github' | 'google'} props.provider * @param {() => void} props.onDone * @param {() => void} props.onCancel + * @param {() => void | Promise} [props.onReconnect] * @param {string} [props.storageError] * @param {import('../lib/apiClient').ApiClient} [props.client] */ @@ -166,6 +175,7 @@ export default function ConnectView({ provider, onDone, onCancel, + onReconnect, storageError = null, client = apiClient, }) { @@ -186,6 +196,7 @@ export default function ConnectView({ const [confirming, setConfirming] = useState(false) const [error, setError] = useState(storageError) const [listError, setListError] = useState(null) + const [needsReconnect, setNeedsReconnect] = useState(false) const [nameAvailability, setNameAvailability] = useState(null) const [checkingName, setCheckingName] = useState(false) @@ -214,6 +225,7 @@ export default function ConnectView({ if (!isGitHub) return setLoadingRepos(true) setListError(null) + setNeedsReconnect(false) try { const result = await client.listStorages('github') const list = result.storages ?? [] @@ -221,7 +233,13 @@ export default function ConnectView({ setSelectedId((prev) => prev || list[0]?.id || '') return list } catch (err) { - setListError(err.message || 'Failed to load repositories') + const lost = isGitHubAccessLost(err) + setNeedsReconnect(lost) + setListError( + lost + ? 'GitHub access was revoked. Reconnect to authorize Vizably again.' + : err.message || 'Failed to load repositories', + ) setStorages([]) setSelectedId('') return [] @@ -352,17 +370,18 @@ export default function ConnectView({ (mode === 'new' && !activeStorageRef) const confirmLabel = useMemo(() => { + if (needsReconnect) return 'Reconnect GitHub' if (awaitingCreate) return 'Create repository' if (statusUi?.button) return statusUi.button return 'Continue' - }, [awaitingCreate, statusUi]) + }, [needsReconnect, awaitingCreate, statusUi]) const primaryDisabled = creating || confirming || validating || - (mode === 'new' && needsInstall) || - (awaitingCreate ? nameUnavailable : confirmBlocked) + (needsReconnect ? false : (mode === 'new' && needsInstall) || + (awaitingCreate ? nameUnavailable : confirmBlocked)) const handleCreateRepo = async () => { const name = normalizeGitHubRepoName(newRepoName) @@ -459,7 +478,24 @@ export default function ConnectView({ await runValidation(ref) } + const handleReconnect = async () => { + if (onReconnect) { + await onReconnect() + return + } + try { + await client.logout() + } catch { + // Continue to GitHub even if logout fails (stale cookie). + } + client.githubLogin() + } + const handleConfirm = async () => { + if (needsReconnect) { + await handleReconnect() + return + } if (mode === 'new' && !activeStorageRef && newRepoName.trim()) { await handleCreateRepo() return @@ -596,9 +632,11 @@ export default function ConnectView({ > {providerIcon} - {pv.name} connected + {needsReconnect ? `${pv.name} access revoked` : `${pv.name} connected`} + + + {Ico(needsReconnect ? 'TriangleAlert' : 'Check', 15, 'currentColor')} - {Ico('Check', 15, 'currentColor')}

Where should we save your scans? @@ -1004,7 +1042,7 @@ export default function ConnectView({ style={{ flex: 1 }} disabled={primaryDisabled} onClick={handleConfirm} - iconRight={Ico('ArrowRight', 17, '#fff')} + iconRight={Ico(needsReconnect ? 'Github' : 'ArrowRight', 17, '#fff')} > {creating ? 'Creating…' : confirming ? 'Connecting…' : confirmLabel}