Skip to content
Draft
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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 11 additions & 1 deletion src/components/OrganizationSelection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/components/PATLogin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
}
Expand Down
18 changes: 18 additions & 0 deletions src/components/PATSetupInstructions.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion src/components/PATSetupInstructions.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@ const PATSetupInstructions = () => {
<li><strong>Contents:</strong> Read and Write</li>
<li><strong>Metadata:</strong> Read</li>
<li><strong>Pull requests:</strong> Read and Write</li>
<li><strong>Members:</strong> Read-only (optional - only if you want to list organizations)</li>
</ul>
<p>For <strong>classic tokens</strong>, select these scopes:</p>
<ul>
<li><strong>repo</strong> - Full control of private repositories</li>
<li><strong>read:org</strong> - Read org and team membership</li>
<li><strong>read:org</strong> - Read org and team membership (optional - only if you want to list organizations)</li>
</ul>
<div className="info-note">
<strong>ℹ️ Note:</strong> The organization permission (<code>read:org</code> for classic or <code>Members: Read-only</code> 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.
</div>
</div>

<div className="step">
Expand Down
9 changes: 9 additions & 0 deletions src/services/githubService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
80 changes: 80 additions & 0 deletions src/services/githubService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});