Skip to content

Commit 4f4bc2d

Browse files
authored
Add iframe based user login and pdf file support (#90)
Add iframe based user login and pdf/image file support
1 parent c9f740c commit 4f4bc2d

File tree

14 files changed

+909
-49
lines changed

14 files changed

+909
-49
lines changed

src/app/ai/AuthButton.tsx

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
'use client';
2+
3+
import cn from "classnames";
4+
import { useState, useEffect, useRef } from "react";
5+
import { API_BASE, checkAuthSession } from "@/lib/grapes-api";
6+
import { useAuthContext } from "./AuthContext";
7+
8+
interface AuthButtonProps {
9+
readonly isMobile?: boolean;
10+
readonly onMobileMenuClose?: () => void;
11+
readonly showLogin?: boolean;
12+
readonly showUserProfile?: boolean;
13+
}
14+
15+
export function AuthButton({
16+
isMobile = false,
17+
onMobileMenuClose,
18+
showLogin = true,
19+
showUserProfile = true,
20+
}: AuthButtonProps) {
21+
const authContext = useAuthContext();
22+
23+
const [showUserMenu, setShowUserMenu] = useState(false);
24+
const userMenuRef = useRef<HTMLDivElement>(null);
25+
26+
// Since we are now guaranteed to be inside AuthProvider, context should exist.
27+
// However, for safety (e.g. if used outside provider accidentally), we can have a safe check.
28+
const user = authContext?.authSession?.isAuthenticated ? authContext.authSession.user : null;
29+
30+
useEffect(() => {
31+
const handleClickOutside = (event: PointerEvent) => {
32+
if (userMenuRef.current && !userMenuRef.current.contains(event.target as Node)) {
33+
setShowUserMenu(false);
34+
}
35+
};
36+
37+
if (showUserMenu) {
38+
document.addEventListener('pointerdown', handleClickOutside);
39+
}
40+
41+
return () => {
42+
document.removeEventListener('pointerdown', handleClickOutside);
43+
};
44+
}, [showUserMenu]);
45+
46+
const handleLogin = (e: React.MouseEvent) => {
47+
e.preventDefault();
48+
onMobileMenuClose?.();
49+
authContext?.triggerAuth();
50+
};
51+
52+
const handleSignOut = async () => {
53+
setShowUserMenu(false);
54+
onMobileMenuClose?.();
55+
authContext?.logout();
56+
};
57+
58+
const handleDashboardClick = () => {
59+
onMobileMenuClose?.();
60+
};
61+
62+
const userInitial = (user?.name || user?.email || 'U')[0].toUpperCase();
63+
const userName = user?.name || 'User';
64+
const userEmail = user?.email;
65+
const avatarSize = isMobile ? "w-10 h-10" : "w-8 h-8";
66+
67+
return (
68+
<>
69+
{/* Login Button - shown when not authenticated */}
70+
{!user && showLogin && (
71+
<button
72+
onClick={handleLogin}
73+
className={cn(
74+
"font-semibold text-gray-100 no-underline border border-gray-600 rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-800 hover:border-gray-500",
75+
isMobile
76+
? "mt-4 w-full text-center px-6 py-3 text-base"
77+
: "inline-block px-4 py-2 text-sm leading-5 sm:px-5 sm:py-2 lg:px-6 lg:py-2 whitespace-nowrap"
78+
)}
79+
>
80+
Login
81+
</button>
82+
)}
83+
84+
{/* Mobile Authenticated View */}
85+
{user && isMobile && showUserProfile && (
86+
<>
87+
<div className="mt-4 px-4 py-3 bg-zinc-800 rounded-lg flex items-center gap-3">
88+
{user.image ? (
89+
<img src={user.image} alt={userName} className={cn("rounded-full", avatarSize)} />
90+
) : (
91+
<div className={cn("rounded-full bg-violet-600 flex items-center justify-center text-white font-semibold", avatarSize)}>
92+
{userInitial}
93+
</div>
94+
)}
95+
<div className="flex-1">
96+
<p className="text-sm font-medium text-white">{userName}</p>
97+
<p className="text-xs text-gray-400">{userEmail}</p>
98+
</div>
99+
</div>
100+
<a
101+
href={`${API_BASE}/dashboard`}
102+
className="mt-2 w-full text-center px-6 py-3 text-base font-semibold text-gray-100 no-underline border border-gray-600 rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-800 hover:border-gray-500"
103+
onClick={handleDashboardClick}
104+
>
105+
Dashboard
106+
</a>
107+
<button
108+
onClick={handleSignOut}
109+
className="mt-2 w-full text-center px-6 py-3 text-base font-semibold text-gray-100 no-underline border border-gray-600 rounded-lg cursor-pointer transition-all duration-200 hover:bg-gray-800 hover:border-gray-500"
110+
>
111+
Sign Out
112+
</button>
113+
</>
114+
)}
115+
116+
{/* Desktop Authenticated View */}
117+
{user && !isMobile && showUserProfile && (
118+
<div className="relative" ref={userMenuRef}>
119+
<button
120+
onClick={() => setShowUserMenu(!showUserMenu)}
121+
className="flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-800/50 transition-colors"
122+
aria-label="User menu"
123+
>
124+
{user.image ? (
125+
<img src={user.image} alt={userName} className={cn("rounded-full", avatarSize)} />
126+
) : (
127+
<div className={cn("rounded-full bg-violet-600 flex items-center justify-center text-white font-semibold", avatarSize)}>
128+
{userInitial}
129+
</div>
130+
)}
131+
<svg
132+
className={cn("w-4 h-4 text-gray-400 transition-transform max-lg:hidden", showUserMenu && "rotate-180")}
133+
fill="none"
134+
stroke="currentColor"
135+
viewBox="0 0 24 24"
136+
>
137+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
138+
</svg>
139+
</button>
140+
141+
{showUserMenu && (
142+
<div className="absolute right-0 mt-2 w-56 bg-zinc-900 rounded-lg shadow-lg border border-zinc-700 overflow-hidden z-50">
143+
<div className="px-4 py-3 border-b border-zinc-700">
144+
<p className="text-sm font-medium text-white">{userName}</p>
145+
<p className="text-xs text-gray-400 truncate">{userEmail}</p>
146+
</div>
147+
<div className="py-1">
148+
<a
149+
href={`${API_BASE}/dashboard`}
150+
className="block px-4 py-2 text-sm text-gray-300 hover:bg-zinc-800 hover:text-white transition-colors no-underline"
151+
>
152+
Dashboard
153+
</a>
154+
<button
155+
onClick={handleSignOut}
156+
className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-zinc-800 hover:text-white transition-colors"
157+
>
158+
Sign Out
159+
</button>
160+
</div>
161+
</div>
162+
)}
163+
</div>
164+
)}
165+
</>
166+
);
167+
}

src/app/ai/AuthContext.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { createContext, useContext } from 'react';
2+
import { UseAuthResult } from './useAuth';
3+
4+
export const AuthContext = createContext<UseAuthResult | null>(null);
5+
6+
export function useAuthContext() {
7+
return useContext(AuthContext);
8+
}

src/app/ai/AuthIframe.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use client';
2+
3+
import { useEffect, useRef } from 'react';
4+
import { createPortal } from 'react-dom';
5+
import { API_BASE, checkAuthSession, type AuthUser } from '@/lib/grapes-api';
6+
7+
const IFRAME_MIN_HEIGHT = 700;
8+
const ALLOWED_ORIGINS = [
9+
'https://app.grapesjs.com',
10+
'https://app-staging.grapesjs.com',
11+
'http://localhost:3000',
12+
'http://localhost:3001',
13+
];
14+
15+
interface AuthIframeProps {
16+
readonly onAuthSuccess: (user: AuthUser) => void;
17+
readonly onClose: () => void;
18+
}
19+
20+
export function AuthIframe({ onAuthSuccess, onClose }: AuthIframeProps) {
21+
const iframeRef = useRef<HTMLIFrameElement>(null);
22+
23+
useEffect(() => {
24+
const handleMessage = async (event: MessageEvent<any>) => {
25+
if (!ALLOWED_ORIGINS.includes(event.origin)) return;
26+
27+
const message = event.data;
28+
29+
if (message.type === 'AUTH_SUCCESS') {
30+
try {
31+
const result = await checkAuthSession();
32+
if (result.isAuthenticated && result.user) {
33+
onAuthSuccess(result.user);
34+
onClose();
35+
}
36+
} catch (error) {
37+
console.error('Auth session check failed:', error);
38+
}
39+
} else if (message.type === 'AUTH_CANCELLED') {
40+
onClose();
41+
}
42+
};
43+
44+
const handleKeyDown = (e: KeyboardEvent) => {
45+
if (e.key === 'Escape') {
46+
onClose();
47+
}
48+
};
49+
50+
window.addEventListener('message', handleMessage);
51+
window.addEventListener('keydown', handleKeyDown);
52+
53+
return () => {
54+
window.removeEventListener('message', handleMessage);
55+
window.removeEventListener('keydown', handleKeyDown);
56+
};
57+
}, [onAuthSuccess, onClose]);
58+
59+
const modalContent = (
60+
<div
61+
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
62+
role="dialog"
63+
aria-modal="true"
64+
onClick={(e) => e.target === e.currentTarget && onClose()}
65+
>
66+
<div className="relative w-full max-w-md">
67+
<div className="bg-zinc-900 rounded-2xl overflow-hidden shadow-2xl border border-zinc-800">
68+
<iframe
69+
ref={iframeRef}
70+
src={`${API_BASE}/signin?redirect=/signin`}
71+
className="w-full border-0 rounded-2xl"
72+
style={{ height: '700px', maxHeight: '85vh' }}
73+
title="Sign In"
74+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation allow-storage-access-by-user-activation"
75+
allow="camera 'none'; microphone 'none'; geolocation 'none'"
76+
/>
77+
</div>
78+
</div>
79+
</div>
80+
);
81+
82+
// Render in a portal to ensure it's at the document root level
83+
if (typeof document !== 'undefined') {
84+
return createPortal(modalContent, document.body);
85+
}
86+
87+
return null;
88+
}

src/app/ai/AuthProvider.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"use client";
2+
3+
import { useAuth } from "./useAuth";
4+
import { AuthContext } from "./AuthContext";
5+
import { AuthIframe } from "./AuthIframe";
6+
7+
export function AuthProvider({ children }: { children: React.ReactNode }) {
8+
const {
9+
authSession,
10+
showAuthIframe,
11+
setShowAuthIframe,
12+
triggerAuth,
13+
handleAuthSuccess,
14+
handleAuthClose,
15+
logout,
16+
useNewFlow
17+
} = useAuth();
18+
19+
const authContextValue = {
20+
authSession,
21+
showAuthIframe,
22+
setShowAuthIframe,
23+
triggerAuth,
24+
handleAuthSuccess,
25+
handleAuthClose,
26+
logout,
27+
useNewFlow
28+
};
29+
30+
return (
31+
<AuthContext.Provider value={authContextValue}>
32+
{children}
33+
{showAuthIframe && (
34+
<AuthIframe
35+
onAuthSuccess={handleAuthSuccess}
36+
onClose={handleAuthClose}
37+
/>
38+
)}
39+
</AuthContext.Provider>
40+
);
41+
}

0 commit comments

Comments
 (0)