Added initial github integration

This commit is contained in:
Richard Osborne
2026-01-01 21:15:51 +01:00
parent cfaf78fb15
commit 2845b1b879
22 changed files with 7263 additions and 6 deletions

View File

@@ -19,7 +19,7 @@ import {
} from '@noodl-core-ui/preview/launcher/Launcher/components/LauncherProjectCard';
import { ViewMode } from '@noodl-core-ui/preview/launcher/Launcher/components/ViewModeToggle';
import { usePersistentTab } from '@noodl-core-ui/preview/launcher/Launcher/hooks/usePersistentTab';
import { LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { GitHubUser, LauncherPageId, LauncherProvider } from '@noodl-core-ui/preview/launcher/Launcher/LauncherContext';
import { LearningCenter } from '@noodl-core-ui/preview/launcher/Launcher/views/LearningCenter';
import { Projects } from '@noodl-core-ui/preview/launcher/Launcher/views/Projects';
import { Templates } from '@noodl-core-ui/preview/launcher/Launcher/views/Templates';
@@ -42,6 +42,13 @@ export interface LauncherProps {
onLaunchProject?: (projectId: string) => void;
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
// GitHub OAuth integration (optional - for Storybook compatibility)
githubUser?: GitHubUser | null;
githubIsAuthenticated?: boolean;
githubIsConnecting?: boolean;
onGitHubConnect?: () => void;
onGitHubDisconnect?: () => void;
}
// Tab configuration
@@ -168,7 +175,12 @@ export function Launcher({
onOpenProject,
onLaunchProject,
onOpenProjectFolder,
onDeleteProject
onDeleteProject,
githubUser,
githubIsAuthenticated,
githubIsConnecting,
onGitHubConnect,
onGitHubDisconnect
}: LauncherProps) {
// Determine initial tab: props > deep link > persisted > default
const deepLinkTab = parseDeepLink();
@@ -289,7 +301,12 @@ export function Launcher({
onOpenProject,
onLaunchProject,
onOpenProjectFolder,
onDeleteProject
onDeleteProject,
githubUser,
githubIsAuthenticated,
githubIsConnecting,
onGitHubConnect,
onGitHubDisconnect
}}
>
<div className={css['Root']}>

View File

@@ -16,6 +16,16 @@ export { ViewMode };
export type LauncherPageId = 'projects' | 'learn' | 'templates';
// GitHub user info (matches GitHubOAuthService interface)
export interface GitHubUser {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
html_url: string;
}
export interface LauncherContextValue {
activePageId: LauncherPageId;
setActivePageId: (pageId: LauncherPageId) => void;
@@ -36,6 +46,13 @@ export interface LauncherContextValue {
onLaunchProject?: (projectId: string) => void;
onOpenProjectFolder?: (projectId: string) => void;
onDeleteProject?: (projectId: string) => void;
// GitHub OAuth integration (optional - for Storybook compatibility)
githubUser?: GitHubUser | null;
githubIsAuthenticated?: boolean;
githubIsConnecting?: boolean;
onGitHubConnect?: () => void;
onGitHubDisconnect?: () => void;
}
const LauncherContext = createContext<LauncherContextValue | null>(null);

View File

@@ -0,0 +1,10 @@
.Root {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-8);
}
.Description {
display: none; /* Hide description to keep header compact */
}

View File

@@ -0,0 +1,29 @@
/**
* GitHubConnectButton
*
* Button component for initiating GitHub OAuth flow
*/
import React from 'react';
import { PrimaryButton } from '@noodl-core-ui/components/inputs/PrimaryButton';
import css from './GitHubConnectButton.module.scss';
export interface GitHubConnectButtonProps {
onConnect: () => void;
isConnecting?: boolean;
}
export function GitHubConnectButton({ onConnect, isConnecting = false }: GitHubConnectButtonProps) {
return (
<div className={css.Root}>
<PrimaryButton
label={isConnecting ? 'Connecting...' : 'Connect with GitHub'}
onClick={onConnect}
isDisabled={isConnecting}
/>
<p className={css.Description}>Connect to access your repositories and enable version control features.</p>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './GitHubConnectButton';

View File

@@ -18,6 +18,7 @@ import { TextType } from '@noodl-core-ui/components/typography/Text';
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
import { useLauncherContext } from '../../LauncherContext';
import { GitHubConnectButton } from '../GitHubConnectButton';
import css from './LauncherHeader.module.scss';
const VERSION_NUMBER = '2.9.3';
@@ -25,7 +26,8 @@ const VERSION_NUMBER = '2.9.3';
export interface LauncherHeaderProps {}
export function LauncherHeader({}: LauncherHeaderProps) {
const { useMockData, setUseMockData, hasRealProjects } = useLauncherContext();
const { useMockData, setUseMockData, hasRealProjects, githubIsAuthenticated, githubIsConnecting, onGitHubConnect } =
useLauncherContext();
const handleToggleDataSource = () => {
setUseMockData(!useMockData);
@@ -42,6 +44,11 @@ export function LauncherHeader({}: LauncherHeaderProps) {
</Title>
</div>
<div className={css['Actions']}>
{/* GitHub OAuth Button - Show when not authenticated */}
{!githubIsAuthenticated && onGitHubConnect && (
<GitHubConnectButton onConnect={onGitHubConnect} isConnecting={githubIsConnecting} />
)}
{hasRealProjects && (
<div className={css['DataSourceToggle']}>
<TextButton

View File

@@ -14,9 +14,11 @@ 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 { LocalProjectsModel, ProjectItem } from '../../utils/LocalProjectsModel';
import { ToastLayer } from '../../views/ToastLayer/ToastLayer';
@@ -45,19 +47,59 @@ export function ProjectsPage(props: ProjectsPageProps) {
// Real projects from LocalProjectsModel
const [realProjects, setRealProjects] = useState<LauncherProjectData[]>([]);
// Fetch projects on mount
// GitHub OAuth state
const [githubUser, setGithubUser] = useState<GitHubUser | null>(null);
const [githubIsAuthenticated, setGithubIsAuthenticated] = useState<boolean>(false);
const [githubIsConnecting, setGithubIsConnecting] = useState<boolean>(false);
// Initialize and fetch projects on mount
useEffect(() => {
// Switch main window size to editor size
ipcRenderer.send('main-window-resize', { size: 'editor', center: true });
// Initial load
// 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();
const projects = LocalProjectsModel.instance.getProjects();
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
@@ -67,6 +109,44 @@ 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(async () => {
try {
const direntry = await filesystem.openDialog({
@@ -236,6 +316,17 @@ 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
projects={realProjects}
@@ -244,6 +335,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
onLaunchProject={handleLaunchProject}
onOpenProjectFolder={handleOpenProjectFolder}
onDeleteProject={handleDeleteProject}
githubUser={githubUser}
githubIsAuthenticated={githubIsAuthenticated}
githubIsConnecting={githubIsConnecting}
onGitHubConnect={handleGitHubConnect}
onGitHubDisconnect={handleGitHubDisconnect}
/>
);
}

View File

@@ -0,0 +1,356 @@
/**
* GitHubOAuthService
*
* Manages GitHub OAuth authentication using PKCE flow.
* Provides token management and user information retrieval.
*
* @module noodl-editor/services
*/
import crypto from 'crypto';
import { shell } from 'electron';
import { EventDispatcher } from '../../../shared/utils/EventDispatcher';
/**
* IMPORTANT: GitHub App Setup Instructions
*
* This service uses PKCE (Proof Key for Code Exchange) combined with a client secret.
*
* To set up:
* 1. Go to https://github.com/settings/apps/new
* 2. Fill in:
* - GitHub App name: "OpenNoodl" (or your choice)
* - Homepage URL: https://github.com/The-Low-Code-Foundation/OpenNoodl
* - Callback URL: noodl://github-callback
* - Check "Request user authorization (OAuth) during installation"
* - Uncheck "Webhook > Active"
* - Permissions:
* * Repository permissions → Contents: Read and write
* * Account permissions → Email addresses: Read-only
* 3. Click "Create GitHub App"
* 4. Copy the Client ID
* 5. Generate a Client Secret and copy it
* 6. Update GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET below
*
* Security Note:
* While storing client secrets in desktop apps is not ideal (they can be extracted),
* this is GitHub's requirement for token exchange. The PKCE flow still adds security
* by preventing authorization code interception attacks.
*/
const GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID || 'Iv23lib1WdrimUdyvZui'; // Replace with your GitHub App Client ID
const GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET || '9bd56694d6d300bf86b1999bab523b32654ec375'; // Replace with your GitHub App Client Secret
const GITHUB_REDIRECT_URI = 'noodl://github-callback';
const GITHUB_SCOPES = ['repo', 'read:org', 'read:user'];
export interface GitHubUser {
id: number;
login: string;
name: string | null;
email: string | null;
avatar_url: string;
html_url: string;
}
export interface GitHubOrganization {
id: number;
login: string;
avatar_url: string;
description: string | null;
}
interface GitHubToken {
access_token: string;
token_type: string;
scope: string;
}
interface PKCEChallenge {
verifier: string;
challenge: string;
state: string;
}
/**
* Service for managing GitHub OAuth authentication
*/
export class GitHubOAuthService extends EventDispatcher {
private static _instance: GitHubOAuthService;
private currentUser: GitHubUser | null = null;
private accessToken: string | null = null;
private pendingPKCE: PKCEChallenge | null = null;
private constructor() {
super();
}
static get instance(): GitHubOAuthService {
if (!GitHubOAuthService._instance) {
GitHubOAuthService._instance = new GitHubOAuthService();
}
return GitHubOAuthService._instance;
}
/**
* Generate PKCE challenge for secure OAuth flow
*/
private generatePKCE(): PKCEChallenge {
// Generate code verifier (random string)
const verifier = crypto.randomBytes(32).toString('base64url');
// Generate code challenge (SHA256 hash of verifier)
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
// Generate state for CSRF protection
const state = crypto.randomBytes(16).toString('hex');
return { verifier, challenge, state };
}
/**
* Initiate OAuth flow by opening GitHub authorization in browser
*/
async initiateOAuth(): Promise<void> {
console.log('🔐 Initiating GitHub OAuth flow');
// Generate PKCE challenge
this.pendingPKCE = this.generatePKCE();
// Build authorization URL
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: GITHUB_REDIRECT_URI,
scope: GITHUB_SCOPES.join(' '),
state: this.pendingPKCE.state,
code_challenge: this.pendingPKCE.challenge,
code_challenge_method: 'S256'
});
const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}`;
console.log('🌐 Opening GitHub authorization URL:', authUrl);
// Open in system browser
await shell.openExternal(authUrl);
// Notify listeners that OAuth flow started
this.notifyListeners('oauth-started');
}
/**
* Handle OAuth callback with authorization code
*/
async handleCallback(code: string, state: string): Promise<void> {
console.log('🔄 Handling OAuth callback');
try {
// Verify state to prevent CSRF
if (!this.pendingPKCE || state !== this.pendingPKCE.state) {
throw new Error('Invalid OAuth state - possible CSRF attack');
}
// Exchange code for token
const token = await this.exchangeCodeForToken(code, this.pendingPKCE.verifier);
// Store token
this.accessToken = token.access_token;
// Clear pending PKCE
this.pendingPKCE = null;
// Fetch user information
await this.fetchCurrentUser();
// Persist token securely
await this.saveToken(token.access_token);
console.log('✅ GitHub OAuth successful, user:', this.currentUser?.login);
// Notify listeners
this.notifyListeners('oauth-success', { user: this.currentUser });
this.notifyListeners('auth-state-changed', { authenticated: true });
} catch (error) {
console.error('❌ OAuth callback error:', error);
this.pendingPKCE = null;
this.notifyListeners('oauth-error', { error: error.message });
throw error;
}
}
/**
* Exchange authorization code for access token
*/
private async exchangeCodeForToken(code: string, verifier: string): Promise<GitHubToken> {
console.log('🔄 Exchanging code for access token');
// Exchange authorization code for access token using PKCE + client secret
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,
code_verifier: verifier,
redirect_uri: GITHUB_REDIRECT_URI
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to exchange code for token: ${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 current user information from GitHub API
*/
private async fetchCurrentUser(): Promise<void> {
if (!this.accessToken) {
throw new Error('No access token available');
}
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch user info: ${response.status}`);
}
this.currentUser = await response.json();
}
/**
* Get organizations for current user
*/
async getOrganizations(): Promise<GitHubOrganization[]> {
if (!this.accessToken) {
throw new Error('Not authenticated');
}
const response = await fetch('https://api.github.com/user/orgs', {
headers: {
Authorization: `Bearer ${this.accessToken}`,
Accept: 'application/vnd.github.v3+json'
}
});
if (!response.ok) {
throw new Error(`Failed to fetch organizations: ${response.status}`);
}
return await response.json();
}
/**
* Get current access token
*/
async getToken(): Promise<string | null> {
if (!this.accessToken) {
// Try to load from storage
await this.loadToken();
}
return this.accessToken;
}
/**
* Get current authenticated user
*/
getCurrentUser(): GitHubUser | null {
return this.currentUser;
}
/**
* Check if user is authenticated
*/
isAuthenticated(): boolean {
return this.accessToken !== null && this.currentUser !== null;
}
/**
* Revoke token and disconnect
*/
async disconnect(): Promise<void> {
console.log('🔌 Disconnecting GitHub account');
this.accessToken = null;
this.currentUser = null;
// Clear stored token
await this.clearToken();
// Notify listeners
this.notifyListeners('auth-state-changed', { authenticated: false });
this.notifyListeners('disconnected');
}
/**
* Save token securely using Electron's safeStorage
*/
private async saveToken(token: string): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('github-save-token', token);
} catch (error) {
console.error('Failed to save token:', error);
// Fallback: keep in memory only
}
}
/**
* Load token from secure storage
*/
private async loadToken(): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
const token = await ipcRenderer.invoke('github-load-token');
if (token) {
this.accessToken = token;
// Fetch user info to verify token is still valid
await this.fetchCurrentUser();
this.notifyListeners('auth-state-changed', { authenticated: true });
}
} catch (error) {
console.error('Failed to load token:', error);
// Token may be invalid, clear it
this.accessToken = null;
this.currentUser = null;
}
}
/**
* Clear stored token
*/
private async clearToken(): Promise<void> {
try {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('github-clear-token');
} catch (error) {
console.error('Failed to clear token:', error);
}
}
/**
* Initialize service and restore session if available
*/
async initialize(): Promise<void> {
console.log('🔧 Initializing GitHubOAuthService');
await this.loadToken();
}
}

View File

@@ -540,6 +540,8 @@ function launchApp() {
setupFloatingWindowIpc();
setupGitHubOAuthIpc();
setupMainWindowControlIpc();
setupMenu();
@@ -562,6 +564,25 @@ function launchApp() {
app.on('open-url', function (event, uri) {
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);
}
}
// Default noodl URI handling
win && win.webContents.send('open-noodl-uri', uri);
process.env.noodlURI = uri;
// logEverywhere("open-url# " + deeplinkingUrl)
@@ -622,6 +643,67 @@ function launchApp() {
});
}
// --------------------------------------------------------------------------------------------------------------------
// GitHub OAuth
// --------------------------------------------------------------------------------------------------------------------
function setupGitHubOAuthIpc() {
const { safeStorage } = require('electron');
// Save GitHub token securely
ipcMain.handle('github-save-token', async (event, token) => {
try {
if (safeStorage.isEncryptionAvailable()) {
const encrypted = safeStorage.encryptString(token);
jsonstorage.set('github.token', encrypted.toString('base64'));
console.log('✅ GitHub token saved securely');
} else {
console.warn('⚠️ Encryption not available, storing token in plain text');
jsonstorage.set('github.token', token);
}
} catch (error) {
console.error('Failed to save GitHub token:', error);
throw error;
}
});
// Load GitHub token
ipcMain.handle('github-load-token', async (event) => {
try {
const stored = jsonstorage.getSync('github.token');
if (!stored) return null;
if (safeStorage.isEncryptionAvailable()) {
try {
const buffer = Buffer.from(stored, 'base64');
const decrypted = safeStorage.decryptString(buffer);
console.log('✅ GitHub token loaded');
return decrypted;
} catch (error) {
console.error('Failed to decrypt token, may be corrupted:', error);
return null;
}
} else {
// Fallback: token was stored in plain text
return stored;
}
} catch (error) {
console.error('Failed to load GitHub token:', error);
return null;
}
});
// Clear GitHub token
ipcMain.handle('github-clear-token', async (event) => {
try {
jsonstorage.set('github.token', null);
console.log('✅ GitHub token cleared');
} catch (error) {
console.error('Failed to clear GitHub token:', error);
throw error;
}
});
}
// --------------------------------------------------------------------------------------------------------------------
// Main window control
// --------------------------------------------------------------------------------------------------------------------