diff --git a/README.md b/README.md index c226f5a46..6cd770739 100644 --- a/README.md +++ b/README.md @@ -180,10 +180,13 @@ For **fine-grained tokens**: - **Contents**: Read and Write (for editing DAK content) - **Metadata**: Read (for repository information) - **Pull requests**: Read and Write (for creating pull requests) +- **Members**: Read-only (optional - only needed if you want to list organizations you're a member of) For **classic tokens**: - **repo**: Full control of private repositories (for editing DAK content) -- **read:org**: Read org and team membership (for listing organization repositories) +- **read:org**: Read org and team membership (optional - only needed if you want to list organizations you're a member of) + +**Note**: The `read:org` scope (classic) or `Members: Read-only` permission (fine-grained) is only needed if you want to see and select organizations you're a member of. You can still use your personal account and the WHO organization without this permission. This authentication method is fully compatible with static deployments and requires no backend server. diff --git a/src/components/OrganizationSelection.js b/src/components/OrganizationSelection.js index 2e3055fb0..b48016cc7 100644 --- a/src/components/OrganizationSelection.js +++ b/src/components/OrganizationSelection.js @@ -103,7 +103,17 @@ const OrganizationSelection = () => { setOrganizations(orgsData); } catch (error) { console.error('Error fetching organizations:', error); - setError('Failed to fetch organizations. Please check your connection and try again.'); + + // Provide helpful error message based on error type + let errorMessage = 'Failed to fetch organizations. '; + if (error.status === 403 || error.status === 401) { + errorMessage = 'Unable to list your organizations. Your Personal Access Token needs the "read:org" scope (classic tokens) or "Members: Read-only" permission (fine-grained tokens) to list organizations. You can still use your personal account or select WHO.'; + } else { + errorMessage += 'Please check your connection and try again.'; + } + + setError(errorMessage); + // Fallback to mock data for demonstration (which includes WHO) try { const mockOrgs = await getMockOrganizations(); diff --git a/src/components/PATLogin.js b/src/components/PATLogin.js index 1c6f9cabc..23aadc2e1 100644 --- a/src/components/PATLogin.js +++ b/src/components/PATLogin.js @@ -63,7 +63,7 @@ const PATLogin = ({ onAuthSuccess }) => { if (err.status === 401) { setError("Invalid Personal Access Token. Please check your token and try again."); } else if (err.status === 403) { - setError("Token doesn't have sufficient permissions. Please ensure your token has 'repo' and 'read:org' scopes."); + setError("Token doesn't have sufficient permissions. Please ensure your token has 'repo' scope (classic) or 'Contents: Read and Write' + 'Pull requests: Read and Write' (fine-grained)."); } else { setError("Authentication failed. Please check your connection and try again."); } diff --git a/src/components/PATSetupInstructions.css b/src/components/PATSetupInstructions.css index a62a909b8..997c2b9c6 100644 --- a/src/components/PATSetupInstructions.css +++ b/src/components/PATSetupInstructions.css @@ -78,6 +78,24 @@ color: #856404; } +.info-note { + background: #e6f3ff; + border: 1px solid #b3d9ff; + border-radius: 4px; + padding: 0.75rem; + margin: 0.5rem 0; + font-size: 0.9rem; + color: #004085; +} + +.info-note code { + background: rgba(0, 64, 133, 0.1); + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: 'Courier New', Courier, monospace; + font-size: 0.85em; +} + .benefits-section, .security-note { background: white; diff --git a/src/components/PATSetupInstructions.js b/src/components/PATSetupInstructions.js index 8261e6e92..8db128d27 100644 --- a/src/components/PATSetupInstructions.js +++ b/src/components/PATSetupInstructions.js @@ -32,12 +32,16 @@ const PATSetupInstructions = () => {
  • Contents: Read and Write
  • Metadata: Read
  • Pull requests: Read and Write
  • +
  • Members: Read-only (optional - only if you want to list organizations)
  • For classic tokens, select these scopes:

    +
    + ℹ️ Note: The organization permission (read:org for classic or Members: Read-only for fine-grained) is only needed if you want to see organizations you're a member of. You can still use your personal account and the WHO organization without it. +
    diff --git a/src/services/githubService.js b/src/services/githubService.js index e249d0fa3..31f3d7fe0 100644 --- a/src/services/githubService.js +++ b/src/services/githubService.js @@ -350,6 +350,15 @@ class GitHubService { return data; } catch (error) { console.error('Failed to fetch organizations:', error); + + // Handle permission errors gracefully + if (error.status === 403 || error.status === 401) { + console.warn('Token does not have permission to list organizations. This requires the "read:org" scope for classic tokens or "Members: Read" for fine-grained tokens.'); + // Return empty array instead of throwing - user can still use their personal account + return []; + } + + // For other errors (network issues, etc.), throw so the UI can show retry option throw error; } } diff --git a/src/services/githubService.test.js b/src/services/githubService.test.js index 896faa200..e392efbf0 100644 --- a/src/services/githubService.test.js +++ b/src/services/githubService.test.js @@ -636,4 +636,84 @@ describe('GitHubService', () => { expect(result).toBeNull(); }); }); + + describe('getUserOrganizations', () => { + beforeEach(() => { + mockOctokit.rest.orgs = { + listForAuthenticatedUser: jest.fn() + }; + }); + + it('should return organizations when authenticated with proper permissions', async () => { + // Manually set authentication state + githubService.octokit = mockOctokit; + githubService.isAuthenticated = true; + + const mockOrgs = [ + { id: 1, login: 'my-org', name: 'My Organization' }, + { id: 2, login: 'another-org', name: 'Another Org' } + ]; + + mockOctokit.rest.orgs.listForAuthenticatedUser.mockResolvedValue({ + status: 200, + data: mockOrgs + }); + + const result = await githubService.getUserOrganizations(); + + expect(result).toEqual(mockOrgs); + expect(mockOctokit.rest.orgs.listForAuthenticatedUser).toHaveBeenCalled(); + }); + + it('should return empty array when token lacks read:org permission (403)', async () => { + // Manually set authentication state + githubService.octokit = mockOctokit; + githubService.isAuthenticated = true; + + const permissionError = new Error('Resource not accessible by personal access token'); + permissionError.status = 403; + + mockOctokit.rest.orgs.listForAuthenticatedUser.mockRejectedValue(permissionError); + + const result = await githubService.getUserOrganizations(); + + expect(result).toEqual([]); + expect(mockOctokit.rest.orgs.listForAuthenticatedUser).toHaveBeenCalled(); + }); + + it('should return empty array when token lacks read:org permission (401)', async () => { + // Manually set authentication state + githubService.octokit = mockOctokit; + githubService.isAuthenticated = true; + + const permissionError = new Error('Requires authentication'); + permissionError.status = 401; + + mockOctokit.rest.orgs.listForAuthenticatedUser.mockRejectedValue(permissionError); + + const result = await githubService.getUserOrganizations(); + + expect(result).toEqual([]); + expect(mockOctokit.rest.orgs.listForAuthenticatedUser).toHaveBeenCalled(); + }); + + it('should throw error for non-permission errors', async () => { + // Manually set authentication state + githubService.octokit = mockOctokit; + githubService.isAuthenticated = true; + + const networkError = new Error('Network error'); + networkError.status = 500; + + mockOctokit.rest.orgs.listForAuthenticatedUser.mockRejectedValue(networkError); + + await expect(githubService.getUserOrganizations()).rejects.toThrow('Network error'); + }); + + it('should throw error when not authenticated', async () => { + githubService.logout(); + + await expect(githubService.getUserOrganizations()).rejects.toThrow('Not authenticated with GitHub'); + }); + }); }); \ No newline at end of file