mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-11 14:52:55 +01:00
Added initial github integration
This commit is contained in:
@@ -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']}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './GitHubConnectButton';
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user