Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => ({
Expand All @@ -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' });
}
Expand All @@ -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,
Expand Down Expand Up @@ -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()) {
Expand Down
19 changes: 19 additions & 0 deletions backend/tests/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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(() => {
Expand All @@ -363,7 +385,8 @@ function AppRoutes() {
<ConnectView
provider={connectProvider}
onDone={connectDone}
onCancel={() => navigate(PATHS.signin)}
onCancel={redirectToSignIn}
onReconnect={reconnectGitHub}
storageError={storageError}
/>
)
Expand Down Expand Up @@ -397,9 +420,7 @@ function AppRoutes() {
element={
authed && storageReady
? <Navigate to={PATHS.dashboard} replace />
: authed
? <Navigate to={PATHS.connect} replace />
: <SignInView onNav={nav} onAuth={auth} />
: <SignInView onNav={nav} onAuth={auth} />
}
/>
<Route path={PATHS.connect} element={<ConnectRoute />} />
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/__tests__/connectView.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ConnectView
provider="github"
onDone={vi.fn()}
onCancel={vi.fn()}
onReconnect={onReconnect}
client={client}
/>,
)

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',
Expand Down
52 changes: 45 additions & 7 deletions frontend/src/views/ConnectView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -159,13 +167,15 @@ function ConnectOption({ active, onSelect, icon, title, desc, children }) {
* @param {'github' | 'google'} props.provider
* @param {() => void} props.onDone
* @param {() => void} props.onCancel
* @param {() => void | Promise<void>} [props.onReconnect]
* @param {string} [props.storageError]
* @param {import('../lib/apiClient').ApiClient} [props.client]
*/
export default function ConnectView({
provider,
onDone,
onCancel,
onReconnect,
storageError = null,
client = apiClient,
}) {
Expand All @@ -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)

Expand Down Expand Up @@ -214,14 +225,21 @@ export default function ConnectView({
if (!isGitHub) return
setLoadingRepos(true)
setListError(null)
setNeedsReconnect(false)
try {
const result = await client.listStorages('github')
const list = result.storages ?? []
setStorages(list)
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 []
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -596,9 +632,11 @@ export default function ConnectView({
>
{providerIcon}
<span style={{ font: 'var(--font-label)', color: 'var(--text-strong)' }}>
{pv.name} connected
{needsReconnect ? `${pv.name} access revoked` : `${pv.name} connected`}
</span>
<span style={{ color: needsReconnect ? 'var(--sev-serious-fg)' : 'var(--green-600)' }}>
{Ico(needsReconnect ? 'TriangleAlert' : 'Check', 15, 'currentColor')}
</span>
<span style={{ color: 'var(--green-600)' }}>{Ico('Check', 15, 'currentColor')}</span>
</div>
<h1 style={{ fontSize: 'var(--text-xl)', margin: '0 0 6px' }}>
Where should we save your scans?
Expand Down Expand Up @@ -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}
</Button>
Expand Down