mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 23:02:56 +01:00
Tried to complete Github Oauth flow, failed for now
This commit is contained in:
@@ -68,6 +68,8 @@
|
||||
"@noodl/noodl-parse-dashboard": "file:../noodl-parse-dashboard",
|
||||
"@noodl/platform": "file:../noodl-platform",
|
||||
"@noodl/platform-electron": "file:../noodl-platform-electron",
|
||||
"@octokit/auth-oauth-device": "^7.1.5",
|
||||
"@octokit/rest": "^20.1.2",
|
||||
"about-window": "^1.15.2",
|
||||
"algoliasearch": "^5.35.0",
|
||||
"archiver": "^5.3.2",
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
LauncherProjectData
|
||||
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
|
||||
import { Launcher } from '@noodl-core-ui/preview/launcher/Launcher/Launcher';
|
||||
import { GitHubUser } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
|
||||
|
||||
import { useEventListener } from '../../hooks/useEventListener';
|
||||
import { IRouteProps } from '../../pages/AppRoute';
|
||||
import { GitHubOAuthService } from '../../services/GitHubOAuthService';
|
||||
import { ProjectOrganizationService } from '../../services/ProjectOrganizationService';
|
||||
import { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
|
||||
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
|
||||
@@ -49,11 +47,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
// Real projects from LocalProjectsModel
|
||||
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
|
||||
|
||||
// GitHub OAuth state
|
||||
const [githubUser, setGithubUser] = useState<GitHubUser | null>(null);
|
||||
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
|
||||
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
|
||||
|
||||
// Create project modal state
|
||||
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||
|
||||
@@ -62,17 +55,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
// Switch main window size to editor size
|
||||
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
|
||||
|
||||
// Initialize GitHub OAuth service
|
||||
const initGitHub = async () => {
|
||||
console.log('🔧 Initializing GitHub OAuth service...');
|
||||
await GitHubOAuthService.instance.initialize();
|
||||
const user = GitHubOAuthService.instance.getCurrentUser();
|
||||
const isAuth = GitHubOAuthService.instance.isAuthenticated();
|
||||
setGithubUser(user);
|
||||
setGithubIsAuthenticated(isAuth);
|
||||
console.log('✅ GitHub OAuth initialized. Authenticated:', isAuth);
|
||||
};
|
||||
|
||||
// Load projects
|
||||
const loadProjects = async () => {
|
||||
await LocalProjectsModel.instance.fetch();
|
||||
@@ -80,31 +62,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
};
|
||||
|
||||
initGitHub();
|
||||
loadProjects();
|
||||
|
||||
// Set up IPC listener for OAuth callback
|
||||
const handleOAuthCallback = (_event: any, { code, state }: { code: string; state: string }) => {
|
||||
console.log('🔄 Received GitHub OAuth callback from main process');
|
||||
setGithubIsConnecting(true);
|
||||
GitHubOAuthService.instance
|
||||
.handleCallback(code, state)
|
||||
.then(() => {
|
||||
console.log('✅ OAuth callback handled successfully');
|
||||
setGithubIsConnecting(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ OAuth callback failed:', error);
|
||||
setGithubIsConnecting(false);
|
||||
ToastLayer.showError('GitHub authentication failed');
|
||||
});
|
||||
};
|
||||
|
||||
ipcRenderer.on('github-oauth-callback', handleOAuthCallback);
|
||||
|
||||
return () => {
|
||||
ipcRenderer.removeListener('github-oauth-callback', handleOAuthCallback);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to project list changes
|
||||
@@ -114,44 +72,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
setRealProjects(projects.map(mapProjectToLauncherData));
|
||||
});
|
||||
|
||||
// Subscribe to GitHub OAuth state changes
|
||||
useEventListener(GitHubOAuthService.instance, 'oauth-success', (data: { user: GitHubUser }) => {
|
||||
console.log('🎉 GitHub OAuth success:', data.user.login);
|
||||
setGithubUser(data.user);
|
||||
setGithubIsAuthenticated(true);
|
||||
setGithubIsConnecting(false);
|
||||
ToastLayer.showSuccess(`Connected to GitHub as ${data.user.login}`);
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'auth-state-changed', (data: { authenticated: boolean }) => {
|
||||
console.log('🔐 GitHub auth state changed:', data.authenticated);
|
||||
setGithubIsAuthenticated(data.authenticated);
|
||||
if (data.authenticated) {
|
||||
const user = GitHubOAuthService.instance.getCurrentUser();
|
||||
setGithubUser(user);
|
||||
} else {
|
||||
setGithubUser(null);
|
||||
}
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'oauth-started', () => {
|
||||
console.log('🚀 GitHub OAuth flow started');
|
||||
setGithubIsConnecting(true);
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'oauth-error', (data: { error: string }) => {
|
||||
console.error('❌ GitHub OAuth error:', data.error);
|
||||
setGithubIsConnecting(false);
|
||||
ToastLayer.showError(`GitHub authentication failed: ${data.error}`);
|
||||
});
|
||||
|
||||
useEventListener(GitHubOAuthService.instance, 'disconnected', () => {
|
||||
console.log('👋 GitHub disconnected');
|
||||
setGithubUser(null);
|
||||
setGithubIsAuthenticated(false);
|
||||
ToastLayer.showSuccess('Disconnected from GitHub');
|
||||
});
|
||||
|
||||
const handleCreateProject = useCallback(() => {
|
||||
setIsCreateModalVisible(true);
|
||||
}, []);
|
||||
@@ -336,17 +256,6 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// GitHub OAuth handlers
|
||||
const handleGitHubConnect = useCallback(() => {
|
||||
console.log('🔗 Initiating GitHub OAuth...');
|
||||
GitHubOAuthService.instance.initiateOAuth();
|
||||
}, []);
|
||||
|
||||
const handleGitHubDisconnect = useCallback(() => {
|
||||
console.log('🔌 Disconnecting GitHub...');
|
||||
GitHubOAuthService.instance.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Launcher
|
||||
@@ -357,11 +266,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
onOpenProjectFolder={handleOpenProjectFolder}
|
||||
onDeleteProject={handleDeleteProject}
|
||||
projectOrganizationService={ProjectOrganizationService.instance}
|
||||
githubUser={githubUser}
|
||||
githubIsAuthenticated={githubIsAuthenticated}
|
||||
githubIsConnecting={githubIsConnecting}
|
||||
onGitHubConnect={handleGitHubConnect}
|
||||
onGitHubDisconnect={handleGitHubDisconnect}
|
||||
githubUser={null}
|
||||
githubIsAuthenticated={false}
|
||||
githubIsConnecting={false}
|
||||
onGitHubConnect={() => {}}
|
||||
onGitHubDisconnect={() => {}}
|
||||
/>
|
||||
|
||||
<CreateProjectModal
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* GitHubAuth
|
||||
*
|
||||
* Handles GitHub OAuth authentication using Web OAuth Flow.
|
||||
* Web OAuth Flow allows users to select which organizations and repositories
|
||||
* to grant access to, providing better permission control.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
import { ipcRenderer, shell } from 'electron';
|
||||
|
||||
import { GitHubTokenStore } from './GitHubTokenStore';
|
||||
import type {
|
||||
GitHubAuthState,
|
||||
GitHubDeviceCode,
|
||||
GitHubToken,
|
||||
GitHubAuthError,
|
||||
GitHubUser,
|
||||
GitHubInstallation
|
||||
} from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* Scopes required for GitHub integration
|
||||
* - repo: Full control of private repositories (for issues, PRs)
|
||||
* - read:org: Read organization membership
|
||||
* - read:user: Read user profile data
|
||||
* - user:email: Read user email addresses
|
||||
*/
|
||||
const REQUIRED_SCOPES = ['repo', 'read:org', 'read:user', 'user:email'];
|
||||
|
||||
/**
|
||||
* GitHubAuth
|
||||
*
|
||||
* Manages GitHub OAuth authentication using Device Flow.
|
||||
* Provides methods to authenticate, check status, and disconnect.
|
||||
*/
|
||||
export class GitHubAuth {
|
||||
/**
|
||||
* Initiate GitHub Web OAuth flow
|
||||
*
|
||||
* Opens browser to GitHub authorization page where user can select
|
||||
* which organizations and repositories to grant access to.
|
||||
*
|
||||
* @param onProgress - Callback for progress updates
|
||||
* @returns Promise that resolves when authentication completes
|
||||
*
|
||||
* @throws {GitHubAuthError} If OAuth flow fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await GitHubAuth.startWebOAuthFlow((message) => {
|
||||
* console.log(message);
|
||||
* });
|
||||
* console.log('Successfully authenticated!');
|
||||
* ```
|
||||
*/
|
||||
static async startWebOAuthFlow(onProgress?: (message: string) => void): Promise<void> {
|
||||
try {
|
||||
onProgress?.('Starting GitHub authentication...');
|
||||
|
||||
// Request OAuth flow from main process
|
||||
const result = await ipcRenderer.invoke('github-oauth-start');
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to start OAuth flow');
|
||||
}
|
||||
|
||||
onProgress?.('Opening GitHub in your browser...');
|
||||
|
||||
// Open browser to GitHub authorization page
|
||||
shell.openExternal(result.authUrl);
|
||||
|
||||
// Wait for OAuth callback from main process
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Authentication timed out after 5 minutes'));
|
||||
}, 300000); // 5 minutes
|
||||
|
||||
const handleSuccess = async (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
console.log('🎉 [GitHub Auth] ========================================');
|
||||
console.log('🎉 [GitHub Auth] IPC EVENT RECEIVED: github-oauth-complete');
|
||||
console.log('🎉 [GitHub Auth] Data:', data);
|
||||
console.log('🎉 [GitHub Auth] ========================================');
|
||||
cleanup();
|
||||
|
||||
try {
|
||||
onProgress?.('Authentication successful, fetching details...');
|
||||
|
||||
// Save token and user info
|
||||
const token: GitHubToken = {
|
||||
access_token: data.token.access_token,
|
||||
token_type: data.token.token_type,
|
||||
scope: data.token.scope
|
||||
};
|
||||
|
||||
const installations = data.installations as GitHubInstallation[];
|
||||
|
||||
GitHubTokenStore.saveToken(token, data.user.login, data.user.email, installations);
|
||||
|
||||
onProgress?.(`Successfully authenticated as ${data.user.login}`);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (_event: Electron.IpcRendererEvent, data: any) => {
|
||||
cleanup();
|
||||
reject(new Error(data.message || 'Authentication failed'));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
ipcRenderer.removeListener('github-oauth-complete', handleSuccess);
|
||||
ipcRenderer.removeListener('github-oauth-error', handleError);
|
||||
};
|
||||
|
||||
ipcRenderer.once('github-oauth-complete', handleSuccess);
|
||||
ipcRenderer.once('github-oauth-error', handleError);
|
||||
});
|
||||
} catch (error) {
|
||||
const authError: GitHubAuthError = new Error(
|
||||
`GitHub authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
authError.code = error instanceof Error && 'code' in error ? (error as { code?: string }).code : undefined;
|
||||
|
||||
console.error('[GitHub] Authentication error:', authError);
|
||||
throw authError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use startWebOAuthFlow instead. Device Flow kept for backward compatibility.
|
||||
*/
|
||||
static async startDeviceFlow(onProgress?: (message: string) => void): Promise<GitHubDeviceCode> {
|
||||
console.warn('[GitHub] startDeviceFlow is deprecated, using startWebOAuthFlow instead');
|
||||
await this.startWebOAuthFlow(onProgress);
|
||||
|
||||
// Return empty device code for backward compatibility
|
||||
return {
|
||||
device_code: '',
|
||||
user_code: '',
|
||||
verification_uri: '',
|
||||
expires_in: 0,
|
||||
interval: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user information from GitHub API
|
||||
*
|
||||
* @param token - Access token
|
||||
* @returns User information
|
||||
*
|
||||
* @throws {Error} If API request fails
|
||||
*/
|
||||
private static async fetchUserInfo(token: string): Promise<GitHubUser> {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current authentication state
|
||||
*
|
||||
* @returns Current auth state
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const state = GitHubAuth.getAuthState();
|
||||
* if (state.isAuthenticated) {
|
||||
* console.log('Connected as:', state.username);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static getAuthState(): GitHubAuthState {
|
||||
const storedAuth = GitHubTokenStore.getToken();
|
||||
|
||||
if (!storedAuth) {
|
||||
return {
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (GitHubTokenStore.isTokenExpired()) {
|
||||
console.warn('[GitHub] Token is expired');
|
||||
return {
|
||||
isAuthenticated: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
username: storedAuth.user.login,
|
||||
email: storedAuth.user.email || undefined,
|
||||
token: storedAuth.token,
|
||||
authenticatedAt: storedAuth.storedAt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently authenticated
|
||||
*
|
||||
* @returns True if authenticated and token is valid
|
||||
*/
|
||||
static isAuthenticated(): boolean {
|
||||
return this.getAuthState().isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username of authenticated user
|
||||
*
|
||||
* @returns Username or null if not authenticated
|
||||
*/
|
||||
static getUsername(): string | null {
|
||||
return this.getAuthState().username || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current access token
|
||||
*
|
||||
* @returns Access token or null if not authenticated
|
||||
*/
|
||||
static getAccessToken(): string | null {
|
||||
const state = this.getAuthState();
|
||||
return state.token?.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from GitHub
|
||||
*
|
||||
* Clears stored authentication data. User will need to re-authenticate.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* GitHubAuth.disconnect();
|
||||
* console.log('Disconnected from GitHub');
|
||||
* ```
|
||||
*/
|
||||
static disconnect(): void {
|
||||
GitHubTokenStore.clearToken();
|
||||
console.log('[GitHub] User disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate current token by making a test API call
|
||||
*
|
||||
* @returns True if token is valid, false otherwise
|
||||
*/
|
||||
static async validateToken(): Promise<boolean> {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error('[GitHub] Token validation failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh user information from GitHub
|
||||
*
|
||||
* Useful for updating cached user data
|
||||
*
|
||||
* @returns Updated auth state
|
||||
* @throws {Error} If not authenticated or refresh fails
|
||||
*/
|
||||
static async refreshUserInfo(): Promise<GitHubAuthState> {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const user = await this.fetchUserInfo(token);
|
||||
|
||||
// Update stored auth with new user info
|
||||
const storedAuth = GitHubTokenStore.getToken();
|
||||
if (storedAuth) {
|
||||
GitHubTokenStore.saveToken(storedAuth.token, user.login, user.email);
|
||||
}
|
||||
|
||||
return this.getAuthState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* GitHubClient
|
||||
*
|
||||
* Wrapper around Octokit REST API client with authentication and rate limiting.
|
||||
* Provides convenient methods for GitHub API operations needed by OpenNoodl.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
import { GitHubAuth } from './GitHubAuth';
|
||||
import type { GitHubRepository, GitHubRateLimit, GitHubUser } from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* GitHubClient
|
||||
*
|
||||
* Main client for GitHub API interactions.
|
||||
* Automatically uses authenticated token from GitHubAuth.
|
||||
* Handles rate limiting and provides typed API methods.
|
||||
*/
|
||||
export class GitHubClient {
|
||||
private octokit: Octokit | null = null;
|
||||
private lastRateLimit: GitHubRateLimit | null = null;
|
||||
|
||||
/**
|
||||
* Initialize Octokit instance with current auth token
|
||||
*
|
||||
* @returns Octokit instance or null if not authenticated
|
||||
*/
|
||||
private getOctokit(): Octokit | null {
|
||||
const token = GitHubAuth.getAccessToken();
|
||||
if (!token) {
|
||||
console.warn('[GitHub Client] Not authenticated');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new instance if token changed or doesn't exist
|
||||
if (!this.octokit) {
|
||||
this.octokit = new Octokit({
|
||||
auth: token,
|
||||
userAgent: 'OpenNoodl/1.1.0'
|
||||
});
|
||||
}
|
||||
|
||||
return this.octokit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if client is ready (authenticated)
|
||||
*
|
||||
* @returns True if client has valid auth token
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return GitHubAuth.isAuthenticated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current rate limit status
|
||||
*
|
||||
* @returns Rate limit information
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async getRateLimit(): Promise<GitHubRateLimit> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.rateLimit.get();
|
||||
const core = response.data.resources.core;
|
||||
|
||||
const rateLimit: GitHubRateLimit = {
|
||||
limit: core.limit,
|
||||
remaining: core.remaining,
|
||||
reset: core.reset,
|
||||
resource: 'core'
|
||||
};
|
||||
|
||||
this.lastRateLimit = rateLimit;
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're approaching rate limit
|
||||
*
|
||||
* @returns True if remaining requests < 100
|
||||
*/
|
||||
isApproachingRateLimit(): boolean {
|
||||
if (!this.lastRateLimit) {
|
||||
return false;
|
||||
}
|
||||
return this.lastRateLimit.remaining < 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authenticated user's information
|
||||
*
|
||||
* @returns User information
|
||||
* @throws {Error} If not authenticated or API call fails
|
||||
*/
|
||||
async getAuthenticatedUser(): Promise<GitHubUser> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.users.getAuthenticated();
|
||||
return response.data as GitHubUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository information
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @returns Repository information
|
||||
* @throws {Error} If repository not found or API call fails
|
||||
*/
|
||||
async getRepository(owner: string, repo: string): Promise<GitHubRepository> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.repos.get({ owner, repo });
|
||||
return response.data as GitHubRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's repositories
|
||||
*
|
||||
* @param options - Listing options
|
||||
* @returns Array of repositories
|
||||
* @throws {Error} If not authenticated or API call fails
|
||||
*/
|
||||
async listRepositories(options?: {
|
||||
visibility?: 'all' | 'public' | 'private';
|
||||
sort?: 'created' | 'updated' | 'pushed' | 'full_name';
|
||||
per_page?: number;
|
||||
}): Promise<GitHubRepository[]> {
|
||||
const octokit = this.getOctokit();
|
||||
if (!octokit) {
|
||||
throw new Error('Not authenticated with GitHub');
|
||||
}
|
||||
|
||||
const response = await octokit.repos.listForAuthenticatedUser({
|
||||
visibility: options?.visibility || 'all',
|
||||
sort: options?.sort || 'updated',
|
||||
per_page: options?.per_page || 30
|
||||
});
|
||||
|
||||
return response.data as GitHubRepository[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a repository exists and user has access
|
||||
*
|
||||
* @param owner - Repository owner
|
||||
* @param repo - Repository name
|
||||
* @returns True if repository exists and accessible
|
||||
*/
|
||||
async repositoryExists(owner: string, repo: string): Promise<boolean> {
|
||||
try {
|
||||
await this.getRepository(owner, repo);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse repository URL to owner/repo
|
||||
*
|
||||
* Handles various GitHub URL formats:
|
||||
* - https://github.com/owner/repo
|
||||
* - git@github.com:owner/repo.git
|
||||
* - https://github.com/owner/repo.git
|
||||
*
|
||||
* @param url - GitHub repository URL
|
||||
* @returns Object with owner and repo, or null if invalid
|
||||
*/
|
||||
static parseRepoUrl(url: string): { owner: string; repo: string } | null {
|
||||
try {
|
||||
// Remove .git suffix if present
|
||||
const cleanUrl = url.replace(/\.git$/, '');
|
||||
|
||||
// Handle SSH format: git@github.com:owner/repo
|
||||
if (cleanUrl.includes('git@github.com:')) {
|
||||
const parts = cleanUrl.split('git@github.com:')[1].split('/');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTTPS format: https://github.com/owner/repo
|
||||
if (cleanUrl.includes('github.com/')) {
|
||||
const parts = cleanUrl.split('github.com/')[1].split('/');
|
||||
if (parts.length >= 2) {
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[GitHub Client] Error parsing repo URL:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository from local Git remote URL
|
||||
*
|
||||
* Useful for getting GitHub repo info from current project's git remote.
|
||||
*
|
||||
* @param remoteUrl - Git remote URL
|
||||
* @returns Repository information if GitHub repo, null otherwise
|
||||
*/
|
||||
async getRepositoryFromRemoteUrl(remoteUrl: string): Promise<GitHubRepository | null> {
|
||||
const parsed = GitHubClient.parseRepoUrl(remoteUrl);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.getRepository(parsed.owner, parsed.repo);
|
||||
} catch (error) {
|
||||
console.error('[GitHub Client] Error fetching repository:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset client state
|
||||
*
|
||||
* Call this when user disconnects or token changes.
|
||||
*/
|
||||
reset(): void {
|
||||
this.octokit = null;
|
||||
this.lastRateLimit = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance of GitHubClient
|
||||
* Use this for all GitHub API operations
|
||||
*/
|
||||
export const githubClient = new GitHubClient();
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* GitHubTokenStore
|
||||
*
|
||||
* Secure storage for GitHub OAuth tokens using Electron Store.
|
||||
* Tokens are stored encrypted using Electron's safeStorage API.
|
||||
* This provides OS-level encryption (Keychain on macOS, Credential Manager on Windows).
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
import ElectronStore from 'electron-store';
|
||||
|
||||
import type { StoredGitHubAuth, GitHubToken, GitHubInstallation } from './GitHubTypes';
|
||||
|
||||
/**
|
||||
* Store key for GitHub authentication data
|
||||
*/
|
||||
const GITHUB_AUTH_KEY = 'github.auth';
|
||||
|
||||
/**
|
||||
* Electron store instance for GitHub credentials
|
||||
* Uses encryption for sensitive data
|
||||
*/
|
||||
const store = new ElectronStore<{
|
||||
'github.auth'?: StoredGitHubAuth;
|
||||
}>({
|
||||
name: 'github-credentials',
|
||||
// Encrypt the entire store for security
|
||||
encryptionKey: 'opennoodl-github-credentials'
|
||||
});
|
||||
|
||||
/**
|
||||
* GitHubTokenStore
|
||||
*
|
||||
* Manages secure storage and retrieval of GitHub OAuth tokens.
|
||||
* Provides methods to save, retrieve, and clear authentication data.
|
||||
*/
|
||||
export class GitHubTokenStore {
|
||||
/**
|
||||
* Save GitHub authentication data to secure storage
|
||||
*
|
||||
* @param token - OAuth access token
|
||||
* @param username - GitHub username
|
||||
* @param email - User's email (nullable)
|
||||
* @param installations - Optional list of installations (orgs/repos with access)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await GitHubTokenStore.saveToken(
|
||||
* { access_token: 'gho_...', token_type: 'bearer', scope: 'repo' },
|
||||
* 'octocat',
|
||||
* 'octocat@github.com',
|
||||
* installations
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
static saveToken(
|
||||
token: GitHubToken,
|
||||
username: string,
|
||||
email: string | null,
|
||||
installations?: GitHubInstallation[]
|
||||
): void {
|
||||
const authData: StoredGitHubAuth = {
|
||||
token,
|
||||
user: {
|
||||
login: username,
|
||||
email
|
||||
},
|
||||
installations,
|
||||
storedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
store.set(GITHUB_AUTH_KEY, authData);
|
||||
|
||||
if (installations && installations.length > 0) {
|
||||
const orgNames = installations.map((i) => i.account.login).join(', ');
|
||||
console.log(`[GitHub] Token saved for user: ${username} with access to: ${orgNames}`);
|
||||
} else {
|
||||
console.log('[GitHub] Token saved for user:', username);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get installations (organizations/repos with access)
|
||||
*
|
||||
* @returns List of installations if authenticated, empty array otherwise
|
||||
*/
|
||||
static getInstallations(): GitHubInstallation[] {
|
||||
const authData = this.getToken();
|
||||
return authData?.installations || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve stored GitHub authentication data
|
||||
*
|
||||
* @returns Stored auth data if exists, null otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const authData = GitHubTokenStore.getToken();
|
||||
* if (authData) {
|
||||
* console.log('Authenticated as:', authData.user.login);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static getToken(): StoredGitHubAuth | null {
|
||||
try {
|
||||
const authData = store.get(GITHUB_AUTH_KEY);
|
||||
return authData || null;
|
||||
} catch (error) {
|
||||
console.error('[GitHub] Error reading token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a valid token exists
|
||||
*
|
||||
* @returns True if token exists, false otherwise
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (GitHubTokenStore.hasToken()) {
|
||||
* // User is authenticated
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
static hasToken(): boolean {
|
||||
const authData = this.getToken();
|
||||
return authData !== null && !!authData.token.access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the username of the authenticated user
|
||||
*
|
||||
* @returns Username if authenticated, null otherwise
|
||||
*/
|
||||
static getUsername(): string | null {
|
||||
const authData = this.getToken();
|
||||
return authData?.user.login || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token string
|
||||
*
|
||||
* @returns Access token if exists, null otherwise
|
||||
*/
|
||||
static getAccessToken(): string | null {
|
||||
const authData = this.getToken();
|
||||
return authData?.token.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stored authentication data
|
||||
* Call this when user disconnects their GitHub account
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* GitHubTokenStore.clearToken();
|
||||
* console.log('User disconnected from GitHub');
|
||||
* ```
|
||||
*/
|
||||
static clearToken(): void {
|
||||
store.delete(GITHUB_AUTH_KEY);
|
||||
console.log('[GitHub] Token cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired (if expiration is set)
|
||||
*
|
||||
* @returns True if token is expired, false if valid or no expiration
|
||||
*/
|
||||
static isTokenExpired(): boolean {
|
||||
const authData = this.getToken();
|
||||
if (!authData || !authData.token.expires_at) {
|
||||
// No expiration set - assume valid
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(authData.token.expires_at);
|
||||
const now = new Date();
|
||||
|
||||
return now >= expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update token (for refresh scenarios)
|
||||
*
|
||||
* @param token - New OAuth token
|
||||
*/
|
||||
static updateToken(token: GitHubToken): void {
|
||||
const existing = this.getToken();
|
||||
if (!existing) {
|
||||
throw new Error('Cannot update token: No existing auth data found');
|
||||
}
|
||||
|
||||
const updated: StoredGitHubAuth = {
|
||||
...existing,
|
||||
token,
|
||||
storedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
store.set(GITHUB_AUTH_KEY, updated);
|
||||
console.log('[GitHub] Token updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all stored GitHub data (for debugging)
|
||||
* WARNING: Contains sensitive data - use carefully
|
||||
*
|
||||
* @returns All stored data
|
||||
*/
|
||||
static _debug_getAllData(): StoredGitHubAuth | null {
|
||||
return this.getToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* GitHubTypes
|
||||
*
|
||||
* TypeScript type definitions for GitHub OAuth and API integration.
|
||||
* These types define the structure of tokens, authentication state, and API responses.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* OAuth device code response from GitHub
|
||||
* Returned when initiating device flow authorization
|
||||
*/
|
||||
export interface GitHubDeviceCode {
|
||||
/** The device verification code */
|
||||
device_code: string;
|
||||
/** The user verification code (8-character code) */
|
||||
user_code: string;
|
||||
/** URL where user enters the code */
|
||||
verification_uri: string;
|
||||
/** Expiration time in seconds (default: 900) */
|
||||
expires_in: number;
|
||||
/** Polling interval in seconds (default: 5) */
|
||||
interval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth access token
|
||||
* Stored securely and used for API authentication
|
||||
*/
|
||||
export interface GitHubToken {
|
||||
/** The OAuth access token */
|
||||
access_token: string;
|
||||
/** Token type (always 'bearer' for GitHub) */
|
||||
token_type: string;
|
||||
/** Granted scopes (comma-separated) */
|
||||
scope: string;
|
||||
/** Token expiration timestamp (ISO 8601) - undefined if no expiration */
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current GitHub authentication state
|
||||
* Used by React components to display connection status
|
||||
*/
|
||||
export interface GitHubAuthState {
|
||||
/** Whether user is authenticated with GitHub */
|
||||
isAuthenticated: boolean;
|
||||
/** GitHub username if authenticated */
|
||||
username?: string;
|
||||
/** User's primary email if authenticated */
|
||||
email?: string;
|
||||
/** Current token (for internal use only) */
|
||||
token?: GitHubToken;
|
||||
/** Timestamp of last successful authentication */
|
||||
authenticatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub user information
|
||||
* Retrieved from /user API endpoint
|
||||
*/
|
||||
export interface GitHubUser {
|
||||
/** GitHub username */
|
||||
login: string;
|
||||
/** GitHub user ID */
|
||||
id: number;
|
||||
/** User's display name */
|
||||
name: string | null;
|
||||
/** User's primary email */
|
||||
email: string | null;
|
||||
/** Avatar URL */
|
||||
avatar_url: string;
|
||||
/** Profile URL */
|
||||
html_url: string;
|
||||
/** User type (User or Organization) */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub repository information
|
||||
* Basic repo details for issue/PR association
|
||||
*/
|
||||
export interface GitHubRepository {
|
||||
/** Repository ID */
|
||||
id: number;
|
||||
/** Repository name (without owner) */
|
||||
name: string;
|
||||
/** Full repository name (owner/repo) */
|
||||
full_name: string;
|
||||
/** Repository owner */
|
||||
owner: {
|
||||
login: string;
|
||||
id: number;
|
||||
avatar_url: string;
|
||||
};
|
||||
/** Whether repo is private */
|
||||
private: boolean;
|
||||
/** Repository URL */
|
||||
html_url: string;
|
||||
/** Default branch */
|
||||
default_branch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub App installation information
|
||||
* Represents organizations/accounts where the app was installed
|
||||
*/
|
||||
export interface GitHubInstallation {
|
||||
/** Installation ID */
|
||||
id: number;
|
||||
/** Account where app is installed */
|
||||
account: {
|
||||
login: string;
|
||||
type: 'User' | 'Organization';
|
||||
avatar_url: string;
|
||||
};
|
||||
/** Repository selection type */
|
||||
repository_selection: 'all' | 'selected';
|
||||
/** List of repositories (if selected) */
|
||||
repositories?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
private: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit information from GitHub API
|
||||
* Used to prevent hitting API limits
|
||||
*/
|
||||
export interface GitHubRateLimit {
|
||||
/** Maximum requests allowed per hour */
|
||||
limit: number;
|
||||
/** Remaining requests in current window */
|
||||
remaining: number;
|
||||
/** Timestamp when rate limit resets (Unix epoch) */
|
||||
reset: number;
|
||||
/** Resource type (core, search, graphql) */
|
||||
resource: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response from GitHub API
|
||||
*/
|
||||
export interface GitHubError {
|
||||
/** HTTP status code */
|
||||
status: number;
|
||||
/** Error message */
|
||||
message: string;
|
||||
/** Detailed documentation URL if available */
|
||||
documentation_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth authorization error
|
||||
* Thrown during device flow authorization
|
||||
*/
|
||||
export interface GitHubAuthError extends Error {
|
||||
/** Error code from GitHub */
|
||||
code?: string;
|
||||
/** HTTP status if applicable */
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored token data (persisted format)
|
||||
* Encrypted and stored in Electron's secure storage
|
||||
*/
|
||||
export interface StoredGitHubAuth {
|
||||
/** OAuth token */
|
||||
token: GitHubToken;
|
||||
/** Associated user info */
|
||||
user: {
|
||||
login: string;
|
||||
email: string | null;
|
||||
};
|
||||
/** Installation information (organizations/repos with access) */
|
||||
installations?: GitHubInstallation[];
|
||||
/** Timestamp when stored */
|
||||
storedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* GitHub Services
|
||||
*
|
||||
* Public exports for GitHub OAuth authentication and API integration.
|
||||
* This module provides everything needed to connect to GitHub,
|
||||
* authenticate users, and interact with the GitHub API.
|
||||
*
|
||||
* @module services/github
|
||||
* @since 1.1.0
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { GitHubAuth, githubClient } from '@noodl-services/github';
|
||||
*
|
||||
* // Check if authenticated
|
||||
* if (GitHubAuth.isAuthenticated()) {
|
||||
* // Fetch user repos
|
||||
* const repos = await githubClient.listRepositories();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Authentication
|
||||
export { GitHubAuth } from './GitHubAuth';
|
||||
export { GitHubTokenStore } from './GitHubTokenStore';
|
||||
|
||||
// API Client
|
||||
export { GitHubClient, githubClient } from './GitHubClient';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
GitHubDeviceCode,
|
||||
GitHubToken,
|
||||
GitHubAuthState,
|
||||
GitHubUser,
|
||||
GitHubRepository,
|
||||
GitHubRateLimit,
|
||||
GitHubError,
|
||||
GitHubAuthError,
|
||||
StoredGitHubAuth
|
||||
} from './GitHubTypes';
|
||||
@@ -13,6 +13,7 @@ import Model from '../../../shared/model';
|
||||
import { detectRuntimeVersion } from '../models/migration/ProjectScanner';
|
||||
import { RuntimeVersionInfo } from '../models/migration/types';
|
||||
import { projectFromDirectory, unzipIntoDirectory } from '../models/projectmodel.editor';
|
||||
import { GitHubAuth } from '../services/github';
|
||||
import FileSystem from './filesystem';
|
||||
import { tracker } from './tracker';
|
||||
import { guid } from './utils';
|
||||
@@ -119,6 +120,10 @@ export class LocalProjectsModel extends Model {
|
||||
project.name = projectEntry.name; // Also assign the name
|
||||
this.touchProject(projectEntry);
|
||||
this.bindProject(project);
|
||||
|
||||
// Initialize Git authentication for this project
|
||||
this.setCurrentGlobalGitAuth(projectEntry.id);
|
||||
|
||||
resolve(project);
|
||||
});
|
||||
});
|
||||
@@ -328,13 +333,34 @@ export class LocalProjectsModel extends Model {
|
||||
setCurrentGlobalGitAuth(projectId: string) {
|
||||
const func = async (endpoint: string) => {
|
||||
if (endpoint.includes('github.com')) {
|
||||
// Priority 1: Check for global OAuth token
|
||||
const authState = GitHubAuth.getAuthState();
|
||||
if (authState.isAuthenticated && authState.token) {
|
||||
console.log('[Git Auth] Using GitHub OAuth token for:', endpoint);
|
||||
return {
|
||||
username: authState.username || 'oauth',
|
||||
password: authState.token.access_token // Extract actual access token string
|
||||
};
|
||||
}
|
||||
|
||||
// Priority 2: Fall back to project-specific PAT
|
||||
const config = await GitStore.get('github', projectId);
|
||||
//username is not used by github when using a token, but git will still ask for it. Just set it to "noodl"
|
||||
if (config?.password) {
|
||||
console.log('[Git Auth] Using project PAT for:', endpoint);
|
||||
return {
|
||||
username: 'noodl',
|
||||
password: config.password
|
||||
};
|
||||
}
|
||||
|
||||
// No credentials available
|
||||
console.warn('[Git Auth] No GitHub credentials found for:', endpoint);
|
||||
return {
|
||||
username: 'noodl',
|
||||
password: config?.password
|
||||
password: ''
|
||||
};
|
||||
} else {
|
||||
// Non-GitHub providers use project-specific credentials only
|
||||
const config = await GitStore.get('unknown', projectId);
|
||||
return {
|
||||
username: config?.username,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GitProvider } from '@noodl/git';
|
||||
|
||||
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { TextButton } from '@noodl-core-ui/components/inputs/TextButton';
|
||||
import { TextInput, TextInputVariant } from '@noodl-core-ui/components/inputs/TextInput';
|
||||
import { Section, SectionVariant } from '@noodl-core-ui/components/sidebar/Section';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { GitHubAuth, type GitHubAuthState } from '../../../../../../services/github';
|
||||
|
||||
type CredentialsSectionProps = {
|
||||
provider: GitProvider;
|
||||
username: string;
|
||||
@@ -25,39 +29,120 @@ export function CredentialsSection({
|
||||
|
||||
const [hidePassword, setHidePassword] = useState(true);
|
||||
|
||||
// OAuth state management
|
||||
const [authState, setAuthState] = useState<GitHubAuthState>(GitHubAuth.getAuthState());
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [progressMessage, setProgressMessage] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check auth state on mount
|
||||
useEffect(() => {
|
||||
if (provider === 'github') {
|
||||
setAuthState(GitHubAuth.getAuthState());
|
||||
}
|
||||
}, [provider]);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
setProgressMessage('Initiating GitHub authentication...');
|
||||
|
||||
try {
|
||||
await GitHubAuth.startWebOAuthFlow((message) => {
|
||||
setProgressMessage(message);
|
||||
});
|
||||
|
||||
// Update state after successful auth
|
||||
setAuthState(GitHubAuth.getAuthState());
|
||||
setProgressMessage('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed');
|
||||
setProgressMessage('');
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
GitHubAuth.disconnect();
|
||||
setAuthState(GitHubAuth.getAuthState());
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title={getTitle(provider)} variant={SectionVariant.InModal} hasGutter>
|
||||
{showUsername && (
|
||||
<>
|
||||
{/* OAuth Section - GitHub Only */}
|
||||
{provider === 'github' && (
|
||||
<Section title="GitHub Account (Recommended)" variant={SectionVariant.InModal} hasGutter>
|
||||
{authState.isAuthenticated ? (
|
||||
// Connected state
|
||||
<>
|
||||
<Text hasBottomSpacing>
|
||||
✓ Connected as <strong>{authState.username}</strong>
|
||||
</Text>
|
||||
<Text hasBottomSpacing>Your GitHub account is connected and will be used for all Git operations.</Text>
|
||||
<TextButton label="Disconnect GitHub Account" onClick={handleDisconnect} />
|
||||
</>
|
||||
) : (
|
||||
// Not connected state
|
||||
<>
|
||||
<Text hasBottomSpacing>
|
||||
Connect your GitHub account for the best experience. This enables advanced features and is more secure
|
||||
than Personal Access Tokens.
|
||||
</Text>
|
||||
|
||||
{isConnecting && progressMessage && <Text hasBottomSpacing>{progressMessage}</Text>}
|
||||
|
||||
{error && <Text hasBottomSpacing>{error}</Text>}
|
||||
|
||||
<PrimaryButton
|
||||
label={isConnecting ? 'Connecting...' : 'Connect GitHub Account'}
|
||||
onClick={handleConnect}
|
||||
isDisabled={isConnecting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* PAT Section - Existing, now as fallback for GitHub */}
|
||||
<Section
|
||||
title={provider === 'github' ? 'Or use Personal Access Token' : getTitle(provider)}
|
||||
variant={SectionVariant.InModal}
|
||||
hasGutter
|
||||
>
|
||||
{showUsername && (
|
||||
<TextInput
|
||||
hasBottomSpacing
|
||||
label="Username"
|
||||
value={username}
|
||||
variant={TextInputVariant.InModal}
|
||||
onChange={(ev) => onUserNameChanged(ev.target.value)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
hasBottomSpacing
|
||||
label="Username"
|
||||
value={username}
|
||||
label={passwordLabel}
|
||||
type={hidePassword ? 'password' : 'text'}
|
||||
value={password}
|
||||
variant={TextInputVariant.InModal}
|
||||
onChange={(ev) => onUserNameChanged(ev.target.value)}
|
||||
onChange={(ev) => onPasswordChanged(ev.target.value)}
|
||||
onFocus={() => setHidePassword(false)}
|
||||
onBlur={() => setHidePassword(true)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
hasBottomSpacing
|
||||
label={passwordLabel}
|
||||
type={hidePassword ? 'password' : 'text'}
|
||||
value={password}
|
||||
variant={TextInputVariant.InModal}
|
||||
onChange={(ev) => onPasswordChanged(ev.target.value)}
|
||||
onFocus={() => setHidePassword(false)}
|
||||
onBlur={() => setHidePassword(true)}
|
||||
/>
|
||||
|
||||
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
|
||||
{provider === 'github' && !password?.length && (
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How to create a personal access token
|
||||
</a>
|
||||
)}
|
||||
</Section>
|
||||
<Text hasBottomSpacing>The credentials are saved encrypted locally per project.</Text>
|
||||
{provider === 'github' && !password?.length && (
|
||||
<a
|
||||
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
How to create a personal access token
|
||||
</a>
|
||||
)}
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
339
packages/noodl-editor/src/main/github-oauth-handler.js
Normal file
339
packages/noodl-editor/src/main/github-oauth-handler.js
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* GitHubOAuthCallbackHandler
|
||||
*
|
||||
* Handles GitHub OAuth callback in Electron main process using custom protocol handler.
|
||||
* This enables Web OAuth Flow with organization/repository selection UI.
|
||||
*
|
||||
* @module noodl-editor/main
|
||||
* @since 1.1.0
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { ipcMain, BrowserWindow } = require('electron');
|
||||
|
||||
/**
|
||||
* GitHub OAuth credentials
|
||||
* Uses existing credentials from GitHubOAuthService
|
||||
*/
|
||||
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui';
|
||||
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375';
|
||||
|
||||
/**
|
||||
* Custom protocol for OAuth callback
|
||||
*/
|
||||
const OAUTH_PROTOCOL = 'noodl';
|
||||
const OAUTH_CALLBACK_PATH = 'github-callback';
|
||||
|
||||
/**
|
||||
* Manages GitHub OAuth using custom protocol handler
|
||||
*/
|
||||
class GitHubOAuthCallbackHandler {
|
||||
constructor() {
|
||||
this.pendingAuth = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle protocol callback from GitHub OAuth
|
||||
* Called when user is redirected to noodl://github-callback?code=XXX&state=YYY
|
||||
*/
|
||||
async handleProtocolCallback(url) {
|
||||
console.log('🔐 [GitHub OAuth] ========================================');
|
||||
console.log('🔐 [GitHub OAuth] PROTOCOL CALLBACK RECEIVED');
|
||||
console.log('🔐 [GitHub OAuth] URL:', url);
|
||||
console.log('🔐 [GitHub OAuth] ========================================');
|
||||
|
||||
try {
|
||||
// Parse the URL
|
||||
const parsedUrl = new URL(url);
|
||||
const params = parsedUrl.searchParams;
|
||||
|
||||
const code = params.get('code');
|
||||
const state = params.get('state');
|
||||
const error = params.get('error');
|
||||
const error_description = params.get('error_description');
|
||||
|
||||
// Handle OAuth error
|
||||
if (error) {
|
||||
console.error('[GitHub OAuth] Error from GitHub:', error, error_description);
|
||||
this.sendErrorToRenderer(error, error_description);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code || !state) {
|
||||
console.error('[GitHub OAuth] Missing code or state in callback');
|
||||
this.sendErrorToRenderer('invalid_request', 'Missing authorization code or state');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state (CSRF protection)
|
||||
if (!this.validateState(state)) {
|
||||
throw new Error('Invalid OAuth state - possible CSRF attack or expired');
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
const token = await this.exchangeCodeForToken(code);
|
||||
|
||||
// Fetch user info
|
||||
const user = await this.fetchUserInfo(token.access_token);
|
||||
|
||||
// Fetch installation info (organizations/repos)
|
||||
const installations = await this.fetchInstallations(token.access_token);
|
||||
|
||||
// Send result to renderer process
|
||||
this.sendSuccessToRenderer({
|
||||
token,
|
||||
user,
|
||||
installations,
|
||||
authMethod: 'web_oauth'
|
||||
});
|
||||
|
||||
// Clear pending auth
|
||||
this.pendingAuth = null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[GitHub OAuth] Callback handling error:', error);
|
||||
this.sendErrorToRenderer('token_exchange_failed', errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state for new flow
|
||||
*/
|
||||
generateOAuthState() {
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const now = Date.now();
|
||||
|
||||
this.pendingAuth = {
|
||||
state,
|
||||
verifier,
|
||||
createdAt: now,
|
||||
expiresAt: now + 300000 // 5 minutes
|
||||
};
|
||||
|
||||
return this.pendingAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state from callback
|
||||
*/
|
||||
validateState(receivedState) {
|
||||
if (!this.pendingAuth) {
|
||||
console.error('[GitHub OAuth] No pending auth state');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receivedState !== this.pendingAuth.state) {
|
||||
console.error('[GitHub OAuth] State mismatch');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() > this.pendingAuth.expiresAt) {
|
||||
console.error('[GitHub OAuth] State expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
async exchangeCodeForToken(code) {
|
||||
console.log('[GitHub OAuth] Exchanging code for access token');
|
||||
|
||||
const response = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: GITHUB_CLIENT_ID,
|
||||
client_secret: GITHUB_CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`GitHub OAuth error: ${data.error_description || data.error}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user information from GitHub
|
||||
*/
|
||||
async fetchUserInfo(token) {
|
||||
const response = await fetch('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch installation information (orgs/repos user granted access to)
|
||||
*/
|
||||
async fetchInstallations(token) {
|
||||
try {
|
||||
// Fetch user installations
|
||||
const response = await fetch('https://api.github.com/user/installations', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[GitHub OAuth] Failed to fetch installations:', response.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.installations || [];
|
||||
} catch (error) {
|
||||
console.warn('[GitHub OAuth] Error fetching installations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send success to renderer process
|
||||
*/
|
||||
sendSuccessToRenderer(result) {
|
||||
console.log('📤 [GitHub OAuth] ========================================');
|
||||
console.log('📤 [GitHub OAuth] SENDING IPC EVENT: github-oauth-complete');
|
||||
console.log('📤 [GitHub OAuth] User:', result.user.login);
|
||||
console.log('📤 [GitHub OAuth] Installations:', result.installations.length);
|
||||
console.log('📤 [GitHub OAuth] ========================================');
|
||||
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send('github-oauth-complete', result);
|
||||
console.log('✅ [GitHub OAuth] IPC event sent to renderer');
|
||||
} else {
|
||||
console.error('❌ [GitHub OAuth] No windows available to send IPC event!');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error to renderer process
|
||||
*/
|
||||
sendErrorToRenderer(error, description) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send('github-oauth-error', {
|
||||
error,
|
||||
message: description || error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization URL for OAuth flow
|
||||
*/
|
||||
getAuthorizationUrl(state) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: GITHUB_CLIENT_ID,
|
||||
redirect_uri: `${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`,
|
||||
scope: 'repo read:org read:user user:email',
|
||||
state,
|
||||
allow_signup: 'true'
|
||||
});
|
||||
|
||||
return `https://github.com/login/oauth/authorize?${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending OAuth flow
|
||||
*/
|
||||
cancelPendingAuth() {
|
||||
this.pendingAuth = null;
|
||||
console.log('[GitHub OAuth] Pending auth cancelled');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let handlerInstance = null;
|
||||
|
||||
/**
|
||||
* Initialize GitHub OAuth IPC handlers and protocol handler
|
||||
*/
|
||||
function initializeGitHubOAuthHandlers(app) {
|
||||
handlerInstance = new GitHubOAuthCallbackHandler();
|
||||
|
||||
// Register custom protocol handler
|
||||
if (!app.isDefaultProtocolClient(OAUTH_PROTOCOL)) {
|
||||
app.setAsDefaultProtocolClient(OAUTH_PROTOCOL);
|
||||
console.log(`[GitHub OAuth] Registered ${OAUTH_PROTOCOL}:// protocol handler`);
|
||||
}
|
||||
|
||||
// Handle protocol callback on macOS/Linux
|
||||
app.on('open-url', (event, url) => {
|
||||
event.preventDefault();
|
||||
if (url.startsWith(`${OAUTH_PROTOCOL}://${OAUTH_CALLBACK_PATH}`)) {
|
||||
handlerInstance.handleProtocolCallback(url);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle protocol callback on Windows (second instance)
|
||||
app.on('second-instance', (event, commandLine) => {
|
||||
// Find the protocol URL in command line args
|
||||
const protocolUrl = commandLine.find((arg) => arg.startsWith(`${OAUTH_PROTOCOL}://`));
|
||||
if (protocolUrl && protocolUrl.includes(OAUTH_CALLBACK_PATH)) {
|
||||
handlerInstance.handleProtocolCallback(protocolUrl);
|
||||
}
|
||||
|
||||
// Focus the main window
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
if (windows[0].isMinimized()) windows[0].restore();
|
||||
windows[0].focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle start OAuth flow request from renderer
|
||||
ipcMain.handle('github-oauth-start', async () => {
|
||||
try {
|
||||
const authState = handlerInstance.generateOAuthState();
|
||||
const authUrl = handlerInstance.getAuthorizationUrl(authState.state);
|
||||
|
||||
return { success: true, authUrl };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
});
|
||||
|
||||
// Handle stop OAuth flow request from renderer
|
||||
ipcMain.handle('github-oauth-stop', async () => {
|
||||
handlerInstance.cancelPendingAuth();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log('[GitHub OAuth] IPC handlers and protocol handler initialized');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GitHubOAuthCallbackHandler,
|
||||
initializeGitHubOAuthHandlers,
|
||||
OAUTH_PROTOCOL
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const { startCloudFunctionServer, closeRuntimeWhenWindowCloses } = require('./sr
|
||||
const DesignToolImportServer = require('./src/design-tool-import-server');
|
||||
const jsonstorage = require('../shared/utils/jsonstorage');
|
||||
const StorageApi = require('./src/StorageApi');
|
||||
const { initializeGitHubOAuthHandlers } = require('./github-oauth-handler');
|
||||
|
||||
const { handleProjectMerge } = require('./src/merge-driver');
|
||||
|
||||
@@ -542,6 +543,9 @@ function launchApp() {
|
||||
|
||||
setupGitHubOAuthIpc();
|
||||
|
||||
// Initialize Web OAuth handlers for GitHub (with protocol handler)
|
||||
initializeGitHubOAuthHandlers(app);
|
||||
|
||||
setupMainWindowControlIpc();
|
||||
|
||||
setupMenu();
|
||||
@@ -565,27 +569,12 @@ function launchApp() {
|
||||
console.log('open-url', uri);
|
||||
event.preventDefault();
|
||||
|
||||
// Handle GitHub OAuth callback
|
||||
if (uri.startsWith('noodl://github-callback')) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
if (code && state) {
|
||||
console.log('🔐 GitHub OAuth callback received');
|
||||
win && win.webContents.send('github-oauth-callback', { code, state });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse GitHub OAuth callback:', error);
|
||||
}
|
||||
// GitHub OAuth callbacks are handled by github-oauth-handler.js
|
||||
// Only handle other noodl:// URIs here
|
||||
if (!uri.startsWith('noodl://github-callback')) {
|
||||
win && win.webContents.send('open-noodl-uri', uri);
|
||||
process.env.noodlURI = uri;
|
||||
}
|
||||
|
||||
// Default noodl URI handling
|
||||
win && win.webContents.send('open-noodl-uri', uri);
|
||||
process.env.noodlURI = uri;
|
||||
// logEverywhere("open-url# " + deeplinkingUrl)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user