mirror of https://github.com/kortix-ai/suna.git
Merge pull request #569 from Dharrnn/feat-github-login
This commit is contained in:
commit
6d7114c5ed
|
@ -0,0 +1,207 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface AuthMessage {
|
||||
type: 'github-auth-success' | 'github-auth-error';
|
||||
message?: string;
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export default function GitHubOAuthPopup() {
|
||||
const [status, setStatus] = useState<'loading' | 'processing' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
|
||||
// Get return URL from sessionStorage (set by parent component)
|
||||
const returnUrl =
|
||||
sessionStorage.getItem('github-returnUrl') || '/dashboard';
|
||||
|
||||
const postMessage = (message: AuthMessage) => {
|
||||
try {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(message, window.location.origin);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to post message to opener:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setStatus('processing');
|
||||
postMessage({
|
||||
type: 'github-auth-success',
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
// Close popup after short delay
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleError = (message: string) => {
|
||||
setStatus('error');
|
||||
setErrorMessage(message);
|
||||
postMessage({
|
||||
type: 'github-auth-error',
|
||||
message,
|
||||
});
|
||||
|
||||
// Close popup after delay to show error
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleOAuth = async () => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isCallback = urlParams.has('code');
|
||||
const hasError = urlParams.has('error');
|
||||
|
||||
// Handle OAuth errors
|
||||
if (hasError) {
|
||||
const error = urlParams.get('error');
|
||||
const errorDescription = urlParams.get('error_description');
|
||||
throw new Error(errorDescription || error || 'GitHub OAuth error');
|
||||
}
|
||||
|
||||
if (isCallback) {
|
||||
// This is the callback from GitHub
|
||||
setStatus('processing');
|
||||
|
||||
try {
|
||||
// Wait a moment for Supabase to process the session
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (session?.user) {
|
||||
handleSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
// If no session yet, listen for auth state change
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'SIGNED_IN' && session?.user) {
|
||||
subscription.unsubscribe();
|
||||
handleSuccess();
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
subscription.unsubscribe();
|
||||
handleError('Authentication failed - please try again');
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup subscription after timeout
|
||||
setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
handleError('Authentication timeout - please try again');
|
||||
}, 10000); // 10 second timeout
|
||||
} catch (authError: any) {
|
||||
console.error('Auth processing error:', authError);
|
||||
handleError(authError.message || 'Authentication failed');
|
||||
}
|
||||
} else {
|
||||
// Start the OAuth flow
|
||||
setStatus('loading');
|
||||
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/auth/github-popup`,
|
||||
queryParams: {
|
||||
access_type: 'online',
|
||||
prompt: 'select_account',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('OAuth error:', err);
|
||||
handleError(err.message || 'Failed to authenticate with GitHub');
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup sessionStorage when popup closes
|
||||
const handleBeforeUnload = () => {
|
||||
sessionStorage.removeItem('github-returnUrl');
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
// Start OAuth process
|
||||
handleOAuth();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getStatusMessage = () => {
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return 'Starting GitHub authentication...';
|
||||
case 'processing':
|
||||
return 'Completing sign-in...';
|
||||
case 'error':
|
||||
return errorMessage || 'Authentication failed';
|
||||
default:
|
||||
return 'Processing...';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
case 'processing':
|
||||
return 'text-green-500';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center justify-center h-screen bg-background p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
|
||||
{status !== 'error' && (
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-lg font-medium">GitHub Sign-In</h1>
|
||||
<p className={`text-sm ${getStatusColor()}`}>{getStatusMessage()}</p>
|
||||
</div>
|
||||
|
||||
{status === 'error' && (
|
||||
<button
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
|
@ -28,6 +28,7 @@ import {
|
|||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import GitHubSignIn from '@/components/GithubSignIn';
|
||||
|
||||
function LoginContent() {
|
||||
const router = useRouter();
|
||||
|
@ -387,11 +388,17 @@ function LoginContent() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Sign In */}
|
||||
<div className="w-full">
|
||||
<GoogleSignIn returnUrl={returnUrl || undefined} />
|
||||
{/* OAuth Sign In */}
|
||||
<div className="w-full flex flex-col gap-3 mb-6">
|
||||
<div className="w-full">
|
||||
<GoogleSignIn returnUrl={returnUrl || undefined} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<GitHubSignIn returnUrl={returnUrl || undefined} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { toast } from 'sonner';
|
||||
import { Icons } from './home/icons';
|
||||
|
||||
interface GitHubSignInProps {
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
interface AuthMessage {
|
||||
type: 'github-auth-success' | 'github-auth-error';
|
||||
message?: string;
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export default function GitHubSignIn({ returnUrl }: GitHubSignInProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
// Cleanup function to handle auth state
|
||||
const cleanupAuthState = useCallback(() => {
|
||||
sessionStorage.removeItem('isGitHubAuthInProgress');
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
// Handle success message
|
||||
const handleSuccess = useCallback(
|
||||
(data: AuthMessage) => {
|
||||
cleanupAuthState();
|
||||
|
||||
// Add a small delay to ensure state is properly cleared
|
||||
setTimeout(() => {
|
||||
window.location.href = data.returnUrl || returnUrl || '/dashboard';
|
||||
}, 100);
|
||||
},
|
||||
[cleanupAuthState, returnUrl],
|
||||
);
|
||||
|
||||
// Handle error message
|
||||
const handleError = useCallback(
|
||||
(data: AuthMessage) => {
|
||||
cleanupAuthState();
|
||||
toast.error(data.message || 'GitHub sign-in failed. Please try again.');
|
||||
},
|
||||
[cleanupAuthState],
|
||||
);
|
||||
|
||||
// Message event handler
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent<AuthMessage>) => {
|
||||
// Security: Only accept messages from same origin
|
||||
if (event.origin !== window.location.origin) {
|
||||
console.warn(
|
||||
'Rejected message from unauthorized origin:',
|
||||
event.origin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message structure
|
||||
if (!event.data?.type || typeof event.data.type !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.data.type) {
|
||||
case 'github-auth-success':
|
||||
handleSuccess(event.data);
|
||||
break;
|
||||
case 'github-auth-error':
|
||||
handleError(event.data);
|
||||
break;
|
||||
default:
|
||||
// Ignore unknown message types
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, [handleSuccess, handleError]);
|
||||
|
||||
// Cleanup on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAuthState();
|
||||
};
|
||||
}, [cleanupAuthState]);
|
||||
|
||||
const handleGitHubSignIn = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
let popupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Store return URL for the popup
|
||||
if (returnUrl) {
|
||||
sessionStorage.setItem('github-returnUrl', returnUrl || '/dashboard');
|
||||
}
|
||||
|
||||
// Open popup with proper dimensions and features
|
||||
const popup = window.open(
|
||||
`${window.location.origin}/auth/github-popup`,
|
||||
'GitHubOAuth',
|
||||
'width=500,height=600,scrollbars=yes,resizable=yes,status=yes,location=yes',
|
||||
);
|
||||
|
||||
if (!popup) {
|
||||
throw new Error(
|
||||
'Popup was blocked. Please enable popups and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
// Set loading state and track popup
|
||||
sessionStorage.setItem('isGitHubAuthInProgress', '1');
|
||||
|
||||
// Monitor popup closure
|
||||
popupInterval = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
if (popupInterval) {
|
||||
clearInterval(popupInterval);
|
||||
popupInterval = null;
|
||||
}
|
||||
|
||||
// Small delay to allow postMessage to complete
|
||||
setTimeout(() => {
|
||||
if (sessionStorage.getItem('isGitHubAuthInProgress')) {
|
||||
cleanupAuthState();
|
||||
toast.error('GitHub sign-in was cancelled or not completed.');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('GitHub sign-in error:', error);
|
||||
if (popupInterval) {
|
||||
clearInterval(popupInterval);
|
||||
}
|
||||
cleanupAuthState();
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to start GitHub sign-in',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Matched the button with the GoogleSignIn component
|
||||
<button
|
||||
onClick={handleGitHubSignIn}
|
||||
disabled={isLoading}
|
||||
className="relative w-full h-12 flex items-center justify-center text-sm font-normal tracking-wide rounded-full bg-background text-foreground border border-border hover:bg-accent/30 transition-all duration-200 disabled:opacity-60 disabled:cursor-not-allowed font-sans"
|
||||
aria-label={
|
||||
isLoading ? 'Signing in with GitHub...' : 'Sign in with GitHub'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<div className="absolute left-0 inset-y-0 flex items-center pl-1 w-10">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-foreground dark:bg-foreground dark:text-background">
|
||||
{isLoading ? (
|
||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Icons.github className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="ml-9 font-light">
|
||||
{isLoading ? 'Signing in...' : 'Continue with GitHub'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -2461,4 +2461,25 @@ export const Icons = {
|
|||
</defs>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
github: ({
|
||||
className,
|
||||
color = 'currentColor',
|
||||
}: {
|
||||
className?: string;
|
||||
color?: string;
|
||||
}) => (
|
||||
<svg
|
||||
className={cn('w-9 h-9', className)}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill={color}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 0C5.37 0 0 5.373 0 12.01c0 5.303 3.438 9.8 8.207 11.387.6.113.82-.26.82-.577v-2.256c-3.338.727-4.033-1.416-4.033-1.416-.546-1.39-1.333-1.76-1.333-1.76-1.09-.745.082-.729.082-.729 1.205.085 1.84 1.26 1.84 1.26 1.07 1.836 2.807 1.306 3.492.998.108-.775.42-1.307.763-1.606-2.665-.307-5.466-1.34-5.466-5.968 0-1.318.47-2.396 1.24-3.24-.125-.307-.537-1.545.116-3.22 0 0 1.008-.324 3.3 1.23a11.44 11.44 0 013.006-.404c1.02.005 2.047.137 3.006.404 2.29-1.554 3.297-1.23 3.297-1.23.655 1.675.243 2.913.12 3.22.77.844 1.237 1.922 1.237 3.24 0 4.64-2.805 5.658-5.48 5.96.43.37.814 1.103.814 2.222v3.293c0 .32.216.694.825.577C20.565 21.807 24 17.31 24 12.01 24 5.373 18.627 0 12 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
|
@ -31,6 +31,7 @@ import { useAccounts } from '@/hooks/use-accounts';
|
|||
import { isLocalMode, config } from '@/lib/config';
|
||||
import { toast } from 'sonner';
|
||||
import { useModal } from '@/hooks/use-modal-store';
|
||||
import GitHubSignIn from '@/components/GithubSignIn';
|
||||
import { ChatInput, ChatInputHandles } from '@/components/thread/chat-input/chat-input';
|
||||
import { normalizeFilenameToNFC } from '@/lib/utils/unicode';
|
||||
import { createQueryHook } from '@/hooks/use-query';
|
||||
|
@ -354,9 +355,10 @@ export function HeroSection() {
|
|||
|
||||
|
||||
|
||||
{/* Google Sign In */}
|
||||
{/* OAuth Sign In */}
|
||||
<div className="w-full">
|
||||
<GoogleSignIn returnUrl="/dashboard" />
|
||||
<GitHubSignIn returnUrl="/dashboard" />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
|
|
Loading…
Reference in New Issue